From e82b24550b4c326c8b164e640777eaab64069ecd Mon Sep 17 00:00:00 2001 From: Russell Johnson <47639332+Russ4262@users.noreply.github.com> Date: Tue, 25 Feb 2020 19:12:43 -0600 Subject: [PATCH] ProfileEdges: New feature - profile open edges Open edges, exterior or interior, can now be used to generate paths. The new feature behaves as though the parent face is vertically oriented (standing). It is preferred that the user select upper (top) edges. Selecting bottom edges in some cases may not produce a path. Path ends calculations might need slight adjustment in the code. Path ends are *near* perpendicular to ends of edge. Cut direction seems sporadic, but the `Direction` property will switch the direction of cut. The `CW` and `CCW` direction labels might not align with the actual direction. Additional algorithm modification is necessary to enforce fidelity to the labels. Existing properties, `UseComp` and `OffsetExtra` are observed with open faces. Negative values for `OffsetExtra` have not been tested - only zero and positive. --- src/Mod/Path/PathScripts/PathAreaOp.py | 78 +- src/Mod/Path/PathScripts/PathProfileEdges.py | 941 ++++++++++++++++++- 2 files changed, 995 insertions(+), 24 deletions(-) diff --git a/src/Mod/Path/PathScripts/PathAreaOp.py b/src/Mod/Path/PathScripts/PathAreaOp.py index 1a7a4f6f53..965e5d89e5 100644 --- a/src/Mod/Path/PathScripts/PathAreaOp.py +++ b/src/Mod/Path/PathScripts/PathAreaOp.py @@ -30,21 +30,17 @@ import PathScripts.PathUtils as PathUtils import PathScripts.PathGeom as PathGeom import Draft import math +import Part # from PathScripts.PathUtils import waiting_effects from PySide import QtCore if FreeCAD.GuiUp: import FreeCADGui - __title__ = "Base class for PathArea based operations." __author__ = "sliptonic (Brad Collette)" __url__ = "http://www.freecadweb.org" __doc__ = "Base class and properties for Path.Area based operations." -__contributors__ = "russ4262 (Russell Johnson)" -__createdDate__ = "2017" -__scriptVersion__ = "2p" -__lastModified__ = "2020-02-13 17:11 CST" LOGLEVEL = PathLog.Level.INFO PathLog.setLevel(LOGLEVEL, PathLog.thisModule()) @@ -53,8 +49,6 @@ if LOGLEVEL is PathLog.Level.DEBUG: PathLog.trackModule() # Qt translation handling - - def translate(context, text, disambig=None): return QtCore.QCoreApplication.translate(context, text, disambig) @@ -286,6 +280,59 @@ class ObjectOp(PathOp.ObjectOp): return pp, simobj + def _buildProfileOpenEdges(self, obj, baseShape, isHole, start, getsim): + '''_buildPathArea(obj, baseShape, isHole, start, getsim) ... internal function.''' + # pylint: disable=unused-argument + PathLog.track() + + paths = [] + heights = [i for i in self.depthparams] + 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)) + + 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 not self.areaOpRetractTool(obj): + # pathParams['threshold'] = 2.001 * self.radius + + 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)) + + (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 + + simobj = None + if getsim and False: + areaParams['ToolRadius'] = self.radius - self.radius * .005 + area.setParams(**areaParams) + sec = area.makeSections(mode=0, project=False, heights=heights)[-1].getShape() + simobj = sec.extrude(FreeCAD.Vector(0, 0, baseobject.BoundBox.ZMax)) + + return paths, simobj + def opExecute(self, obj, getsim=False): # pylint: disable=arguments-differ '''opExecute(obj, getsim=False) ... implementation of Path.Area ops. determines the parameters for _buildPathArea(). @@ -306,6 +353,7 @@ class ObjectOp(PathOp.ObjectOp): self.tempObjectNames = [] # pylint: disable=attribute-defined-outside-init self.stockBB = PathUtils.findParentJob(obj).Stock.Shape.BoundBox # pylint: disable=attribute-defined-outside-init self.useTempJobClones('Delete') # Clear temporary group and recreate for temp job clones + self.profileEdgesIsOpen = False if obj.EnableRotation != 'Off': # Calculate operation heights based upon rotation radii @@ -384,9 +432,13 @@ class ObjectOp(PathOp.ObjectOp): shapes = [j['shape'] for j in jobs] + if self.profileEdgesIsOpen is True: + if PathOp.FeatureStartPoint & self.opFeatures(obj) and obj.UseStartPoint: + osp = obj.StartPoint + self.commandlist.append(Path.Command('G0', {'X': osp.x, 'Y': osp.y, 'F': self.horizRapid})) + sims = [] numShapes = len(shapes) - for ns in range(0, numShapes): (shape, isHole, sub, angle, axis, strDep, finDep) = shapes[ns] # pylint: disable=unused-variable if ns < numShapes - 1: @@ -405,12 +457,18 @@ class ObjectOp(PathOp.ObjectOp): user_depths=None) try: - (pp, sim) = self._buildPathArea(obj, shape, isHole, start, getsim) + if self.profileEdgesIsOpen is True: + (pp, sim) = self._buildProfileOpenEdges(obj, shape, isHole, start, getsim) + else: + (pp, sim) = self._buildPathArea(obj, shape, isHole, start, getsim) except Exception as e: # pylint: disable=broad-except FreeCAD.Console.PrintError(e) FreeCAD.Console.PrintError("Something unexpected happened. Check project and tool config.") else: - ppCmds = pp.Commands + if self.profileEdgesIsOpen is True: + ppCmds = pp + else: + ppCmds = pp.Commands if obj.EnableRotation != 'Off' and self.rotateFlag is True: # Rotate model to index for cut if axis == 'X': diff --git a/src/Mod/Path/PathScripts/PathProfileEdges.py b/src/Mod/Path/PathScripts/PathProfileEdges.py index b39f6c7447..5fce9b0774 100644 --- a/src/Mod/Path/PathScripts/PathProfileEdges.py +++ b/src/Mod/Path/PathScripts/PathProfileEdges.py @@ -30,16 +30,19 @@ import PathScripts.PathOp as PathOp import PathScripts.PathProfileBase as PathProfileBase import PathScripts.PathUtils as PathUtils -from DraftGeomUtils import findWires -from PySide import QtCore +import DraftGeomUtils +import Draft +import math +import PySide PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) -#PathLog.trackModule(PathLog.thisModule()) +# PathLog.trackModule(PathLog.thisModule()) # Qt translation handling def translate(context, text, disambig=None): - return QtCore.QCoreApplication.translate(context, text, disambig) + return PySide.QtCore.QCoreApplication.translate(context, text, disambig) + __title__ = "Path Profile Edges Operation" __author__ = "sliptonic (Brad Collette)" @@ -63,9 +66,19 @@ class ObjectProfile(PathProfileBase.ObjectProfile): '''areaOpShapes(obj) ... returns envelope for all wires formed by the base edges.''' PathLog.track() + self.tmpGrp = FreeCAD.ActiveDocument.addObject('App::DocumentObjectGroup', 'tmpDebugGrp') + tmpGrpNm = self.tmpGrp.Name + self.JOB = PathUtils.findParentJob(obj) + + self.offsetExtra = abs(obj.OffsetExtra.Value) + if obj.UseComp: + self.useComp = True + self.ofstRadius = self.radius + self.offsetExtra self.commandlist.append(Path.Command("(Compensated Tool Path. Diameter: " + str(self.radius * 2) + ")")) else: + self.useComp = False + self.ofstRadius = self.offsetExtra self.commandlist.append(Path.Command("(Uncompensated Tool Path)")) shapes = [] @@ -77,26 +90,926 @@ class ObjectProfile(PathProfileBase.ObjectProfile): edgelist = [] for sub in b[1]: edgelist.append(getattr(b[0].Shape, sub)) - basewires.append((b[0], findWires(edgelist))) + basewires.append((b[0], DraftGeomUtils.findWires(edgelist))) if zMin is None or b[0].Shape.BoundBox.ZMin < zMin: zMin = b[0].Shape.BoundBox.ZMin - for base,wires in basewires: + PathLog.debug('PathProfileEdges areaOpShapes():: len(basewires) is {}'.format(len(basewires))) + for base, wires in basewires: for wire in wires: - f = Part.makeFace(wire, 'Part::FaceMakerSimple') + if wire.isClosed() is True: + # f = Part.makeFace(wire, 'Part::FaceMakerSimple') + # if planar error, Comment out previous line, uncomment the next two + (origWire, flatWire) = self._flattenWire(obj, wire, obj.FinalDepth.Value) + f = origWire.Shape.Wires[0] + if f is not False: + # shift the compound to the bottom of the base object for proper sectioning + zShift = zMin - 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)) + else: + PathLog.error(translate('PathProfileEdges', 'The selected edge(s) are inaccessible.')) + else: + cutWireObjs = False + (origWire, flatWire) = self._flattenWire(obj, wire, obj.FinalDepth.Value) + cutShp = self._getCutAreaCrossSection(obj, base, origWire, flatWire) + if cutShp is not False: + cutWireObjs = self._extractPathWire(obj, base, flatWire, cutShp) + + if cutWireObjs is not False: + for cW in cutWireObjs: + shapes.append((cW, False)) + self.profileEdgesIsOpen = True + else: + PathLog.error(translate('PathProfileEdges', 'The selected edge(s) are inaccessible.')) + + # Delete the temporary objects + if PathLog.getLevel(PathLog.thisModule()) != 4: + for to in self.tmpGrp.Group: + FreeCAD.ActiveDocument.removeObject(to.Name) + FreeCAD.ActiveDocument.removeObject(tmpGrpNm) + else: + if FreeCAD.GuiUp: + import FreeCADGui + FreeCADGui.ActiveDocument.getObject(tmpGrpNm).Visibility = False - # shift the compound to the bottom of the base object for - # proper sectioning - zShift = zMin - 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)) return shapes + def _flattenWire(self, obj, wire, trgtDep): + '''_flattenWire(obj, wire)... Return a flattened version of the wire''' + PathLog.debug('_flattenWire()') + wBB = wire.BoundBox + tmpGrp = self.tmpGrp + + OW = FreeCAD.ActiveDocument.addObject('Part::Feature', 'tmpOriginalWire') + OW.Shape = wire + OW.purgeTouched() + tmpGrp.addObject(OW) + + if wBB.ZLength > 0.0: + PathLog.debug('Wire is not horizontally co-planar. Flattening it.') + + # Extrude non-horizontal wire + extFwdLen = wBB.ZLength * 2.2 + mbbEXT = self._extrudeObject(OW, extFwdLen, False) + + # Create cross-section of shape and translate + sliceZ = wire.BoundBox.ZMin + (extFwdLen / 2) + crsectFaceShp = self._makeCrossSection(mbbEXT.Shape, sliceZ, trgtDep) + if crsectFaceShp is not False: + # srtWire = Part.Wire(Part.__sortEdges__(crsectFaceShp.Edges)) + FW = FreeCAD.ActiveDocument.addObject('Part::Feature', 'tmpFlattenedWire') + FW.Shape = crsectFaceShp # srtWire + FW.recompute() + FW.purgeTouched() + tmpGrp.addObject(FW) + + return (OW, FW) + else: + return False + else: + srtWire = Part.Wire(Part.__sortEdges__(wire.Edges)) + srtWire.translate(FreeCAD.Vector(0, 0, trgtDep - srtWire.BoundBox.ZMin)) + FW = FreeCAD.ActiveDocument.addObject('Part::Feature', 'tmpOriginalWireSorted') + FW.Shape = srtWire + FW.purgeTouched() + tmpGrp.addObject(FW) + + return (OW, FW) + + def _getCutAreaCrossSection(self, obj, base, origWire, flatWireObj): + PathLog.debug('_getCutAreaCrossSection()') + tmpGrp = self.tmpGrp + FCAD = FreeCAD.ActiveDocument + tolerance = self.JOB.GeometryTolerance.Value + # toolDiam = float(obj.ToolController.Tool.Diameter) + toolDiam = 2 * self.radius # self.radius defined in PathAreaOp or PathprofileBase modules + minBfr = toolDiam * 1.25 + bbBfr = (self.ofstRadius * 2) * 1.25 + if bbBfr < minBfr: + bbBfr = minBfr + fwBB = flatWireObj.Shape.BoundBox + wBB = origWire.Shape.BoundBox + minArea = (self.ofstRadius - tolerance)**2 * math.pi + + useWire = origWire.Shape.Wires[0] + numOrigEdges = len(useWire.Edges) + sdv = wBB.ZMax + fdv = obj.FinalDepth.Value + extLenFwd = sdv - fdv + WIRE = flatWireObj.Shape.Wires[0] + numEdges = len(WIRE.Edges) + + # Identify first/last edges and first/last vertex on wire + begE = WIRE.Edges[0] # beginning edge + endE = WIRE.Edges[numEdges - 1] # ending edge + blen = begE.Length + elen = endE.Length + Vb = begE.Vertexes[0] # first vertex of wire + Ve = endE.Vertexes[1] # last vertex of wire + pb = FreeCAD.Vector(Vb.X, Vb.Y, fdv) + pe = FreeCAD.Vector(Ve.X, Ve.Y, fdv) + + # Identify endpoints connecting circle center and diameter + vectDist = pe.sub(pb) + diam = vectDist.Length + cntr = vectDist.multiply(0.5).add(pb) + R = diam / 2 + + pl = FreeCAD.Placement() + pl.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 0) + pl.Base = FreeCAD.Vector(0, 0, 0) + + # Obtain beginning point perpendicular points + if blen > 0.1: + bcp = begE.valueAt(begE.getParameterByLength(0.1)) # point returned 0.1 mm along edge + else: + bcp = FreeCAD.Vector(begE.Vertexes[1].X, begE.Vertexes[1].Y, fdv) + if elen > 0.1: + ecp = endE.valueAt(endE.getParameterByLength(elen - 0.1)) # point returned 0.1 mm along edge + else: + ecp = FreeCAD.Vector(endE.Vertexes[1].X, endE.Vertexes[1].Y, fdv) + + # Create intersection tags for determining which side of wire to cut + (begInt, begExt, iTAG, eTAG) = self._makeIntersectionTags(useWire, numOrigEdges, fdv) + self.iTAG = iTAG + self.eTAG = eTAG + + # Create extended wire boundbox, and extrude + extBndbox = self._makeExtendedBoundBox(wBB, bbBfr, fdv) + extBndboxEXT = self._extrudeObject(extBndbox, extLenFwd) # (objToExt, extFwdLen) + + # Cut model(selected edges) from extended edges boundbox + cutArea = extBndboxEXT.Shape.cut(base.Shape) + CA = FCAD.addObject('Part::Feature', 'tmpBndboxCutByBase') + CA.Shape = cutArea + CA.purgeTouched() + tmpGrp.addObject(CA) + + # Get top and bottom faces of cut area (CA), and combine faces when necessary + topFc = list() + botFc = list() + bbZMax = CA.Shape.BoundBox.ZMax + bbZMin = CA.Shape.BoundBox.ZMin + for f in range(0, len(CA.Shape.Faces)): + Fc = CA.Shape.Faces[f] + if abs(Fc.BoundBox.ZMax - bbZMax) < tolerance and abs(Fc.BoundBox.ZMin - bbZMax) < tolerance: + topFc.append(f) + if abs(Fc.BoundBox.ZMax - bbZMin) < tolerance and abs(Fc.BoundBox.ZMin - bbZMin) < tolerance: + botFc.append(f) + topComp = Part.makeCompound([CA.Shape.Faces[f] for f in topFc]) + topComp.translate(FreeCAD.Vector(0, 0, fdv - topComp.BoundBox.ZMin)) # Translate face to final depth + if len(botFc) > 1: + PathLog.debug('len(botFc) > 1') + bndboxFace = Part.Face(extBndbox.Shape.Wires[0]) + tmpFace = Part.Face(extBndbox.Shape.Wires[0]) + for f in botFc: + Q = tmpFace.cut(CA.Shape.Faces[f]) + tmpFace = Q + botComp = bndboxFace.cut(tmpFace) + else: + botComp = Part.makeCompound([CA.Shape.Faces[f] for f in botFc]) + botComp.translate(FreeCAD.Vector(0, 0, fdv - botComp.BoundBox.ZMin)) # Translate face to final depth + + # Convert compound shapes to FC objects for use in multicommon operation + TP = FCAD.addObject('Part::Feature', 'tmpTopCompound') + TP.Shape = topComp + TP.recompute() + TP.purgeTouched() + tmpGrp.addObject(TP) + BT = FCAD.addObject('Part::Feature', 'tmpBotCompound') + BT.Shape = botComp + BT.recompute() + BT.purgeTouched() + tmpGrp.addObject(BT) + + # Make common of the two + comFC = FCAD.addObject('Part::MultiCommon', 'tmpCommonTopBotFaces') + comFC.Shapes = [TP, BT] + comFC.recompute() + TP.purgeTouched() + BT.purgeTouched() + comFC.purgeTouched() + tmpGrp.addObject(comFC) + + # Determine with which set of intersection tags the model intersects + (cmnIntArea, cmnExtArea) = self._checkTagIntersection(iTAG, eTAG, 'QRY', comFC) + if cmnExtArea > cmnIntArea: + PathLog.debug('Cutting on Ext side.') + self.cutSide = 'E' + self.cutSideTags = eTAG.Shape + tagCOM = begExt.CenterOfMass + else: + PathLog.debug('Cutting on Int side.') + self.cutSide = 'I' + self.cutSideTags = iTAG.Shape + tagCOM = begInt.CenterOfMass + + # Make two beginning style(oriented) 'L' shape stops + begStop = self._makeStop('BEG', bcp, pb, 'BegStop') + altBegStop = self._makeStop('END', bcp, pb, 'BegStop') + + # Identify to which style 'L' stop the beginning intersection tag is closest, + # and create partner end 'L' stop geometry, and save for application later + lenBS_extETag = begStop.CenterOfMass.sub(tagCOM).Length + lenABS_extETag = altBegStop.CenterOfMass.sub(tagCOM).Length + if lenBS_extETag < lenABS_extETag: + endStop = self._makeStop('END', ecp, pe, 'EndStop') + pathStops = Part.makeCompound([begStop, endStop]) + else: + altEndStop = self._makeStop('BEG', ecp, pe, 'EndStop') + pathStops = Part.makeCompound([altBegStop, altEndStop]) + pathStops.translate(FreeCAD.Vector(0, 0, fdv - pathStops.BoundBox.ZMin)) + + # Identify closed wire in cross-section that corresponds to user-selected edge(s) + workShp = comFC.Shape + fcShp = workShp + wire = origWire.Shape # flatWireObj.Shape + WS = workShp.Wires + lenWS = len(WS) + if lenWS < 3: + wi = 0 + else: + wi = None + for wvt in wire.Vertexes: + for w in range(0, lenWS): + twr = WS[w] + for v in range(0, len(twr.Vertexes)): + V = twr.Vertexes[v] + if abs(V.X - wvt.X) < tolerance: + if abs(V.Y - wvt.Y) < tolerance: + # Same vertex found. This wire to be used for offset + wi = w + break + # Efor + + if wi is None: + PathLog.error('The cut area cross-section wire does not coincide with selected edge. Wires[] index is None.') + tmpGrp.purgeTouched() + return False + else: + PathLog.debug('Cross-section Wires[] index is {}.'.format(wi)) + + nWire = Part.Wire(Part.__sortEdges__(workShp.Wires[wi].Edges)) + fcShp = Part.Face(nWire) + fcShp.translate(FreeCAD.Vector(0, 0, fdv - workShp.BoundBox.ZMin)) + # Eif + + # verify that wire chosen is not inside the physical model + if wi > 0: # and isInterior is False: + PathLog.debug('Multiple wires in cut area. First choice is not 0. Testing.') + testArea = fcShp.cut(base.Shape) + # testArea = fcShp + TA = FreeCAD.ActiveDocument.addObject('Part::Feature','tmpCutFaceTest') + TA.Shape = testArea + TA.purgeTouched() + tmpGrp.addObject(TA) + + isReady = self._checkTagIntersection(iTAG, eTAG, self.cutSide, TA) + PathLog.debug('isReady {}.'.format(isReady)) + + if isReady is False: + PathLog.debug('Using wire index {}.'.format(wi - 1)) + pWire = Part.Wire(Part.__sortEdges__(workShp.Wires[wi - 1].Edges)) + pfcShp = Part.Face(pWire) + pfcShp.translate(FreeCAD.Vector(0, 0, fdv - workShp.BoundBox.ZMin)) + workShp = pfcShp.cut(fcShp) + + if testArea.Area < minArea: + PathLog.debug('offset area is less than minArea of {}.'.format(minArea)) + PathLog.debug('Using wire index {}.'.format(wi - 1)) + pWire = Part.Wire(Part.__sortEdges__(workShp.Wires[wi - 1].Edges)) + pfcShp = Part.Face(pWire) + pfcShp.translate(FreeCAD.Vector(0, 0, fdv - workShp.BoundBox.ZMin)) + workShp = pfcShp.cut(fcShp) + # Eif + + # Add path stops at ends of wire + cutShp = workShp.cut(pathStops) + + CF = FreeCAD.ActiveDocument.addObject('Part::Feature','tmpCutFace') + CF.Shape = cutShp + CF.recompute() + CF.purgeTouched() + tmpGrp.addObject(CF) + + tmpGrp.purgeTouched() + return cutShp # CF.Shape + + def _checkTagIntersection(self, iTAG, eTAG, cutSide, tstObj): + # Identify intersection of Common area and Interior Tags + intCmn = FreeCAD.ActiveDocument.addObject('Part::MultiCommon', 'tmpCmnIntTags') + intCmn.Shapes = [tstObj, iTAG] + intCmn.recompute() + tstObj.purgeTouched() + iTAG.purgeTouched() + intCmn.purgeTouched() + self.tmpGrp.addObject(intCmn) + + # Identify intersection of Common area and Exterior Tags + extCmn = FreeCAD.ActiveDocument.addObject('Part::MultiCommon', 'tmpCmnExtTags') + extCmn.Shapes = [tstObj, eTAG] + extCmn.recompute() + tstObj.purgeTouched() + eTAG.purgeTouched() + extCmn.purgeTouched() + self.tmpGrp.addObject(extCmn) + + # Calculate common intersection (solid model side, or the non-cut side) area with tags, to determine physical cut side + cmnIntArea = intCmn.Shape.Area + cmnExtArea = extCmn.Shape.Area + if cutSide == 'QRY': + return (cmnIntArea, cmnExtArea) + + if cmnExtArea > cmnIntArea: + PathLog.debug('Cutting on Ext side.') + if cutSide == 'E': + return True + else: + PathLog.debug('Cutting on Int side.') + if cutSide == 'I': + return True + return False + + def _extractPathWire(self, obj, base, fWire, cutShp): + PathLog.debug('_extractPathWire()') + + subLoops = list() + rtnWIRES = list() + osWrIdxs = list() + subDistFactor = 1.0 # Raise to include sub wires at greater distance from original + tmpGrp = self.tmpGrp + fdv = obj.FinalDepth.Value + wire = fWire.Shape + lstVrtIdx = len(wire.Vertexes) - 1 + lstVrt = wire.Vertexes[lstVrtIdx] + frstVrt = wire.Vertexes[0] + cent0 = FreeCAD.Vector(frstVrt.X, frstVrt.Y, fdv) + cent1 = FreeCAD.Vector(lstVrt.X, lstVrt.Y, fdv) + + pl = FreeCAD.Placement() + pl.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 0) + pl.Base = FreeCAD.Vector(0, 0, 0) + + # Calculate offset shape, containing cut region + ofstShp = self._extractFaceOffset(obj, cutShp, False) + + # CHECK for ZERO area of offset shape + try: + osArea = ofstShp.Area + except Exception as ee: + PathLog.error('No area to offset shape returned.') + tmpGrp.purgeTouched() + return False + + os = FreeCAD.ActiveDocument.addObject('Part::Feature', 'tmpOffsetShape') + os.Shape = ofstShp + os.recompute() + os.purgeTouched() + tmpGrp.addObject(os) + + numOSWires = len(ofstShp.Wires) + for w in range(0, numOSWires): + osWrIdxs.append(w) + + # Identify two vertexes for dividing offset loop + NEAR0 = self._findNearestVertex(ofstShp, cent0) + min0i = 0 + min0 = NEAR0[0][4] + for n in range(0, len(NEAR0)): + N = NEAR0[n] + if N[4] < min0: + min0 = N[4] + min0i = n + (w0, vi0, pnt0, vrt0, d0) = NEAR0[0] # min0i + near0 = Draft.makeWire([cent0, pnt0], placement=pl, closed=False, face=False, support=None) + near0.recompute() + near0.purgeTouched() + tmpGrp.addObject(near0) + + NEAR1 = self._findNearestVertex(ofstShp, cent1) + min1i = 0 + min1 = NEAR1[0][4] + for n in range(0, len(NEAR1)): + N = NEAR1[n] + if N[4] < min1: + min1 = N[4] + min1i = n + (w1, vi1, pnt1, vrt1, d1) = NEAR1[0] # min1i + near1 = Draft.makeWire([cent1, pnt1], placement=pl, closed=False, face=False, support=None) + near1.recompute() + near1.purgeTouched() + tmpGrp.addObject(near1) + + if w0 != w1: + PathLog.debug('w0 is {}.'.format(w0)) + PathLog.debug('w1 is {}.'.format(w1)) + PathLog.warning('Offset wire endpoint indexes are not equal: {}, {}'.format(w0, w1)) + + ''' + PathLog.debug('min0i is {}.'.format(min0i)) + PathLog.debug('min1i is {}.'.format(min1i)) + PathLog.debug('NEAR0[{}] is {}.'.format(w0, NEAR0[w0])) + PathLog.debug('NEAR1[{}] is {}.'.format(w1, NEAR1[w1])) + PathLog.debug('NEAR0 is {}.'.format(NEAR0)) + PathLog.debug('NEAR1 is {}.'.format(NEAR1)) + ''' + + mainWire = ofstShp.Wires[w0] + + # Check for additional closed loops in offset wire by checking distance to iTAG or eTAG elements + if numOSWires > 1: + # check all wires for proximity(children) to intersection tags + tagsComList = list() + for T in self.cutSideTags.Faces: + tcom = T.CenterOfMass + tv = FreeCAD.Vector(tcom.x, tcom.y, 0.0) + tagsComList.append(tv) + subDist = self.ofstRadius * subDistFactor + for w in osWrIdxs: + if w != w0: + cutSub = False + VTXS = ofstShp.Wires[w].Vertexes + for V in VTXS: + v = FreeCAD.Vector(V.X, V.Y, 0.0) + for t in tagsComList: + if t.sub(v).Length < subDist: + cutSub = True + break + if cutSub is True: + break + if cutSub is True: + sub = Part.Wire(Part.__sortEdges__(ofstShp.Wires[w].Edges)) + subLoops.append(sub) + # Eif + + # Break offset loop into two wires - one of which is the desired profile path wire. + # (edgeIdxs0, edgeIdxs1) = self._separateWireAtVertexes(mainWire, ofstShp.Vertexes[vi0], ofstShp.Vertexes[vi1]) + (edgeIdxs0, edgeIdxs1) = self._separateWireAtVertexes(mainWire, mainWire.Vertexes[vi0], mainWire.Vertexes[vi1]) + edgs0 = list() + edgs1 = list() + for e in edgeIdxs0: + edgs0.append(mainWire.Edges[e]) + for e in edgeIdxs1: + edgs1.append(mainWire.Edges[e]) + part0 = Part.Wire(Part.__sortEdges__(edgs0)) + part1 = Part.Wire(Part.__sortEdges__(edgs1)) + + # Determine which part is nearest original edge(s) + distToPart0 = self._distMidToMid(wire.Wires[0], part0.Wires[0]) + distToPart1 = self._distMidToMid(wire.Wires[0], part1.Wires[0]) + if distToPart0 < distToPart1: + rtnWIRES.append(part0) + else: + rtnWIRES.append(part1) + rtnWIRES.extend(subLoops) + + tmpGrp.purgeTouched() + return rtnWIRES + + def _extractFaceOffset(self, obj, fcShape, isHole): + '''_extractFaceOffset(obj, fcShape, isHole) ... internal function. + Original _buildPathArea() version copied from PathAreaOp.py module. This version is modified. + Adjustments made based on notes by @sliptonic - https://github.com/sliptonic/FreeCAD/wiki/PathArea-notes.''' + PathLog.debug('_extractFaceOffset()') + + areaParams = {} + JOB = PathUtils.findParentJob(obj) + tolrnc = JOB.GeometryTolerance.Value + if self.useComp is True: + offset = self.ofstRadius # + tolrnc + else: + offset = self.offsetExtra # + tolrnc + + if isHole is False: + offset = 0 - offset + + areaParams['Offset'] = offset + areaParams['Fill'] = 1 + areaParams['Coplanar'] = 0 + areaParams['SectionCount'] = 1 # -1 = full(all per depthparams??) sections + areaParams['Reorient'] = True + areaParams['OpenMode'] = 0 + areaParams['MaxArcPoints'] = 400 # 400 + areaParams['Project'] = True + # areaParams['JoinType'] = 1 + + area = Path.Area() # Create instance of Area() class object + area.setPlane(PathUtils.makeWorkplane(fcShape)) # Set working plane + area.add(fcShape) # obj.Shape to use for extracting offset + area.setParams(**areaParams) # set parameters + + # Save parameters for debugging + # obj.AreaParams = str(area.getParams()) + # PathLog.debug("Area with params: {}".format(area.getParams())) + + offsetShape = area.getShape() + + return offsetShape + + def _findNearestVertex(self, shape, point): + PathLog.debug('_findNearestVertex()') + PT = FreeCAD.Vector(point.x, point.y, 0.0) + + def sortDist(tup): + return tup[4] + + PNTS = list() + for w in range(0, len(shape.Wires)): + WR = shape.Wires[w] + V = WR.Vertexes[0] + P = FreeCAD.Vector(V.X, V.Y, 0.0) + dist = P.sub(PT).Length + vi = 0 + pnt = P + vrt = V + for v in range(0, len(WR.Vertexes)): + V = WR.Vertexes[v] + P = FreeCAD.Vector(V.X, V.Y, 0.0) + d = P.sub(PT).Length + if d < dist: + dist = d + vi = v + pnt = P + vrt = V + PNTS.append((w, vi, pnt, vrt, dist)) + PNTS.sort(key=sortDist) + return PNTS + + def _separateWireAtVertexes(self, wire, VV1, VV2): + PathLog.debug('_separateWireAtVertexes()') + tolerance = self.JOB.GeometryTolerance.Value + grps = [[], []] + wireIdxs = [[], []] + V1 = FreeCAD.Vector(VV1.X, VV1.Y, VV1.Z) + V2 = FreeCAD.Vector(VV2.X, VV2.Y, VV2.Z) + + lenE = len(wire.Edges) + FLGS = list() + for e in range(0, lenE): + FLGS.append(0) + + chk4 = False + for e in range(0, lenE): + v = 0 + E = wire.Edges[e] + fv0 = FreeCAD.Vector(E.Vertexes[0].X, E.Vertexes[0].Y, E.Vertexes[0].Z) + fv1 = FreeCAD.Vector(E.Vertexes[1].X, E.Vertexes[1].Y, E.Vertexes[1].Z) + + if fv0.sub(V1).Length < tolerance: + v = 1 + if fv1.sub(V2).Length < tolerance: + v += 3 + chk4 = True + elif fv1.sub(V1).Length < tolerance: + v = 1 + if fv0.sub(V2).Length < tolerance: + v += 3 + chk4 = True + + if fv0.sub(V2).Length < tolerance: + v = 3 + if fv1.sub(V1).Length < tolerance: + v += 1 + chk4 = True + elif fv1.sub(V2).Length < tolerance: + v = 3 + if fv0.sub(V1).Length < tolerance: + v += 1 + chk4 = True + FLGS[e] += v + # Efor + PathLog.debug('_separateWireAtVertexes() FLGS: \n{}'.format(FLGS)) + + PRE = list() + POST = list() + IDXS = list() + IDX1 = list() + IDX2 = list() + for e in range(0, lenE): + f = FLGS[e] + PRE.append(f) + POST.append(f) + IDXS.append(e) + IDX1.append(e) + IDX2.append(e) + + PRE.extend(FLGS) + PRE.extend(POST) + lenFULL = len(PRE) + IDXS.extend(IDX1) + IDXS.extend(IDX2) + + if chk4 is True: + # find beginning 1 edge + begIdx = None + begFlg = False + for e in range(0, lenFULL): + f = PRE[e] + i = IDXS[e] + if f == 4: + begIdx = e + grps[0].append(f) + wireIdxs[0].append(i) + break + # find first 3 edge + endIdx = None + for e in range(begIdx + 1, lenE + begIdx): + f = PRE[e] + i = IDXS[e] + grps[1].append(f) + wireIdxs[1].append(i) + else: + # find beginning 1 edge + begIdx = None + begFlg = False + for e in range(0, lenFULL): + f = PRE[e] + if f == 1: + if begFlg is False: + begFlg = True + else: + begIdx = e + break + # find first 3 edge and group all first wire edges + endIdx = None + for e in range(begIdx, lenE + begIdx): + f = PRE[e] + i = IDXS[e] + if f == 3: + grps[0].append(f) + wireIdxs[0].append(i) + endIdx = e + break + else: + grps[0].append(f) + wireIdxs[0].append(i) + # Collect remaining edges + for e in range(endIdx + 1, lenFULL): + f = PRE[e] + i = IDXS[e] + if f == 1: + grps[1].append(f) + wireIdxs[1].append(i) + break + else: + wireIdxs[1].append(i) + grps[1].append(f) + # Efor + # Eif + + if PathLog.getLevel(PathLog.thisModule()) != 4: + PathLog.debug('grps[0]: {}'.format(grps[0])) + PathLog.debug('grps[1]: {}'.format(grps[1])) + PathLog.debug('wireIdxs[0]: {}'.format(wireIdxs[0])) + PathLog.debug('wireIdxs[1]: {}'.format(wireIdxs[1])) + PathLog.debug('PRE: {}'.format(PRE)) + PathLog.debug('IDXS: {}'.format(IDXS)) + + return (wireIdxs[0], wireIdxs[1]) + + def _makeCrossSection(self, shape, sliceZ, zHghtTrgt=False): + '''_makeCrossSection(shape, sliceZ, zHghtTrgt=None)... + Creates cross-section objectc from shape. Translates cross-section to zHghtTrgt if available. + Makes face shape from cross-section object. Returns face shape at zHghtTrgt.''' + # Create cross-section of shape and translate + wires = list() + slcs = shape.slice(FreeCAD.Vector(0, 0, 1), sliceZ) + if len(slcs) > 0: + for i in slcs: + wires.append(i) + comp = Part.Compound(wires) + if zHghtTrgt is not False: + comp.translate(FreeCAD.Vector(0, 0, zHghtTrgt - comp.BoundBox.ZMin)) + return comp + + return False + + def _makeExtendedBoundBox(self, wBB, bbBfr, zDep): + pl = FreeCAD.Placement() + pl.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 0) + pl.Base = FreeCAD.Vector(0, 0, 0) + + p1 = FreeCAD.Vector(wBB.XMin - bbBfr, wBB.YMin - bbBfr, zDep) + p2 = FreeCAD.Vector(wBB.XMax + bbBfr, wBB.YMin - bbBfr, zDep) + p3 = FreeCAD.Vector(wBB.XMax + bbBfr, wBB.YMax + bbBfr, zDep) + p4 = FreeCAD.Vector(wBB.XMin - bbBfr, wBB.YMax + bbBfr, zDep) + bb = Draft.makeWire([p1, p2, p3, p4], placement=pl, closed=True, face=False, support=None) + bb.Label = 'ProfileEdges_BoundBox' + bb.recompute() + bb.purgeTouched() + self.tmpGrp.addObject(bb) + + return bb + + def _makeSimpleCircle(self, rad, plcmnt, isFace=False, label='SimpleCircle'): + C = Draft.makeCircle(rad, placement=plcmnt, face=isFace) + C.Label = 'tmp' + label + C.recompute() + C.purgeTouched() + self.tmpGrp.addObject(C) + return C + + def _makeIntersectionTags(self, useWire, numOrigEdges, fdv): + # Create circular probe tags around perimiter of wire + extTags = list() + intTags = list() + tagRad = (self.radius / 2) + tagCnt = 0 + begInt = None + begExt = None + for e in range(0, numOrigEdges): + E = useWire.Edges[e] + LE = E.Length + if LE > (self.radius * 2): + nt = math.ceil(LE / (tagRad * math.pi)) # (tagRad * 2 * math.pi) is circumference + else: + nt = 4 # desired + 1 + mid = LE / nt + spc = self.radius / 10 + for i in range(0, nt): + if i == 0: + if e == 0: + if LE > 0.2: + aspc = 0.1 + else: + aspc = LE * 0.75 + cp1 = E.valueAt(E.getParameterByLength(0)) + cp2 = E.valueAt(E.getParameterByLength(aspc)) + (intTObj, extTObj) = self._makeOffsetCircleTag(cp1, cp2, tagRad, fdv, 'BeginEdge[{}]_'.format(e)) + if intTObj is not False: + begInt = intTObj.Shape + begExt = extTObj.Shape + else: + d = i * mid + cp1 = E.valueAt(E.getParameterByLength(d - spc)) + cp2 = E.valueAt(E.getParameterByLength(d + spc)) + (intTObj, extTObj) = self._makeOffsetCircleTag(cp1, cp2, tagRad, fdv, 'Edge[{}]_'.format(e)) + if intTObj is not False: + tagCnt += nt + intTags.append(intTObj) + extTags.append(extTObj) + tagArea = math.pi * tagRad**2 * tagCnt + # FreeCAD object required for Part::MultiCommon usage + intTagsComp = Part.makeCompound([T.Shape for T in intTags]) + iTAG = FreeCAD.ActiveDocument.addObject('Part::Feature', 'tmpInteriorTags') + iTAG.Shape = intTagsComp + iTAG.purgeTouched() + self.tmpGrp.addObject(iTAG) + # FreeCAD object required for Part::MultiCommon usage + extTagsComp = Part.makeCompound([T.Shape for T in extTags]) + eTAG = FreeCAD.ActiveDocument.addObject('Part::Feature', 'tmpExteriorTags') + eTAG.Shape = extTagsComp + eTAG.purgeTouched() + self.tmpGrp.addObject(eTAG) + return (begInt, begExt, iTAG, eTAG) + + def _makeOffsetCircleTag(self, p1, p2, cutterRad, depth, lbl, reverse=False): + pb = FreeCAD.Vector(p1.x, p1.y, 0.0) + pe = FreeCAD.Vector(p2.x, p2.y, 0.0) + + toMid = pe.sub(pb).multiply(0.5) + lenToMid = toMid.Length + if lenToMid == 0.0: + # Probably a vertical line segment + return (False, False) + + cutFactor = (cutterRad / 2.1) / lenToMid # = 2 is tangent to wire; > 2 allows tag to overlap wire; < 2 pulls tag away from wire + perpE = FreeCAD.Vector(-1 * toMid.y, toMid.x, 0.0).multiply(-1 * cutFactor) # exterior tag + extPnt = pb.add(toMid.add(perpE)) + + pl = FreeCAD.Placement() + pl.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 0) + # make exterior tag + adjlbl = lbl + 'Ext' + pl.Base = extPnt.add(FreeCAD.Vector(0, 0, depth)) + extTag = self._makeSimpleCircle((cutterRad / 2), pl, True, adjlbl) + + # make interior tag + adjlbl = lbl + 'Int' + perpI = FreeCAD.Vector(-1 * toMid.y, toMid.x, 0.0).multiply(cutFactor) # interior tag + intPnt = pb.add(toMid.add(perpI)) + pl.Base = intPnt.add(FreeCAD.Vector(0, 0, depth)) + intTag = self._makeSimpleCircle((cutterRad / 2), pl, True, adjlbl) + + return (intTag, extTag) + + def _extrudeObject(self, objToExt, extFwdLen, solid=True): + # Extrude non-horizontal wire + E = FreeCAD.ActiveDocument.addObject('Part::Extrusion', 'tmpExtrusion') + E.Base = objToExt + E.DirMode = 'Custom' + E.Dir = FreeCAD.Vector(0, 0, 1) + E.LengthFwd = extFwdLen + E.Solid = solid + E.recompute() + E.purgeTouched() + self.tmpGrp.addObject(E) + return E + + def _makeStop(self, sType, pA, pB, lbl): + rad = self.radius + ofstRad = self.ofstRadius + extra = self.radius / 10 + + pl = FreeCAD.Placement() + pl.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 0) + pl.Base = FreeCAD.Vector(0, 0, 0) + + E = FreeCAD.Vector(pB.x, pB.y, 0) # endpoint + C = FreeCAD.Vector(pA.x, pA.y, 0) # checkpoint + lenEC = E.sub(C).Length + + if self.useComp is True or (self.useComp is False and self.offsetExtra != 0): + # 'L' stop shape and edge legend + # --1-- + # | | + # 2 6 + # | | + # | ----5----| + # | 4 + # -----3-------| + # positive dist in _makePerp2DVector() is CCW rotation + p1 = E + if sType == 'BEG': + p2 = self._makePerp2DVector(C, E, -0.25) # E1 + p3 = self._makePerp2DVector(p1, p2, ofstRad + 1 + extra) # E2 + p4 = self._makePerp2DVector(p2, p3, 0.25 + ofstRad + extra) # E3 + p5 = self._makePerp2DVector(p3, p4, 1 + extra) # E4 + p6 = self._makePerp2DVector(p4, p5, ofstRad + extra) # E5 + elif sType == 'END': + p2 = self._makePerp2DVector(C, E, 0.25) # E1 + p3 = self._makePerp2DVector(p1, p2, -1 * (ofstRad + 1 + extra)) # E2 + p4 = self._makePerp2DVector(p2, p3, -1 * (0.25 + ofstRad + extra)) # E3 + p5 = self._makePerp2DVector(p3, p4, -1 * (1 + extra)) # E4 + p6 = self._makePerp2DVector(p4, p5, -1 * (ofstRad + extra)) # E5 + p7 = E # E6 + S = Draft.makeWire([p1, p2, p3, p4, p5, p6, p7], placement=pl, closed=True, face=True) + else: + # 'L' stop shape and edge legend + # : + # |----2-------| + # 3 1 + # |-----4------| + # positive dist in _makePerp2DVector() is CCW rotation + p1 = E + if sType == 'BEG': + p2 = self._makePerp2DVector(C, E, -1 * (0.25 + abs(self.offsetExtra))) # left, 0.25 + p3 = self._makePerp2DVector(p1, p2, 0.25 + abs(self.offsetExtra)) + p4 = self._makePerp2DVector(p2, p3, (0.5 + abs(self.offsetExtra))) # FIRST POINT + p5 = self._makePerp2DVector(p3, p4, 0.25 + abs(self.offsetExtra)) # E1 SECOND + elif sType == 'END': + p2 = self._makePerp2DVector(C, E, (0.25 + abs(self.offsetExtra))) # left, 0.25 + p3 = self._makePerp2DVector(p1, p2, -1 * (0.25 + abs(self.offsetExtra))) + p4 = self._makePerp2DVector(p2, p3, -1 * (0.5 + abs(self.offsetExtra))) # FIRST POINT + p5 = self._makePerp2DVector(p3, p4, -1 * (0.25 + abs(self.offsetExtra))) # E1 SECOND + p6 = p1 # E4 + S = Draft.makeWire([p1, p2, p3, p4, p5, p6], placement=pl, closed=True, face=True) + # Eif + S.Label = 'tmp' + lbl + S.recompute() + S.purgeTouched() + self.tmpGrp.addObject(S) + return S.Shape + + def _makePerp2DVector(self, v1, v2, dist): + p1 = FreeCAD.Vector(v1.x, v1.y, 0.0) + p2 = FreeCAD.Vector(v2.x, v2.y, 0.0) + toEnd = p2.sub(p1) + factor = dist / toEnd.Length + perp = FreeCAD.Vector(-1 * toEnd.y, toEnd.x, 0.0).multiply(factor) + return p1.add(toEnd.add(perp)) + + def _distMidToMid(self, wireA, wireB): + mpA = self._findWireMidpoint(wireA) + mpB = self._findWireMidpoint(wireB) + return mpA.sub(mpB).Length + + def _findWireMidpoint(self, wire): + midPnt = None + dist = 0.0 + wL = wire.Length + midW = wL / 2 + + for e in range(0, len(wire.Edges)): + E = wire.Edges[e] + elen = E.Length + d_ = dist + elen + if dist < midW and midW <= d_: + dtm = midW - dist + midPnt = E.valueAt(E.getParameterByLength(dtm)) + break + else: + dist += elen + return midPnt + + def SetupProperties(): return PathProfileBase.SetupProperties() + def Create(name, obj = None): '''Create(name) ... Creates and returns a Profile based on edges operation.''' if obj is None: