# SPDX-License-Identifier: LGPL-2.1-or-later

# ***************************************************************************
# *   Copyright (c) 2014 Yorik van Havre <yorik@uncreated.net>              *
# *   Copyright (c) 2016 sliptonic <shopinthewoods@gmail.com>               *
# *   Copyright (c) 2020 Schildkroet                                        *
# *                                                                         *
# *   This program is free software; you can redistribute it and/or modify  *
# *   it under the terms of the GNU Lesser General Public License (LGPL)    *
# *   as published by the Free Software Foundation; either version 2 of     *
# *   the License, or (at your option) any later version.                   *
# *   for detail see the LICENCE text file.                                 *
# *                                                                         *
# *   This program is distributed in the hope that it will be useful,       *
# *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
# *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
# *   GNU Library General Public License for more details.                  *
# *                                                                         *
# *   You should have received a copy of the GNU Library General Public     *
# *   License along with this program; if not, write to the Free Software   *
# *   Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  *
# *   USA                                                                   *
# *                                                                         *
# ***************************************************************************

import FreeCAD
import Path
import Path.Base.Drillable as Drillable
import Path.Op.Area as PathAreaOp
import Path.Op.Base as PathOp
import PathScripts.PathUtils as PathUtils
import math
import numpy
from PySide.QtCore import QT_TRANSLATE_NOOP

# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader

Part = LazyLoader("Part", globals(), "Part")
DraftGeomUtils = LazyLoader("DraftGeomUtils", globals(), "DraftGeomUtils")

translate = FreeCAD.Qt.translate

__title__ = "CAM Profile Operation"
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecad.org"
__doc__ = "Create a profile toolpath based on entire model, selected faces or selected edges."
__contributors__ = "Schildkroet"

if False:
    Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
    Path.Log.trackModule(Path.Log.thisModule())
else:
    Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())


class ObjectProfile(PathAreaOp.ObjectOp):
    """Proxy object for Profile operations based on faces."""

    def areaOpFeatures(self, obj):
        """areaOpFeatures(obj) ... returns operation-specific features"""
        return PathOp.FeatureBaseFaces | PathOp.FeatureBaseEdges

    def initAreaOp(self, obj):
        """initAreaOp(obj) ... creates all profile specific properties."""
        self.propertiesReady = False
        self.initAreaOpProperties(obj)

        obj.setEditorMode("MiterLimit", 2)
        obj.setEditorMode("JoinType", 2)

    def initAreaOpProperties(self, obj, warn=False):
        """initAreaOpProperties(obj) ... create operation specific properties"""
        self.addNewProps = []

        for propertytype, propertyname, grp, tt in self.areaOpProperties():
            if not hasattr(obj, propertyname):
                obj.addProperty(propertytype, propertyname, grp, tt)
                self.addNewProps.append(propertyname)

        if len(self.addNewProps) > 0:
            # Set enumeration lists for enumeration properties
            ENUMS = self.areaOpPropertyEnumerations()
            for n in ENUMS:
                if n[0] in self.addNewProps:
                    setattr(obj, n[0], n[1])
            if warn:
                newPropMsg = "New property added to"
                newPropMsg += ' "{}": {}'.format(obj.Label, self.addNewProps) + ". "
                newPropMsg += "Check its default value." + "\n"
                FreeCAD.Console.PrintWarning(newPropMsg)

        self.propertiesReady = True

    def areaOpProperties(self):
        """areaOpProperties(obj) ... returns a tuples.
        Each tuple contains property declaration information in the
        form of (prototype, name, section, tooltip)."""
        return [
            (
                "App::PropertyEnumeration",
                "Direction",
                "Profile",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "The direction that the toolpath should go around the part ClockWise (CW) or CounterClockWise (CCW)",
                ),
            ),
            (
                "App::PropertyEnumeration",
                "HandleMultipleFeatures",
                "Profile",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Choose how to process multiple Base Geometry features.",
                ),
            ),
            (
                "App::PropertyEnumeration",
                "JoinType",
                "Profile",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Controls how tool moves around corners. Default=Round",
                ),
            ),
            (
                "App::PropertyFloat",
                "MiterLimit",
                "Profile",
                QT_TRANSLATE_NOOP(
                    "App::Property", "Maximum distance before a miter joint is truncated"
                ),
            ),
            (
                "App::PropertyDistance",
                "OffsetExtra",
                "Profile",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Extra value to stay away from final profile- good for roughing toolpath",
                ),
            ),
            (
                "App::PropertyBool",
                "processHoles",
                "Profile",
                QT_TRANSLATE_NOOP("App::Property", "Profile holes as well as the outline"),
            ),
            (
                "App::PropertyBool",
                "processPerimeter",
                "Profile",
                QT_TRANSLATE_NOOP("App::Property", "Profile the outline"),
            ),
            (
                "App::PropertyBool",
                "processCircles",
                "Profile",
                QT_TRANSLATE_NOOP("App::Property", "Profile round holes"),
            ),
            (
                "App::PropertyEnumeration",
                "Side",
                "Profile",
                QT_TRANSLATE_NOOP("App::Property", "Side of edge that tool should cut"),
            ),
            (
                "App::PropertyBool",
                "UseComp",
                "Profile",
                QT_TRANSLATE_NOOP(
                    "App::Property", "Make True, if using Cutter Radius Compensation"
                ),
            ),
            (
                "App::PropertyInteger",
                "NumPasses",
                "Profile",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "The number of passes to do. If more than one, requires a non-zero value for Stepover",
                ),
            ),
            (
                "App::PropertyDistance",
                "Stepover",
                "Profile",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "If doing multiple passes, the extra offset of each additional pass",
                ),
            ),
        ]

    @classmethod
    def areaOpPropertyEnumerations(self, dataType="data"):
        """opPropertyEnumerations(dataType="data")... return property enumeration lists of specified dataType.
        Args:
            dataType = 'data', 'raw', 'translated'
        Notes:
        'data' is list of internal string literals used in code
        'raw' is list of (translated_text, data_string) tuples
        'translated' is list of translated string literals
        """

        # Enumeration lists for App::PropertyEnumeration properties
        enums = {
            "Direction": [
                (translate("PathProfile", "CW"), "CW"),
                (translate("PathProfile", "CCW"), "CCW"),
            ],  # this is the direction that the profile runs
            "HandleMultipleFeatures": [
                (translate("PathProfile", "Collectively"), "Collectively"),
                (translate("PathProfile", "Individually"), "Individually"),
            ],
            "JoinType": [
                (translate("PathProfile", "Round"), "Round"),
                (translate("PathProfile", "Square"), "Square"),
                (translate("PathProfile", "Miter"), "Miter"),
            ],  # this is the direction that the Profile runs
            "Side": [
                (translate("PathProfile", "Outside"), "Outside"),
                (translate("PathProfile", "Inside"), "Inside"),
            ],  # side of profile that cutter is on in relation to direction of profile
        }

        if dataType == "raw":
            return enums

        data = list()
        idx = 0 if dataType == "translated" else 1

        Path.Log.debug(enums)

        for k, v in enumerate(enums):
            # data[k] = [tup[idx] for tup in v]
            data.append((v, [tup[idx] for tup in enums[v]]))
        Path.Log.debug(data)

        return data

    def areaOpPropertyDefaults(self, obj, job):
        """areaOpPropertyDefaults(obj, job) ... returns a dictionary of default values
        for the operation's properties."""
        return {
            "Direction": "CW",
            "HandleMultipleFeatures": "Collectively",
            "JoinType": "Round",
            "MiterLimit": 0.1,
            "OffsetExtra": 0.0,
            "Side": "Outside",
            "UseComp": True,
            "processCircles": False,
            "processHoles": False,
            "processPerimeter": True,
            "Stepover": 0,
            "NumPasses": 1,
        }

    def areaOpApplyPropertyDefaults(self, obj, job, propList):
        # Set standard property defaults
        PROP_DFLTS = self.areaOpPropertyDefaults(obj, job)
        for n in PROP_DFLTS:
            if n in propList:
                prop = getattr(obj, n)
                val = PROP_DFLTS[n]
                setVal = False
                if hasattr(prop, "Value"):
                    if isinstance(val, int) or isinstance(val, float):
                        setVal = True
                if setVal:
                    # propVal = getattr(prop, 'Value')
                    # Need to check if `val` below should be `propVal` commented out above
                    setattr(prop, "Value", val)
                else:
                    setattr(obj, n, val)

    def areaOpSetDefaultValues(self, obj, job):
        if self.addNewProps and self.addNewProps.__len__() > 0:
            self.areaOpApplyPropertyDefaults(obj, job, self.addNewProps)

    def setOpEditorProperties(self, obj):
        """setOpEditorProperties(obj, porp) ... Process operation-specific changes to properties visibility."""
        fc = 2
        # ml = 0 if obj.JoinType == 'Miter' else 2
        side = 0 if obj.UseComp else 2
        opType = self._getOperationType(obj)

        if opType == "Contour":
            side = 2
        elif opType == "Face":
            fc = 0
        elif opType == "Edge":
            pass

        obj.setEditorMode("JoinType", 2)
        obj.setEditorMode("MiterLimit", 2)  # ml
        obj.setEditorMode("Side", side)
        obj.setEditorMode("HandleMultipleFeatures", fc)
        obj.setEditorMode("processCircles", fc)
        obj.setEditorMode("processHoles", fc)
        obj.setEditorMode("processPerimeter", fc)

    def _getOperationType(self, obj):
        if len(obj.Base) == 0:
            return "Contour"

        # return first geometry type selected
        (_, subsList) = obj.Base[0]
        return subsList[0][:4]

    def areaOpOnDocumentRestored(self, obj):
        self.propertiesReady = False

        self.initAreaOpProperties(obj, warn=True)
        self.areaOpSetDefaultValues(obj, PathUtils.findParentJob(obj))
        self.setOpEditorProperties(obj)
        if not hasattr(obj, "NumPasses"):
            obj.addProperty(
                "App::PropertyInteger",
                "NumPasses",
                "Profile",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "The number of passes to do. Requires a non-zero value for Stepover",
                ),
            )
        if not hasattr(obj, "Stepover"):
            obj.addProperty(
                "App::PropertyDistance",
                "Stepover",
                "Profile",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "If doing multiple passes, the extra offset of each additional pass",
                ),
            )

    def areaOpOnChanged(self, obj, prop):
        """areaOpOnChanged(obj, prop) ... updates certain property visibilities depending on changed properties."""
        if prop in ["UseComp", "JoinType", "Base"]:
            if hasattr(self, "propertiesReady") and self.propertiesReady:
                self.setOpEditorProperties(obj)

    def areaOpAreaParams(self, obj, isHole):
        """areaOpAreaParams(obj, isHole) ... returns dictionary with area parameters.
        Do not overwrite."""
        params = {}
        params["Fill"] = 0
        params["Coplanar"] = 0
        params["SectionCount"] = -1

        offset = obj.OffsetExtra.Value  # 0.0
        num_passes = max(1, obj.NumPasses)
        stepover = obj.Stepover.Value
        if num_passes > 1 and stepover == 0:
            # This check is important because C++ code has a default value for stepover if it's 0 and extra passes are requested
            num_passes = 1
            Path.Log.warning(
                "Multipass profile requires a non-zero stepover. Reducing to a single pass."
            )

        if obj.UseComp:
            offset = self.radius + obj.OffsetExtra.Value
        if obj.Side == "Inside":
            offset = 0 - offset
            stepover = -stepover
        if isHole:
            offset = 0 - offset
            stepover = -stepover

        # Modify offset and stepover to do passes from most-offset to least
        offset += stepover * (num_passes - 1)
        stepover = -stepover

        params["Offset"] = offset
        params["ExtraPass"] = num_passes - 1
        params["Stepover"] = stepover

        jointype = ["Round", "Square", "Miter"]
        params["JoinType"] = jointype.index(obj.JoinType)

        if obj.JoinType == "Miter":
            params["MiterLimit"] = obj.MiterLimit

        if obj.SplitArcs:
            params["Explode"] = True
            params["FitArcs"] = False

        return params

    def areaOpPathParams(self, obj, isHole):
        """areaOpPathParams(obj, isHole) ... returns dictionary with path parameters.
        Do not overwrite."""
        params = {}

        # Reverse the direction for holes
        if isHole:
            direction = "CW" if obj.Direction == "CCW" else "CCW"
        else:
            direction = obj.Direction

        if direction == "CCW":
            params["orientation"] = 0
        else:
            params["orientation"] = 1

        offset = obj.OffsetExtra.Value
        if obj.UseComp:
            offset = self.radius + obj.OffsetExtra.Value
        if offset == 0.0:
            if direction == "CCW":
                params["orientation"] = 1
            else:
                params["orientation"] = 0

        if obj.NumPasses > 1:
            # Disable path sorting to ensure that offsets appear in order, from farthest offset to closest, on all layers
            params["sort_mode"] = 0

        return params

    def areaOpUseProjection(self, obj):
        """areaOpUseProjection(obj) ... returns True"""
        return True

    def opUpdateDepths(self, obj):
        if hasattr(obj, "Base") and obj.Base.__len__() == 0:
            obj.OpStartDepth = obj.OpStockZMax
            obj.OpFinalDepth = obj.OpStockZMin

    def areaOpShapes(self, obj):
        """areaOpShapes(obj) ... returns envelope for all base shapes or wires"""

        shapes = []
        remainingObjBaseFeatures = []
        self.isDebug = True if Path.Log.getLevel(Path.Log.thisModule()) == 4 else False
        self.inaccessibleMsg = translate(
            "PathProfile",
            "The selected edge(s) are inaccessible. If multiple, re-ordering selection might work.",
        )
        self.offsetExtra = obj.OffsetExtra.Value

        if self.isDebug:
            for grpNm in ["tmpDebugGrp", "tmpDebugGrp001"]:
                if hasattr(FreeCAD.ActiveDocument, grpNm):
                    for go in FreeCAD.ActiveDocument.getObject(grpNm).Group:
                        FreeCAD.ActiveDocument.removeObject(go.Name)
                    FreeCAD.ActiveDocument.removeObject(grpNm)
            self.tmpGrp = FreeCAD.ActiveDocument.addObject(
                "App::DocumentObjectGroup", "tmpDebugGrp"
            )
            tmpGrpNm = self.tmpGrp.Name
        self.JOB = PathUtils.findParentJob(obj)

        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)"))

        # Pre-process Base Geometry to process edges
        if (
            obj.Base and len(obj.Base) > 0
        ):  # The user has selected subobjects from the base.  Process each.
            shapes.extend(self._processEdges(obj, remainingObjBaseFeatures))
            Path.Log.track("returned {} shapes".format(len(shapes)))

        Path.Log.track(remainingObjBaseFeatures)
        if obj.Base and len(obj.Base) > 0 and not remainingObjBaseFeatures:
            # Edges were already processed, or whole model targeted.
            Path.Log.track("remainingObjBaseFeatures is False")
        elif (
            remainingObjBaseFeatures and len(remainingObjBaseFeatures) > 0
        ):  # Process remaining features after edges processed above.
            for base, subsList in remainingObjBaseFeatures:
                holes = []
                faces = []
                faceDepths = []

                for sub in subsList:
                    shape = getattr(base.Shape, sub)
                    # only process faces here
                    if isinstance(shape, Part.Face):
                        faces.append(shape)
                        if numpy.isclose(abs(shape.normalAt(0, 0).z), 1):  # horizontal face
                            Path.Log.debug(abs(shape.normalAt(0, 0).z))
                            for wire in shape.Wires:
                                if wire.hashCode() == shape.OuterWire.hashCode():
                                    continue
                                holes.append((base.Shape, wire))

                        # Add face depth to list
                        faceDepths.append(shape.BoundBox.ZMin)
                    else:
                        Path.Log.track()
                        ignoreSub = base.Name + "." + sub
                        msg = "Found a selected object which is not a face. Ignoring:"
                        Path.Log.warning(msg + " {}".format(ignoreSub))

                for baseShape, wire in holes:
                    cont = False
                    f = Part.makeFace(wire, "Part::FaceMakerSimple")
                    drillable = Drillable.isDrillable(baseShape, f, vector=None)
                    Path.Log.debug(drillable)

                    if obj.processCircles:
                        if drillable:
                            cont = True
                    if obj.processHoles:
                        if not drillable:
                            cont = True

                    if cont:
                        shapeEnv = PathUtils.getEnvelope(
                            baseShape, subshape=f, depthparams=self.depthparams
                        )

                        if shapeEnv:
                            self._addDebugObject("HoleShapeEnvelope", shapeEnv)
                            tup = shapeEnv, True, "pathProfile"
                            shapes.append(tup)

                if faces and obj.processPerimeter:
                    if obj.HandleMultipleFeatures == "Collectively":
                        custDepthparams = self.depthparams
                        cont = True
                        profileshape = Part.makeCompound(faces)

                        try:
                            shapeEnv = PathUtils.getEnvelope(
                                profileshape, depthparams=custDepthparams
                            )
                        except Exception as ee:
                            # PathUtils.getEnvelope() failed to return an object.
                            msg = translate("PathProfile", "Unable to create path for face(s).")
                            Path.Log.error(msg + "\n{}".format(ee))
                            cont = False

                        if cont:
                            self._addDebugObject("CollectCutShapeEnv", shapeEnv)
                            tup = shapeEnv, False, "pathProfile"
                            shapes.append(tup)

                    elif obj.HandleMultipleFeatures == "Individually":
                        for shape in faces:
                            custDepthparams = self.depthparams
                            self._addDebugObject("Indiv_Shp", shape)
                            shapeEnv = PathUtils.getEnvelope(shape, depthparams=custDepthparams)
                            if shapeEnv:
                                self._addDebugObject("IndivCutShapeEnv", shapeEnv)
                                tup = shapeEnv, False, "pathProfile"
                                shapes.append(tup)

        else:  # Try to build targets from the job models
            # No base geometry selected, so treating operation like a exterior contour operation
            Path.Log.track()
            self.opUpdateDepths(obj)

            if 1 == len(self.model) and hasattr(self.model[0], "Proxy"):
                Path.Log.debug("Single model processed.")
                shapes.extend(self._processEachModel(obj))
            else:
                shapes.extend(self._processEachModel(obj))

        self.removalshapes = shapes
        Path.Log.debug("%d shapes" % len(shapes))

        # Delete the temporary objects
        if self.isDebug:
            if FreeCAD.GuiUp:
                import FreeCADGui

                FreeCADGui.ActiveDocument.getObject(tmpGrpNm).Visibility = False
            self.tmpGrp.purgeTouched()

        # for shape in shapes:
        #     Part.show(shape[0])
        #     print(shape)
        return shapes

    # Method to handle each model as a whole, when no faces are selected
    def _processEachModel(self, obj):
        shapeTups = []
        for base in self.model:
            if hasattr(base, "Shape"):
                env = PathUtils.getEnvelope(
                    partshape=base.Shape, subshape=None, depthparams=self.depthparams
                )
                if env:
                    shapeTups.append((env, False))
        return shapeTups

    # Edges pre-processing
    def _processEdges(self, obj, remainingObjBaseFeatures):
        Path.Log.track("remainingObjBaseFeatures: {}".format(remainingObjBaseFeatures))
        shapes = []
        basewires = []
        ezMin = None
        self.cutOut = self.tool.Diameter

        for base, subsList in obj.Base:
            keepFaces = []
            edgelist = []
            for sub in subsList:
                shape = getattr(base.Shape, sub)
                # extract and process edges
                if isinstance(shape, Part.Edge):
                    edgelist.append(getattr(base.Shape, sub))
                # save faces for regular processing
                elif isinstance(shape, Part.Face):
                    keepFaces.append(sub)
            if len(edgelist) > 0:
                basewires.append((base, DraftGeomUtils.findWires(edgelist)))
                if ezMin is None or base.Shape.BoundBox.ZMin < ezMin:
                    ezMin = base.Shape.BoundBox.ZMin

            if len(keepFaces) > 0:  # save faces for returning and processing
                remainingObjBaseFeatures.append((base, keepFaces))

        Path.Log.track(basewires)
        for base, wires in basewires:
            for wire in wires:
                if wire.isClosed():
                    # Attempt to profile a closed wire

                    # 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 = flatWire.Wires[0]
                    if f:
                        shapeEnv = PathUtils.getEnvelope(Part.Face(f), depthparams=self.depthparams)
                        if shapeEnv:
                            tup = shapeEnv, False, "pathProfile"
                            shapes.append(tup)
                    else:
                        Path.Log.error(self.inaccessibleMsg)
                else:
                    # Attempt open-edges profile
                    if self.JOB.GeometryTolerance.Value == 0.0:
                        msg = self.JOB.Label + ".GeometryTolerance = 0.0. "
                        msg += "Please set to an acceptable value greater than zero."
                        Path.Log.error(msg)
                    else:
                        flattened = self._flattenWire(obj, wire, obj.FinalDepth.Value)
                        zDiff = math.fabs(wire.BoundBox.ZMin - obj.FinalDepth.Value)
                        if flattened and zDiff >= self.JOB.GeometryTolerance.Value:
                            cutWireObjs = False
                            openEdges = []
                            params = self.areaOpAreaParams(obj, False)
                            passOffsets = [
                                self.ofstRadius + i * abs(params["Stepover"])
                                for i in range(params["ExtraPass"] + 1)
                            ][::-1]
                            (origWire, flatWire) = flattened

                            self._addDebugObject("FlatWire", flatWire)

                            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:
                                        openEdges.append(cW)
                                else:
                                    Path.Log.error(self.inaccessibleMsg)

                            if openEdges:
                                tup = openEdges, False, "OpenEdge"
                                shapes.append(tup)
                        else:
                            if zDiff < self.JOB.GeometryTolerance.Value:
                                msg = translate(
                                    "PathProfile",
                                    "Check edge selection and Final Depth requirements for profiling open edge(s).",
                                )
                                Path.Log.error(msg)
                            else:
                                Path.Log.error(self.inaccessibleMsg)

        return shapes

    def _flattenWire(self, obj, wire, trgtDep):
        """_flattenWire(obj, wire)... Return a flattened version of the wire"""
        Path.Log.debug("_flattenWire()")
        wBB = wire.BoundBox

        if wBB.ZLength > 0.0:
            Path.Log.debug("Wire is not horizontally co-planar. Flattening it.")

            # Extrude non-horizontal wire
            extFwdLen = (wBB.ZLength + 2.0) * 2.0
            mbbEXT = wire.extrude(FreeCAD.Vector(0, 0, extFwdLen))

            # Create cross-section of shape and translate
            sliceZ = wire.BoundBox.ZMin + (extFwdLen / 2)
            crsectFaceShp = self._makeCrossSection(mbbEXT, sliceZ, trgtDep)
            if crsectFaceShp is not False:
                return (wire, crsectFaceShp)
            else:
                return False
        else:
            srtWire = Part.Wire(Part.__sortEdges__(wire.Edges))
            srtWire.translate(FreeCAD.Vector(0, 0, trgtDep - srtWire.BoundBox.ZMin))

        return (wire, srtWire)

    # Open-edges methods
    def _getCutAreaCrossSection(self, obj, base, origWire, flatWire):
        Path.Log.debug("_getCutAreaCrossSection()")
        # FCAD = FreeCAD.ActiveDocument
        tolerance = self.JOB.GeometryTolerance.Value
        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 = flatWire.BoundBox
        wBB = origWire.BoundBox
        minArea = (self.ofstRadius - tolerance) ** 2 * math.pi

        useWire = origWire.Wires[0]
        numOrigEdges = len(useWire.Edges)
        sdv = wBB.ZMax
        fdv = obj.FinalDepth.Value
        extLenFwd = sdv - fdv
        if extLenFwd <= 0.0:
            msg = "For open edges, verify Final Depth for this operation."
            FreeCAD.Console.PrintError(msg + "\n")
            # return False
            extLenFwd = 0.1
        WIRE = flatWire.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)

        # 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)
        if not begInt or not begExt:
            return False
        self.iTAG = iTAG
        self.eTAG = eTAG

        # Create extended wire boundbox, and extrude
        extBndbox = self._makeExtendedBoundBox(wBB, bbBfr, fdv)
        extBndboxEXT = extBndbox.extrude(FreeCAD.Vector(0, 0, extLenFwd))

        # Cut model(selected edges) from extended edges boundbox
        cutArea = extBndboxEXT.cut(base.Shape)
        cutArea.tessellate(tolerance)
        self._addDebugObject("CutArea", cutArea)

        # Get top and bottom faces of cut area (CA), and combine faces when necessary
        topFc = []
        botFc = []
        bbZMax = cutArea.BoundBox.ZMax
        bbZMin = cutArea.BoundBox.ZMin
        for f in range(0, len(cutArea.Faces)):
            FcBB = cutArea.Faces[f].BoundBox
            if abs(FcBB.ZMax - bbZMax) < tolerance and abs(FcBB.ZMin - bbZMax) < tolerance:
                topFc.append(f)
            if abs(FcBB.ZMax - bbZMin) < tolerance and abs(FcBB.ZMin - bbZMin) < tolerance:
                botFc.append(f)
        if len(topFc) == 0:
            Path.Log.error("Failed to identify top faces of cut area.")
            return False
        topComp = Part.makeCompound([cutArea.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:
            # Path.Log.debug('len(botFc) > 1')
            bndboxFace = Part.Face(extBndbox.Wires[0])
            tmpFace = Part.Face(extBndbox.Wires[0])
            for f in botFc:
                Q = tmpFace.cut(cutArea.Faces[f])
                tmpFace = Q
            botComp = bndboxFace.cut(tmpFace)
        else:
            botComp = Part.makeCompound(
                [cutArea.Faces[f] for f in botFc]
            )  # 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

        # Make common of the two
        comFC = topComp.common(botComp)

        # Determine with which set of intersection tags the model intersects
        (cmnIntArea, cmnExtArea) = self._checkTagIntersection(iTAG, eTAG, "QRY", comFC)
        if cmnExtArea > cmnIntArea:
            Path.Log.debug("Cutting on Ext side.")
            self.cutSide = "E"
            self.cutSideTags = eTAG
            tagCOM = begExt.CenterOfMass
        else:
            Path.Log.debug("Cutting on Int side.")
            self.cutSide = "I"
            self.cutSideTags = iTAG
            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
        wire = origWire
        WS = workShp.Wires
        lenWS = len(WS)
        wi = 0
        if lenWS < 3:
            # fcShp = workShp
            pass
        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:
                Path.Log.error(
                    "The cut area cross-section wire does not coincide with selected edge. Wires[] index is None."
                )
                return False
            else:
                Path.Log.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:
            Path.Log.debug("Multiple wires in cut area. First choice is not 0. Testing.")
            testArea = fcShp.cut(base.Shape)

            isReady = self._checkTagIntersection(iTAG, eTAG, self.cutSide, testArea)
            Path.Log.debug("isReady {}.".format(isReady))

            if isReady is False:
                Path.Log.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:
                Path.Log.debug("offset area is less than minArea of {}.".format(minArea))
                Path.Log.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)
        self._addDebugObject("CutShape", cutShp)

        return cutShp

    def _checkTagIntersection(self, iTAG, eTAG, cutSide, tstObj):
        Path.Log.debug("_checkTagIntersection()")
        # Identify intersection of Common area and Interior Tags
        intCmn = tstObj.common(iTAG)

        # Identify intersection of Common area and Exterior Tags
        extCmn = tstObj.common(eTAG)

        # Calculate common intersection (solid model side, or the non-cut side) area with tags, to determine physical cut side
        cmnIntArea = intCmn.Area
        cmnExtArea = extCmn.Area
        if cutSide == "QRY":
            return (cmnIntArea, cmnExtArea)

        if cmnExtArea > cmnIntArea:
            Path.Log.debug("Cutting on Ext side.")
            if cutSide == "E":
                return True
        else:
            Path.Log.debug("Cutting on Int side.")
            if cutSide == "I":
                return True
        return False

    def _extractPathWire(self, obj, base, flatWire, cutShp):
        Path.Log.debug("_extractPathWire()")

        subLoops = []
        rtnWIRES = []
        osWrIdxs = []
        subDistFactor = 1.0  # Raise to include sub wires at greater distance from original
        fdv = obj.FinalDepth.Value
        wire = flatWire
        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)

        # Calculate offset shape, containing cut region
        ofstShp = self._getOffsetArea(obj, cutShp, False)

        # CHECK for ZERO area of offset shape
        try:
            if hasattr(ofstShp, "Area"):
                osArea = ofstShp.Area
                if osArea:  # Make LGTM parser happy
                    pass
            else:
                Path.Log.error("No area to offset shape returned.")
                return []
        except Exception as ee:
            Path.Log.error("No area to offset shape returned.\n{}".format(ee))
            return []

        self._addDebugObject("OffsetShape", ofstShp)

        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, _, _) = NEAR0[0]  # min0i
        near0Shp = Part.makeLine(cent0, pnt0)
        self._addDebugObject("Near0", near0Shp)

        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, _, _) = NEAR1[0]  # min1i
        near1Shp = Part.makeLine(cent1, pnt1)
        self._addDebugObject("Near1", near1Shp)

        if w0 != w1:
            Path.Log.warning(
                "Offset wire endpoint indexes are not equal - w0, w1: {}, {}".format(w0, w1)
            )

        # Debugging
        """
        if self.isDebug:
            Path.Log.debug('min0i is {}.'.format(min0i))
            Path.Log.debug('min1i is {}.'.format(min1i))
            Path.Log.debug('NEAR0[{}] is {}.'.format(w0, NEAR0[w0]))
            Path.Log.debug('NEAR1[{}] is {}.'.format(w1, NEAR1[w1]))
            Path.Log.debug('NEAR0 is {}.'.format(NEAR0))
            Path.Log.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 = []
            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.
        try:
            (edgeIdxs0, edgeIdxs1) = self._separateWireAtVertexes(
                mainWire, mainWire.Vertexes[vi0], mainWire.Vertexes[vi1]
            )
        except Exception as ee:
            Path.Log.error("Failed to identify offset edge.\n{}".format(ee))
            return False
        edgs0 = []
        edgs1 = []
        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)

        return rtnWIRES

    def _getOffsetArea(self, obj, fcShape, isHole):
        """Get an offset area for a shape. Wrapper around
        PathUtils.getOffsetArea."""
        Path.Log.debug("_getOffsetArea()")

        JOB = PathUtils.findParentJob(obj)
        tolerance = JOB.GeometryTolerance.Value
        offset = self.ofstRadius

        if isHole is False:
            offset = 0 - offset

        return PathUtils.getOffsetArea(fcShape, offset, plane=fcShape, tolerance=tolerance)

    def _findNearestVertex(self, shape, point):
        Path.Log.debug("_findNearestVertex()")
        PT = FreeCAD.Vector(point.x, point.y, 0.0)

        def sortDist(tup):
            return tup[4]

        PNTS = []
        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):
        Path.Log.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)

        edgeCount = len(wire.Edges)
        FLGS = []
        for e in range(0, edgeCount):
            FLGS.append(0)

        chk4 = False
        for e in range(0, edgeCount):
            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

        # Path.Log.debug('_separateWireAtVertexes() FLGS: {}'.format(FLGS))

        PRE = []
        POST = []
        IDXS = []
        IDX1 = []
        IDX2 = []
        for e in range(0, edgeCount):
            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
            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
            for e in range(begIdx + 1, edgeCount + 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 not begFlg:
                        begFlg = True
                    else:
                        begIdx = e
                        break
            # find first 3 edge and group all first wire edges
            endIdx = None
            for e in range(begIdx, edgeCount + 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

        # Debugging
        """
        if self.isDebug:
            Path.Log.debug('grps[0]: {}'.format(grps[0]))
            Path.Log.debug('grps[1]: {}'.format(grps[1]))
            Path.Log.debug('wireIdxs[0]: {}'.format(wireIdxs[0]))
            Path.Log.debug('wireIdxs[1]: {}'.format(wireIdxs[1]))
            Path.Log.debug('PRE: {}'.format(PRE))
            Path.Log.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."""
        Path.Log.debug("_makeCrossSection()")
        # Create cross-section of shape and translate
        wires = []
        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):
        Path.Log.debug("_makeExtendedBoundBox()")
        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)

        L1 = Part.makeLine(p1, p2)
        L2 = Part.makeLine(p2, p3)
        L3 = Part.makeLine(p3, p4)
        L4 = Part.makeLine(p4, p1)

        return Part.Face(Part.Wire([L1, L2, L3, L4]))

    def _makeIntersectionTags(self, useWire, numOrigEdges, fdv):
        Path.Log.debug("_makeIntersectionTags()")
        # Create circular probe tags around perimiter of wire
        extTags = []
        intTags = []
        tagRad = self.radius / 2
        tagCnt = 0
        begInt = False
        begExt = False
        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, int(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 and extTObj:
                            begInt = intTObj
                            begExt = extTObj
                else:
                    d = i * mid
                    negTestLen = d - spc
                    if negTestLen < 0:
                        negTestLen = d - (LE * 0.25)
                    posTestLen = d + spc
                    if posTestLen > LE:
                        posTestLen = d + (LE * 0.25)
                    cp1 = E.valueAt(E.getParameterByLength(negTestLen))
                    cp2 = E.valueAt(E.getParameterByLength(posTestLen))
                    (intTObj, extTObj) = self._makeOffsetCircleTag(
                        cp1, cp2, tagRad, fdv, "Edge[{}]_".format(e)
                    )
                    if intTObj and extTObj:
                        tagCnt += nt
                        intTags.append(intTObj)
                        extTags.append(extTObj)
        # tagArea = math.pi * tagRad**2 * tagCnt
        iTAG = Part.makeCompound(intTags)
        eTAG = Part.makeCompound(extTags)

        return (begInt, begExt, iTAG, eTAG)

    def _makeOffsetCircleTag(self, p1, p2, cutterRad, depth, lbl, reverse=False):
        # Path.Log.debug('_makeOffsetCircleTag()')
        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))

        # make exterior tag
        eCntr = extPnt.add(FreeCAD.Vector(0, 0, depth))
        ecw = Part.Wire(Part.makeCircle((cutterRad / 2), eCntr).Edges[0])
        extTag = Part.Face(ecw)

        # make interior tag
        perpI = FreeCAD.Vector(-1 * toMid.y, toMid.x, 0.0).multiply(cutFactor)  # interior tag
        intPnt = pb.add(toMid.add(perpI))
        iCntr = intPnt.add(FreeCAD.Vector(0, 0, depth))
        icw = Part.Wire(Part.makeCircle((cutterRad / 2), iCntr).Edges[0])
        intTag = Part.Face(icw)

        return (intTag, extTag)

    def _makeStop(self, sType, pA, pB, lbl):
        # Path.Log.debug('_makeStop()')
        ofstRad = self.ofstRadius
        extra = self.radius / 5.0
        lng = 0.05
        med = lng / 2.0
        shrt = lng / 5.0

        E = FreeCAD.Vector(pB.x, pB.y, 0)  # endpoint
        C = FreeCAD.Vector(pA.x, pA.y, 0)  # checkpoint

        if self.useComp is True or (self.useComp is False and self.offsetExtra != 0):
            # 'L' stop shape and edge map
            # --1--
            # |   |
            # 2   6
            # |   |
            # |   ----5----|
            # |            4
            # -----3-------|
            # positive dist in _makePerp2DVector() is CCW rotation
            p1 = E
            if sType == "BEG":
                p2 = self._makePerp2DVector(C, E, -1 * shrt)  # E1
                p3 = self._makePerp2DVector(p1, p2, ofstRad + lng + extra)  # E2
                p4 = self._makePerp2DVector(p2, p3, shrt + ofstRad + extra)  # E3
                p5 = self._makePerp2DVector(p3, p4, lng + extra)  # E4
                p6 = self._makePerp2DVector(p4, p5, ofstRad + extra)  # E5
            elif sType == "END":
                p2 = self._makePerp2DVector(C, E, shrt)  # E1
                p3 = self._makePerp2DVector(p1, p2, -1 * (ofstRad + lng + extra))  # E2
                p4 = self._makePerp2DVector(p2, p3, -1 * (shrt + ofstRad + extra))  # E3
                p5 = self._makePerp2DVector(p3, p4, -1 * (lng + extra))  # E4
                p6 = self._makePerp2DVector(p4, p5, -1 * (ofstRad + extra))  # E5
            p7 = E  # E6
            L1 = Part.makeLine(p1, p2)
            L2 = Part.makeLine(p2, p3)
            L3 = Part.makeLine(p3, p4)
            L4 = Part.makeLine(p4, p5)
            L5 = Part.makeLine(p5, p6)
            L6 = Part.makeLine(p6, p7)
            wire = Part.Wire([L1, L2, L3, L4, L5, L6])
        else:
            # 'L' stop shape and edge map
            # :
            # |----2-------|
            # 3            1
            # |-----4------|
            # positive dist in _makePerp2DVector() is CCW rotation
            p1 = E
            if sType == "BEG":
                p2 = self._makePerp2DVector(C, E, -1 * (shrt + abs(self.offsetExtra)))  # left, shrt
                p3 = self._makePerp2DVector(p1, p2, shrt + abs(self.offsetExtra))
                p4 = self._makePerp2DVector(p2, p3, (med + abs(self.offsetExtra)))  # FIRST POINT
                p5 = self._makePerp2DVector(p3, p4, shrt + abs(self.offsetExtra))  # E1 SECOND
            elif sType == "END":
                p2 = self._makePerp2DVector(C, E, (shrt + abs(self.offsetExtra)))  # left, shrt
                p3 = self._makePerp2DVector(p1, p2, -1 * (shrt + abs(self.offsetExtra)))
                p4 = self._makePerp2DVector(
                    p2, p3, -1 * (med + abs(self.offsetExtra))
                )  # FIRST POINT
                p5 = self._makePerp2DVector(
                    p3, p4, -1 * (shrt + abs(self.offsetExtra))
                )  # E1 SECOND
            p6 = p1  # E4
            L1 = Part.makeLine(p1, p2)
            L2 = Part.makeLine(p2, p3)
            L3 = Part.makeLine(p3, p4)
            L4 = Part.makeLine(p4, p5)
            L5 = Part.makeLine(p5, p6)
            wire = Part.Wire([L1, L2, L3, L4, L5])
        # Eif
        face = Part.Face(wire)
        self._addDebugObject(lbl, face)

        return face

    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 Part.sortEdges(wire.Edges)[0]:
            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

    # Method to add temporary debug object
    def _addDebugObject(self, objName, objShape):
        if self.isDebug:
            newDocObj = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmp_" + objName)
            newDocObj.Shape = objShape
            newDocObj.purgeTouched()
            self.tmpGrp.addObject(newDocObj)


def SetupProperties():
    setup = PathAreaOp.SetupProperties()
    setup.extend([tup[1] for tup in ObjectProfile.areaOpProperties(False)])
    return setup


def Create(name, obj=None, parentJob=None):
    """Create(name) ... Creates and returns a Profile based on faces operation."""
    if obj is None:
        obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name)
    obj.Proxy = ObjectProfile(obj, name, parentJob)
    return obj
