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

# ***************************************************************************
# *   Copyright (c) 2020 russ4262 <russ4262@gmail.com>                      *
# *                                                                         *
# *   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                                                                   *
# *                                                                         *
# ***************************************************************************


__title__ = "CAM Surface Support Module"
__author__ = "russ4262 (Russell Johnson)"
__url__ = "https://www.freecad.org"
__doc__ = "Support functions and classes for 3D Surface and Waterline operations."
__contributors__ = ""

import FreeCAD
import Path
import Path.Op.Util as PathOpUtil
import PathScripts.PathUtils as PathUtils
import math

# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader

# MeshPart = LazyLoader('MeshPart', globals(), 'MeshPart')
Part = LazyLoader("Part", globals(), "Part")


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


translate = FreeCAD.Qt.translate


class PathGeometryGenerator:
    """Creates a path geometry shape from an assigned pattern for conversion to tool paths.
    PathGeometryGenerator(obj, shape, pattern)
    `obj` is the operation object, `shape` is the horizontal planar shape object,
    and `pattern` is the name of the geometric pattern to apply.
    First, call the getCenterOfPattern() method for the CenterOfMass for patterns allowing a custom center.
    Next, call the generatePathGeometry() method to request the path geometry shape."""

    # Register valid patterns here by name
    # Create a corresponding processing method below. Precede the name with an underscore(_)
    patterns = ("Circular", "CircularZigZag", "Line", "Offset", "Spiral", "ZigZag")

    def __init__(self, obj, shape, pattern):
        """__init__(obj, shape, pattern)... Instantiate PathGeometryGenerator class.
        Required arguments are the operation object, horizontal planar shape, and pattern name."""
        self.debugObjectsGroup = False
        self.pattern = "None"
        self.shape = None
        self.pathGeometry = None
        self.rawGeoList = None
        self.centerOfMass = None
        self.centerofPattern = None
        self.deltaX = None
        self.deltaY = None
        self.deltaC = None
        self.halfDiag = None
        self.halfPasses = None
        self.obj = obj
        self.toolDiam = float(obj.ToolController.Tool.Diameter)
        self.cutOut = self.toolDiam * (float(obj.StepOver) / 100.0)
        self.wpc = Part.makeCircle(2.0)  # make circle for workplane

        # validate requested pattern
        if pattern in self.patterns:
            if hasattr(self, "_" + pattern):
                self.pattern = pattern

        if shape.BoundBox.ZMin != 0.0:
            shape.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - shape.BoundBox.ZMin))
        if shape.BoundBox.ZLength > 1.0e-8:
            msg = translate("PathSurfaceSupport", "Shape appears to not be horizontal planar.")
            msg += " ZMax == {} mm.\n".format(shape.BoundBox.ZMax)
            FreeCAD.Console.PrintWarning(msg)
        else:
            self.shape = shape
            self._prepareConstants()

    def _prepareConstants(self):
        # Compute weighted center of mass of all faces combined
        if self.pattern in ["Circular", "CircularZigZag", "Spiral"]:
            if self.obj.PatternCenterAt == "CenterOfMass":
                fCnt = 0
                totArea = 0.0
                zeroCOM = FreeCAD.Vector(0.0, 0.0, 0.0)
                for F in self.shape.Faces:
                    comF = F.CenterOfMass
                    areaF = F.Area
                    totArea += areaF
                    fCnt += 1
                    zeroCOM = zeroCOM.add(FreeCAD.Vector(comF.x, comF.y, 0.0).multiply(areaF))
                if fCnt == 0:
                    msg = translate("PathSurfaceSupport", "Cannot calculate the Center Of Mass.")
                    msg += (
                        " "
                        + translate("PathSurfaceSupport", "Using Center of Boundbox instead.")
                        + "\n"
                    )
                    FreeCAD.Console.PrintError(msg)
                    bbC = self.shape.BoundBox.Center
                    zeroCOM = FreeCAD.Vector(bbC.x, bbC.y, 0.0)
                else:
                    avgArea = totArea / fCnt
                    zeroCOM.multiply(1 / fCnt)
                    zeroCOM.multiply(1 / avgArea)
                self.centerOfMass = FreeCAD.Vector(zeroCOM.x, zeroCOM.y, 0.0)
            self.centerOfPattern = self._getPatternCenter()
        else:
            bbC = self.shape.BoundBox.Center
            self.centerOfPattern = FreeCAD.Vector(bbC.x, bbC.y, 0.0)

        # get X, Y, Z spans; Compute center of rotation
        self.deltaX = self.shape.BoundBox.XLength
        self.deltaY = self.shape.BoundBox.YLength
        self.deltaC = (
            self.shape.BoundBox.DiagonalLength
        )  # math.sqrt(self.deltaX**2 + self.deltaY**2)
        lineLen = self.deltaC + (
            2.0 * self.toolDiam
        )  # Line length to span boundbox diag with 2x cutter diameter extra on each end
        self.halfDiag = math.ceil(lineLen / 2.0)
        cutPasses = (
            math.ceil(lineLen / self.cutOut) + 1
        )  # Number of lines(passes) required to cover boundbox diagonal
        self.halfPasses = math.ceil(cutPasses / 2.0)

    # Public methods
    def setDebugObjectsGroup(self, tmpGrpObject):
        """setDebugObjectsGroup(tmpGrpObject)...
        Pass the temporary object group to show temporary construction objects"""
        self.debugObjectsGroup = tmpGrpObject

    def getCenterOfPattern(self):
        """getCenterOfPattern()...
        Returns the Center Of Mass for the current class instance."""
        return self.centerOfPattern

    def generatePathGeometry(self):
        """generatePathGeometry()...
        Call this function to obtain the path geometry shape, generated by this class."""
        if self.pattern == "None":
            return False

        if self.shape is None:
            return False

        cmd = "self._" + self.pattern + "()"
        exec(cmd)

        if self.obj.CutPatternReversed is True:
            self.rawGeoList.reverse()

        # Create compound object to bind all lines in Lineset
        geomShape = Part.makeCompound(self.rawGeoList)

        # Position and rotate the Line and ZigZag geometry
        if self.pattern in ["Line", "ZigZag"]:
            if self.obj.CutPatternAngle != 0.0:
                geomShape.Placement.Rotation = FreeCAD.Rotation(
                    FreeCAD.Vector(0, 0, 1), self.obj.CutPatternAngle
                )
            bbC = self.shape.BoundBox.Center
            geomShape.Placement.Base = FreeCAD.Vector(bbC.x, bbC.y, 0.0 - geomShape.BoundBox.ZMin)

        if self.debugObjectsGroup:
            F = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmpGeometrySet")
            F.Shape = geomShape
            F.purgeTouched()
            self.debugObjectsGroup.addObject(F)

        if self.pattern == "Offset":
            return geomShape

        # Identify intersection of cross-section face and lineset
        cmnShape = self.shape.common(geomShape)

        if self.debugObjectsGroup:
            F = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmpPathGeometry")
            F.Shape = cmnShape
            F.purgeTouched()
            self.debugObjectsGroup.addObject(F)

        return cmnShape

    # Cut pattern methods
    def _Circular(self):
        GeoSet = []
        radialPasses = self._getRadialPasses()
        minRad = self.toolDiam * 0.45
        siX3 = 3 * self.obj.SampleInterval.Value
        minRadSI = (siX3 / 2.0) / math.pi

        if minRad < minRadSI:
            minRad = minRadSI

        Path.Log.debug(" -centerOfPattern: {}".format(self.centerOfPattern))
        # Make small center circle to start pattern
        if self.obj.StepOver > 50:
            circle = Part.makeCircle(minRad, self.centerOfPattern)
            GeoSet.append(circle)

        for lc in range(1, radialPasses + 1):
            rad = lc * self.cutOut
            if rad >= minRad:
                circle = Part.makeCircle(rad, self.centerOfPattern)
                GeoSet.append(circle)
        # Efor
        self.rawGeoList = GeoSet

    def _CircularZigZag(self):
        self._Circular()  # Use _Circular generator

    def _Line(self):
        GeoSet = []
        centRot = FreeCAD.Vector(0.0, 0.0, 0.0)  # Bottom left corner of face/selection/model

        # Create end points for set of lines to intersect with cross-section face
        pntTuples = []
        for lc in range((-1 * (self.halfPasses - 1)), self.halfPasses + 1):
            x1 = centRot.x - self.halfDiag
            x2 = centRot.x + self.halfDiag
            y1 = centRot.y + (lc * self.cutOut)
            # y2 = y1
            p1 = FreeCAD.Vector(x1, y1, 0.0)
            p2 = FreeCAD.Vector(x2, y1, 0.0)
            pntTuples.append((p1, p2))

        # Convert end points to lines
        for p1, p2 in pntTuples:
            line = Part.makeLine(p1, p2)
            GeoSet.append(line)

        self.rawGeoList = GeoSet

    def _Offset(self):
        self.rawGeoList = self._extractOffsetFaces()

    def _Spiral(self):
        GeoSet = []
        SEGS = []
        draw = True
        loopRadians = 0.0  # Used to keep track of complete loops/cycles
        sumRadians = 0.0
        loopCnt = 0
        segCnt = 0
        twoPi = 2.0 * math.pi
        maxDist = math.ceil(self.cutOut * self._getRadialPasses())  # self.halfDiag
        move = self.centerOfPattern  # Use to translate the center of the spiral
        lastPoint = FreeCAD.Vector(0.0, 0.0, 0.0)

        # Set tool properties and calculate cutout
        cutOut = self.cutOut / twoPi
        segLen = self.obj.SampleInterval.Value  # CutterDiameter / 10.0  # SampleInterval.Value
        stepAng = segLen / ((loopCnt + 1) * self.cutOut)  # math.pi / 18.0  # 10 degrees
        stopRadians = maxDist / cutOut

        if self.obj.CutPatternReversed:
            if self.obj.CutMode == "Conventional":
                getPoint = self._makeOppSpiralPnt
            else:
                getPoint = self._makeRegSpiralPnt

            while draw:
                radAng = sumRadians + stepAng
                p1 = lastPoint
                p2 = getPoint(move, cutOut, radAng)  # cutOut is 'b' in the equation r = b * radAng
                sumRadians += stepAng  # Increment sumRadians
                loopRadians += stepAng  # Increment loopRadians
                if loopRadians > twoPi:
                    loopCnt += 1
                    loopRadians -= twoPi
                    stepAng = segLen / (
                        (loopCnt + 1) * self.cutOut
                    )  # adjust stepAng with each loop/cycle
                segCnt += 1
                lastPoint = p2
                if sumRadians > stopRadians:
                    draw = False
                # Create line and show in Object tree
                lineSeg = Part.makeLine(p2, p1)
                SEGS.append(lineSeg)
            # Ewhile
            SEGS.reverse()
        else:
            if self.obj.CutMode == "Climb":
                getPoint = self._makeOppSpiralPnt
            else:
                getPoint = self._makeRegSpiralPnt

            while draw:
                radAng = sumRadians + stepAng
                p1 = lastPoint
                p2 = getPoint(move, cutOut, radAng)  # cutOut is 'b' in the equation r = b * radAng
                sumRadians += stepAng  # Increment sumRadians
                loopRadians += stepAng  # Increment loopRadians
                if loopRadians > twoPi:
                    loopCnt += 1
                    loopRadians -= twoPi
                    stepAng = segLen / (
                        (loopCnt + 1) * self.cutOut
                    )  # adjust stepAng with each loop/cycle
                segCnt += 1
                lastPoint = p2
                if sumRadians > stopRadians:
                    draw = False
                # Create line and show in Object tree
                lineSeg = Part.makeLine(p1, p2)
                SEGS.append(lineSeg)
            # Ewhile
        # Eif
        spiral = Part.Wire([ls.Edges[0] for ls in SEGS])
        GeoSet.append(spiral)

        self.rawGeoList = GeoSet

    def _ZigZag(self):
        self._Line()  # Use _Line generator

    # Support methods
    def _getPatternCenter(self):
        centerAt = self.obj.PatternCenterAt

        if centerAt == "CenterOfMass":
            cntrPnt = FreeCAD.Vector(self.centerOfMass.x, self.centerOfMass.y, 0.0)
        elif centerAt == "CenterOfBoundBox":
            cent = self.shape.BoundBox.Center
            cntrPnt = FreeCAD.Vector(cent.x, cent.y, 0.0)
        elif centerAt == "XminYmin":
            cntrPnt = FreeCAD.Vector(self.shape.BoundBox.XMin, self.shape.BoundBox.YMin, 0.0)
        elif centerAt == "Custom":
            cntrPnt = FreeCAD.Vector(
                self.obj.PatternCenterCustom.x, self.obj.PatternCenterCustom.y, 0.0
            )

        # Update centerOfPattern point
        if centerAt != "Custom":
            self.obj.PatternCenterCustom = cntrPnt
        self.centerOfPattern = cntrPnt

        return cntrPnt

    def _getRadialPasses(self):
        # recalculate number of passes, if need be
        radialPasses = self.halfPasses
        if self.obj.PatternCenterAt != "CenterOfBoundBox":
            # make 4 corners of boundbox in XY plane, find which is greatest distance to new circular center
            EBB = self.shape.BoundBox
            CORNERS = [
                FreeCAD.Vector(EBB.XMin, EBB.YMin, 0.0),
                FreeCAD.Vector(EBB.XMin, EBB.YMax, 0.0),
                FreeCAD.Vector(EBB.XMax, EBB.YMax, 0.0),
                FreeCAD.Vector(EBB.XMax, EBB.YMin, 0.0),
            ]
            dMax = 0.0
            for c in range(0, 4):
                dist = CORNERS[c].sub(self.centerOfPattern).Length
                if dist > dMax:
                    dMax = dist
            diag = dMax + (
                2.0 * self.toolDiam
            )  # Line length to span boundbox diag with 2x cutter diameter extra on each end
            radialPasses = (
                math.ceil(diag / self.cutOut) + 1
            )  # Number of lines(passes) required to cover boundbox diagonal

        return radialPasses

    def _makeRegSpiralPnt(self, move, b, radAng):
        x = b * radAng * math.cos(radAng)
        y = b * radAng * math.sin(radAng)
        return FreeCAD.Vector(x, y, 0.0).add(move)

    def _makeOppSpiralPnt(self, move, b, radAng):
        x = b * radAng * math.cos(radAng)
        y = b * radAng * math.sin(radAng)
        return FreeCAD.Vector(-1 * x, y, 0.0).add(move)

    def _extractOffsetFaces(self):
        Path.Log.debug("_extractOffsetFaces()")
        wires = []
        shape = self.shape
        offset = 0.0  # Start right at the edge of cut area
        direction = 0
        loop_cnt = 0

        def _get_direction(w):
            if PathOpUtil._isWireClockwise(w):
                return 1
            return -1

        def _reverse_wire(w):
            rev_list = []
            for e in w.Edges:
                rev_list.append(PathUtils.reverseEdge(e))
            rev_list.reverse()
            # return Part.Wire(Part.__sortEdges__(rev_list))
            return Part.Wire(rev_list)

        while True:
            offsetArea = PathUtils.getOffsetArea(shape, offset, plane=self.wpc)
            if not offsetArea:
                # Area fully consumed
                break

            # set initial cut direction
            if direction == 0:
                first_face_wire = offsetArea.Faces[0].Wires[0]
                direction = _get_direction(first_face_wire)
                if self.obj.CutMode == "Climb":
                    if direction == 1:
                        direction = -1
                else:
                    if direction == -1:
                        direction = 1

            # Correct cut direction for `Conventional` cuts
            if self.obj.CutMode == "Conventional":
                if loop_cnt == 1:
                    direction = direction * -1

            # process each wire within face
            for f in offsetArea.Faces:
                wire_cnt = 0
                for w in f.Wires:
                    use_direction = direction
                    if wire_cnt > 0:
                        # swap direction for internal features
                        use_direction = direction * -1
                    wire_direction = _get_direction(w)
                    # Process wire
                    if wire_direction == use_direction:
                        # direction is correct
                        wires.append(w)
                    else:
                        # incorrect direction, so reverse wire
                        rw = _reverse_wire(w)
                        wires.append(rw)

            offset -= self.cutOut
            loop_cnt += 1
        return wires


# Eclass


class ProcessSelectedFaces:
    """ProcessSelectedFaces(JOB, obj) class.
    This class processes the `obj.Base` object for selected geometery.
    Calling the preProcessModel(module) method returns
    two compound objects as a tuple: (FACES, VOIDS) or False."""

    def __init__(self, JOB, obj):
        self.modelSTLs = []
        self.profileShapes = []
        self.tempGroup = False
        self.showDebugObjects = False
        self.checkBase = False
        self.module = None
        self.radius = None
        self.depthParams = None
        self.msgNoFaces = (
            translate(
                "PathSurfaceSupport",
                "Face selection is unavailable for Rotational scans.",
            )
            + "\n"
        )
        self.msgNoFaces += " " + translate("PathSurfaceSupport", "Ignoring selected faces.") + "\n"
        self.JOB = JOB
        self.obj = obj
        self.profileEdges = "None"

        if hasattr(obj, "ProfileEdges"):
            self.profileEdges = obj.ProfileEdges

        # Setup STL, model type, and bound box containers for each model in Job
        for m in range(0, len(JOB.Model.Group)):
            self.modelSTLs.append(False)
            self.profileShapes.append(False)

        # make circle for workplane
        self.wpc = Part.makeCircle(2.0)

    def PathSurface(self):
        if self.obj.Base:
            if len(self.obj.Base) > 0:
                self.checkBase = True
                if self.obj.ScanType == "Rotational":
                    self.checkBase = False
                    FreeCAD.Console.PrintWarning(self.msgNoFaces)

    def PathWaterline(self):
        if self.obj.Base:
            if len(self.obj.Base) > 0:
                self.checkBase = True
                if self.obj.Algorithm in ["OCL Dropcutter", "Experimental"]:
                    self.checkBase = False
                    FreeCAD.Console.PrintWarning(self.msgNoFaces)

    # public class methods
    def setShowDebugObjects(self, grpObj, val):
        self.tempGroup = grpObj
        self.showDebugObjects = val

    def preProcessModel(self, module):
        Path.Log.debug("preProcessModel()")

        if not self._isReady(module):
            return False

        FACES = []
        VOIDS = []
        fShapes = []
        vShapes = []
        GRP = self.JOB.Model.Group
        lenGRP = len(GRP)
        proceed = False

        # Crete place holders for each base model in Job
        for m in range(0, lenGRP):
            FACES.append(False)
            VOIDS.append(False)
            fShapes.append(False)
            vShapes.append(False)

        # The user has selected subobjects from the base.  Pre-Process each.
        if self.checkBase:
            Path.Log.debug(" -obj.Base exists. Pre-processing for selected faces.")

            (hasFace, hasVoid) = self._identifyFacesAndVoids(
                FACES, VOIDS
            )  # modifies FACES and VOIDS
            hasGeometry = True if hasFace or hasVoid else False

            # Cycle through each base model, processing faces for each
            for m in range(0, lenGRP):
                base = GRP[m]
                (mFS, mVS, mPS) = self._preProcessFacesAndVoids(base, FACES[m], VOIDS[m])
                fShapes[m] = mFS
                vShapes[m] = mVS
                self.profileShapes[m] = mPS
                if mFS or mVS:
                    proceed = True
            if hasGeometry and not proceed:
                return False
        else:
            Path.Log.debug(" -No obj.Base data.")
            for m in range(0, lenGRP):
                self.modelSTLs[m] = True

        # Process each model base, as a whole, as needed
        for m in range(0, lenGRP):
            if self.modelSTLs[m] and not fShapes[m]:
                Path.Log.debug(" -Pre-processing {} as a whole.".format(GRP[m].Label))
                if self.obj.BoundBox == "BaseBoundBox":
                    base = GRP[m]
                elif self.obj.BoundBox == "Stock":
                    base = self.JOB.Stock

                pPEB = self._preProcessEntireBase(base, m)
                if pPEB is False:
                    msg = (
                        translate(
                            "PathSurfaceSupport",
                            "Failed to pre-process base as a whole.",
                        )
                        + "\n"
                    )
                    FreeCAD.Console.PrintError(msg)
                else:
                    (fcShp, prflShp) = pPEB
                    if fcShp:
                        if fcShp is True:
                            Path.Log.debug(" -fcShp is True.")
                            fShapes[m] = True
                        else:
                            fShapes[m] = [fcShp]
                    if prflShp:
                        if fcShp:
                            Path.Log.debug("vShapes[{}]: {}".format(m, vShapes[m]))
                            if vShapes[m]:
                                Path.Log.debug(" -Cutting void from base profile shape.")
                                adjPS = prflShp.cut(vShapes[m][0])
                                self.profileShapes[m] = [adjPS]
                            else:
                                Path.Log.debug(" -vShapes[m] is False.")
                                self.profileShapes[m] = [prflShp]
                        else:
                            Path.Log.debug(" -Saving base profile shape.")
                            self.profileShapes[m] = [prflShp]
                        Path.Log.debug(
                            "self.profileShapes[{}]: {}".format(m, self.profileShapes[m])
                        )
        # Efor

        return (fShapes, vShapes)

    # private class methods
    def _isReady(self, module):
        """_isReady(module)... Internal method.
        Checks if required attributes are available for processing obj.Base (the Base Geometry)."""
        Path.Log.debug("ProcessSelectedFaces _isReady({})".format(module))
        modMethodName = module.replace("Op.", "Path")
        if hasattr(self, modMethodName):
            self.module = module
            modMethod = getattr(self, modMethodName)  # gets the attribute only
            modMethod()  # executes as method
        else:
            Path.Log.error('PSF._isReady() no "{}" method.'.format(module))
            return False

        if not self.radius:
            Path.Log.error("PSF._isReady() no cutter radius available.")
            return False

        if not self.depthParams:
            Path.Log.error("PSF._isReady() no depth params available.")
            return False

        return True

    def _identifyFacesAndVoids(self, F, V):
        TUPS = []
        GRP = self.JOB.Model.Group
        lenGRP = len(GRP)
        hasFace = False
        hasVoid = False

        # Separate selected faces into (base, face) tuples and flag model(s) for STL creation
        for bs, SBS in self.obj.Base:
            for sb in SBS:
                # Flag model for STL creation
                mdlIdx = None
                for m in range(0, lenGRP):
                    if bs is GRP[m]:
                        self.modelSTLs[m] = True
                        mdlIdx = m
                        break
                TUPS.append((mdlIdx, bs, sb))  # (model idx, base, sub)

        # Apply `AvoidXFaces` value
        faceCnt = len(TUPS)
        add = faceCnt - self.obj.AvoidLastX_Faces
        for bst in range(0, faceCnt):
            (m, base, sub) = TUPS[bst]
            shape = getattr(base.Shape, sub)
            if isinstance(shape, Part.Face):
                faceIdx = int(sub[4:]) - 1
                if bst < add:
                    if F[m] is False:
                        F[m] = []
                    F[m].append((shape, faceIdx))
                    Path.Log.debug(".. Cutting {}".format(sub))
                    hasFace = True
                else:
                    if V[m] is False:
                        V[m] = []
                    V[m].append((shape, faceIdx))
                    Path.Log.debug(".. Avoiding {}".format(sub))
                    hasVoid = True
        return (hasFace, hasVoid)

    def _preProcessFacesAndVoids(self, base, FCS, VDS):
        mFS = False
        mVS = False
        mPS = False
        mIFS = []

        if FCS:
            isHole = False
            if self.obj.HandleMultipleFeatures == "Collectively":
                cont = True
                Path.Log.debug("Attempting to get cross-section of collective faces.")
                outFCS, ifL = self.findUnifiedRegions(FCS)
                if self.obj.InternalFeaturesCut and ifL:
                    ifL = []  # clear avoid shape list

                if len(outFCS) == 0:
                    msg = "PathSurfaceSupport \n Cannot process selected faces. Check horizontal \n surface exposure.\n"
                    FreeCAD.Console.PrintError(msg)
                    cont = False
                else:
                    cfsL = Part.makeCompound(outFCS)

                # Handle profile edges request
                if cont and self.profileEdges != "None":
                    Path.Log.debug(".. include Profile Edge")
                    ofstVal = self._calculateOffsetValue(isHole)
                    psOfst = PathUtils.getOffsetArea(cfsL, ofstVal, plane=self.wpc)
                    if psOfst:
                        mPS = [psOfst]
                        if self.profileEdges == "Only":
                            mFS = True
                            cont = False
                    else:
                        cont = False

                if cont:
                    if self.showDebugObjects:
                        T = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmpCollectiveShape")
                        T.Shape = cfsL
                        T.purgeTouched()
                        self.tempGroup.addObject(T)

                    ofstVal = self._calculateOffsetValue(isHole)
                    faceOfstShp = PathUtils.getOffsetArea(cfsL, ofstVal, plane=self.wpc)
                    if not faceOfstShp:
                        msg = "Failed to create offset face."
                        FreeCAD.Console.PrintError(msg)
                        cont = False

                if cont:
                    lenIfL = len(ifL)
                    if not self.obj.InternalFeaturesCut:
                        if lenIfL == 0:
                            Path.Log.debug(" -No internal features saved.")
                        else:
                            if lenIfL == 1:
                                casL = ifL[0]
                            else:
                                casL = Part.makeCompound(ifL)
                            if self.showDebugObjects:
                                C = FreeCAD.ActiveDocument.addObject(
                                    "Part::Feature", "tmpCompoundIntFeat"
                                )
                                C.Shape = casL
                                C.purgeTouched()
                                self.tempGroup.addObject(C)
                            ofstVal = self._calculateOffsetValue(isHole=True)
                            intOfstShp = PathUtils.getOffsetArea(casL, ofstVal, plane=self.wpc)
                            mIFS.append(intOfstShp)

                    mFS = [faceOfstShp]
                # Eif

            elif self.obj.HandleMultipleFeatures == "Individually":
                for fcshp, fcIdx in FCS:
                    cont = True
                    fNum = fcIdx + 1
                    outerFace = False

                    gUR, ifL = self.findUnifiedRegions(FCS)
                    if len(gUR) > 0:
                        outerFace = gUR[0]
                    if self.obj.InternalFeaturesCut:
                        ifL = []  # avoid shape list

                    if outerFace:
                        Path.Log.debug("Attempting to create offset face of Face{}".format(fNum))

                        if self.profileEdges != "None":
                            ofstVal = self._calculateOffsetValue(isHole)
                            psOfst = PathUtils.getOffsetArea(outerFace, ofstVal, plane=self.wpc)
                            if psOfst:
                                if mPS is False:
                                    mPS = []
                                mPS.append(psOfst)
                                if self.profileEdges == "Only":
                                    if mFS is False:
                                        mFS = []
                                    mFS.append(True)
                                    cont = False
                            else:
                                cont = False

                        if cont:
                            ofstVal = self._calculateOffsetValue(isHole)
                            faceOfstShp = PathUtils.getOffsetArea(
                                outerFace, ofstVal, plane=self.wpc
                            )

                            lenIfl = len(ifL)
                            if self.obj.InternalFeaturesCut is False and lenIfl > 0:
                                if lenIfl == 1:
                                    casL = ifL[0]
                                else:
                                    casL = Part.makeCompound(ifL)

                                ofstVal = self._calculateOffsetValue(isHole=True)
                                intOfstShp = PathUtils.getOffsetArea(casL, ofstVal, plane=self.wpc)
                                mIFS.append(intOfstShp)
                                # faceOfstShp = faceOfstShp.cut(intOfstShp)

                            if mFS is False:
                                mFS = []
                            mFS.append(faceOfstShp)
                    # Eif
                # Efor
            # Eif
        # Eif

        if len(mIFS) > 0:
            if mVS is False:
                mVS = []
            for ifs in mIFS:
                mVS.append(ifs)

        if VDS:
            Path.Log.debug("Processing avoid faces.")
            cont = True
            isHole = False

            outFCS, intFEAT = self.findUnifiedRegions(VDS)
            if self.obj.InternalFeaturesCut:
                intFEAT = []

            lenOtFcs = len(outFCS)
            if lenOtFcs == 0:
                cont = False
            else:
                if lenOtFcs == 1:
                    avoid = outFCS[0]
                else:
                    avoid = Part.makeCompound(outFCS)

                if self.showDebugObjects:
                    P = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmpVoidEnvelope")
                    P.Shape = avoid
                    P.purgeTouched()
                    self.tempGroup.addObject(P)

            if cont:
                if self.showDebugObjects:
                    P = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmpVoidCompound")
                    P.Shape = avoid
                    P.purgeTouched()
                    self.tempGroup.addObject(P)
                ofstVal = self._calculateOffsetValue(isHole, isVoid=True)
                avdOfstShp = PathUtils.getOffsetArea(avoid, ofstVal, plane=self.wpc)
                if avdOfstShp is False:
                    msg = "Failed to create collective offset avoid face.\n"
                    FreeCAD.Console.PrintError(msg)
                    cont = False

            if cont:
                avdShp = avdOfstShp

                if not self.obj.AvoidLastX_InternalFeatures and len(intFEAT) > 0:
                    if len(intFEAT) > 1:
                        ifc = Part.makeCompound(intFEAT)
                    else:
                        ifc = intFEAT[0]
                    ofstVal = self._calculateOffsetValue(isHole=True)
                    ifOfstShp = PathUtils.getOffsetArea(ifc, ofstVal, plane=self.wpc)
                    if ifOfstShp is False:
                        msg = "Failed to create collective offset avoid internal features.\n"
                        FreeCAD.Console.PrintError(msg)
                    else:
                        avdShp = avdOfstShp.cut(ifOfstShp)

                if mVS is False:
                    mVS = []
                mVS.append(avdShp)

        return (mFS, mVS, mPS)

    def _preProcessEntireBase(self, base, m):
        cont = True
        isHole = False
        prflShp = False
        # Create envelope, extract cross-section and make offset co-planar shape
        # baseEnv = PathUtils.getEnvelope(base.Shape, subshape=None, depthparams=self.depthParams)

        try:
            baseEnv = PathUtils.getEnvelope(
                partshape=base.Shape, subshape=None, depthparams=self.depthParams
            )  # Produces .Shape
        except Exception as ee:
            Path.Log.error(str(ee))
            shell = base.Shape.Shells[0]
            solid = Part.makeSolid(shell)
            try:
                baseEnv = PathUtils.getEnvelope(
                    partshape=solid, subshape=None, depthparams=self.depthParams
                )  # Produces .Shape
            except Exception as eee:
                Path.Log.error(str(eee))
                cont = False

        if cont:
            csFaceShape = getShapeSlice(baseEnv)
            if csFaceShape is False:
                csFaceShape = getCrossSection(baseEnv)
                if csFaceShape is False:
                    csFaceShape = getSliceFromEnvelope(baseEnv)
            if csFaceShape is False:
                Path.Log.debug("Failed to slice baseEnv shape.")
                cont = False

        if cont and self.profileEdges != "None":
            Path.Log.debug(" -Attempting profile geometry for model base.")
            ofstVal = self._calculateOffsetValue(isHole)
            psOfst = PathUtils.getOffsetArea(csFaceShape, ofstVal, plane=self.wpc)
            if psOfst:
                if self.profileEdges == "Only":
                    return (True, psOfst)
                prflShp = psOfst
            else:
                cont = False

        if cont:
            ofstVal = self._calculateOffsetValue(isHole)
            faceOffsetShape = PathUtils.getOffsetArea(csFaceShape, ofstVal, plane=self.wpc)
            if faceOffsetShape is False:
                Path.Log.debug("getOffsetArea() failed for entire base.")
            else:
                faceOffsetShape.translate(
                    FreeCAD.Vector(0.0, 0.0, 0.0 - faceOffsetShape.BoundBox.ZMin)
                )
                return (faceOffsetShape, prflShp)
        return False

    def _calculateOffsetValue(self, isHole, isVoid=False):
        """_calculateOffsetValue(self.obj, isHole, isVoid) ... internal function.
        Calculate the offset for the Path.Area() function."""
        self.JOB = PathUtils.findParentJob(self.obj)
        # We need to offset by at least our linear tessellation deflection
        # (default GeometryTolerance / 4) to avoid false retracts at the
        # boundaries.
        tolrnc = max(self.JOB.GeometryTolerance.Value / 10.0, self.obj.LinearDeflection.Value)

        if isVoid is False:
            if isHole is True:
                offset = -1 * self.obj.InternalFeaturesAdjustment.Value
                offset += self.radius + tolrnc
            else:
                offset = -1 * self.obj.BoundaryAdjustment.Value
                if self.obj.BoundaryEnforcement is True:
                    offset += self.radius + tolrnc
                else:
                    offset -= self.radius + tolrnc
                offset = 0.0 - offset
        else:
            offset = -1 * self.obj.BoundaryAdjustment.Value
            offset += self.radius + tolrnc

        return offset

    def findUnifiedRegions(self, shapeAndIndexTuples, useAreaImplementation=True):
        """Wrapper around area and wire based region unification
        implementations."""
        Path.Log.debug("findUnifiedRegions()")
        # Allow merging of faces within the LinearDeflection tolerance.
        tolerance = self.obj.LinearDeflection.Value
        # Default: normal to Z=1 (XY plane), at Z=0
        try:
            # Use Area based implementation
            shapes = Part.makeCompound([t[0] for t in shapeAndIndexTuples])
            outlineShape = PathUtils.getOffsetArea(
                shapes,
                # Make the outline very slightly smaller, to avoid creating
                # small edges in the cut with the hole-preserving projection.
                0.0 - tolerance / 10,
                removeHoles=True,  # Outline has holes filled in
                tolerance=tolerance,
                plane=self.wpc,
            )
            projectionShape = PathUtils.getOffsetArea(
                shapes,
                # Make the projection very slightly larger
                tolerance / 10,
                removeHoles=False,  # Projection has holes preserved
                tolerance=tolerance,
                plane=self.wpc,
            )
            internalShape = outlineShape.cut(projectionShape)
            # Filter out tiny faces, usually artifacts around the perimeter of
            # the cut.
            minArea = (10 * tolerance) ** 2
            internalFaces = [f for f in internalShape.Faces if f.Area > minArea]
            if internalFaces:
                internalFaces = Part.makeCompound(internalFaces)
            return ([outlineShape], [internalFaces])
        except Exception as e:
            Path.Log.warning("getOffsetArea failed: {}; Using FindUnifiedRegions.".format(e))
        # Use face-unifying class
        FUR = FindUnifiedRegions(shapeAndIndexTuples, tolerance)
        if self.showDebugObjects:
            FUR.setTempGroup(self.tempGroup)
        return (FUR.getUnifiedRegions(), FUR.getInternalFeatures)


# Eclass


# Functions for getting a shape envelope and cross-section
def getExtrudedShape(wire):
    Path.Log.debug("getExtrudedShape()")
    wBB = wire.BoundBox
    extFwd = math.floor(2.0 * wBB.ZLength) + 10.0

    try:
        shell = wire.extrude(FreeCAD.Vector(0.0, 0.0, extFwd))
    except Exception as ee:
        Path.Log.error(" -extrude wire failed: \n{}".format(ee))
        return False

    SHP = Part.makeSolid(shell)
    return SHP


def getShapeSlice(shape):
    Path.Log.debug("getShapeSlice()")

    bb = shape.BoundBox
    mid = (bb.ZMin + bb.ZMax) / 2.0
    xmin = bb.XMin - 1.0
    xmax = bb.XMax + 1.0
    ymin = bb.YMin - 1.0
    ymax = bb.YMax + 1.0
    p1 = FreeCAD.Vector(xmin, ymin, mid)
    p2 = FreeCAD.Vector(xmax, ymin, mid)
    p3 = FreeCAD.Vector(xmax, ymax, mid)
    p4 = FreeCAD.Vector(xmin, ymax, mid)

    e1 = Part.makeLine(p1, p2)
    e2 = Part.makeLine(p2, p3)
    e3 = Part.makeLine(p3, p4)
    e4 = Part.makeLine(p4, p1)
    face = Part.Face(Part.Wire([e1, e2, e3, e4]))
    fArea = face.BoundBox.XLength * face.BoundBox.YLength  # face.Wires[0].Area
    sArea = shape.BoundBox.XLength * shape.BoundBox.YLength
    midArea = (fArea + sArea) / 2.0

    slcShp = shape.common(face)
    slcArea = slcShp.BoundBox.XLength * slcShp.BoundBox.YLength

    if slcArea < midArea:
        for W in slcShp.Wires:
            if W.isClosed() is False:
                Path.Log.debug(" -wire.isClosed() is False")
                return False
        if len(slcShp.Wires) == 1:
            wire = slcShp.Wires[0]
            slc = Part.Face(wire)
            slc.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - slc.BoundBox.ZMin))
            return slc
        else:
            fL = []
            for W in slcShp.Wires:
                slc = Part.Face(W)
                slc.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - slc.BoundBox.ZMin))
                fL.append(slc)
            comp = Part.makeCompound(fL)
            return comp

    return False


def getProjectedFace(tempGroup, wire):
    import Draft

    Path.Log.debug("getProjectedFace()")
    F = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmpProjectionWire")
    F.Shape = wire
    F.purgeTouched()
    tempGroup.addObject(F)
    try:
        prj = Draft.makeShape2DView(F, FreeCAD.Vector(0, 0, 1))
        prj.recompute()
        prj.purgeTouched()
        tempGroup.addObject(prj)
    except Exception as ee:
        Path.Log.error(str(ee))
        return False
    else:
        pWire = Part.Wire(prj.Shape.Edges)
        if pWire.isClosed() is False:
            return False
        slc = Part.Face(pWire)
        slc.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - slc.BoundBox.ZMin))
        return slc


def getCrossSection(shape):
    Path.Log.debug("getCrossSection()")
    wires = []
    bb = shape.BoundBox
    mid = (bb.ZMin + bb.ZMax) / 2.0

    for i in shape.slice(FreeCAD.Vector(0, 0, 1), mid):
        wires.append(i)

    if len(wires) > 0:
        comp = Part.Compound(wires)  # produces correct cross-section wire !
        comp.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - comp.BoundBox.ZMin))
        csWire = comp.Wires[0]
        if csWire.isClosed() is False:
            Path.Log.debug(" -comp.Wires[0] is not closed")
            return False
        CS = Part.Face(csWire)
        CS.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - CS.BoundBox.ZMin))
        return CS
    else:
        Path.Log.debug(" -No wires from .slice() method")

    return False


def getShapeEnvelope(shape):
    Path.Log.debug("getShapeEnvelope()")

    wBB = shape.BoundBox
    extFwd = wBB.ZLength + 10.0
    minz = wBB.ZMin
    maxz = wBB.ZMin + extFwd
    stpDwn = (maxz - minz) / 4.0
    dep_par = PathUtils.depth_params(maxz + 5.0, maxz + 3.0, maxz, stpDwn, 0.0, minz)

    try:
        env = PathUtils.getEnvelope(partshape=shape, depthparams=dep_par)  # Produces .Shape
    except Exception as ee:
        FreeCAD.Console.PrintError("PathUtils.getEnvelope() failed.\n" + str(ee) + "\n")
        return False
    else:
        return env


def getSliceFromEnvelope(env):
    Path.Log.debug("getSliceFromEnvelope()")
    eBB = env.BoundBox
    extFwd = eBB.ZLength + 10.0
    maxz = eBB.ZMin + extFwd

    emax = math.floor(maxz - 1.0)
    E = []
    for e in range(0, len(env.Edges)):
        emin = env.Edges[e].BoundBox.ZMin
        if emin > emax:
            E.append(env.Edges[e])
    tf = Part.Face(Part.Wire(Part.__sortEdges__(E)))
    tf.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - tf.BoundBox.ZMin))

    return tf


def _prepareModelSTLs(self, JOB, obj, m, ocl):
    """Tessellate model shapes or copy existing meshes into ocl.STLSurf
    objects"""
    if self.modelSTLs[m] is True:
        model = JOB.Model.Group[m]
        self.modelSTLs[m] = _makeSTL(model, obj, ocl, self.modelTypes[m])


def _makeSafeSTL(self, JOB, obj, mdlIdx, faceShapes, voidShapes, ocl):
    """_makeSafeSTL(JOB, obj, mdlIdx, faceShapes, voidShapes)...
    Creates and OCL.stl object with combined data with waste stock,
    model, and avoided faces.  Travel lines can be checked against this
    STL object to determine minimum travel height to clear stock and model."""
    Path.Log.debug("_makeSafeSTL()")

    fuseShapes = []
    Mdl = JOB.Model.Group[mdlIdx]
    mBB = Mdl.Shape.BoundBox
    sBB = JOB.Stock.Shape.BoundBox

    # add Model shape to safeSTL shape
    fuseShapes.append(Mdl.Shape)

    if obj.BoundBox == "BaseBoundBox":
        cont = False
        extFwd = sBB.ZLength
        zmin = mBB.ZMin
        zmax = mBB.ZMin + extFwd
        stpDwn = (zmax - zmin) / 4.0
        dep_par = PathUtils.depth_params(zmax + 5.0, zmax + 3.0, zmax, stpDwn, 0.0, zmin)

        try:
            envBB = PathUtils.getEnvelope(
                partshape=Mdl.Shape, depthparams=dep_par
            )  # Produces .Shape
            cont = True
        except Exception as ee:
            Path.Log.error(str(ee))
            shell = Mdl.Shape.Shells[0]
            solid = Part.makeSolid(shell)
            try:
                envBB = PathUtils.getEnvelope(
                    partshape=solid, depthparams=dep_par
                )  # Produces .Shape
                cont = True
            except Exception as eee:
                Path.Log.error(str(eee))

        if cont:
            stckWst = JOB.Stock.Shape.cut(envBB)
            if obj.BoundaryAdjustment > 0.0:
                cmpndFS = Part.makeCompound(faceShapes)
                baBB = PathUtils.getEnvelope(
                    partshape=cmpndFS, depthparams=self.depthParams
                )  # Produces .Shape
                adjStckWst = stckWst.cut(baBB)
            else:
                adjStckWst = stckWst
            fuseShapes.append(adjStckWst)
        else:
            msg = "Path transitions might not avoid the model. Verify paths.\n"
            FreeCAD.Console.PrintWarning(msg)
    else:
        # If boundbox is Job.Stock, add hidden pad under stock as base plate
        toolDiam = self.cutter.getDiameter()
        zMin = JOB.Stock.Shape.BoundBox.ZMin
        xMin = JOB.Stock.Shape.BoundBox.XMin - toolDiam
        yMin = JOB.Stock.Shape.BoundBox.YMin - toolDiam
        bL = JOB.Stock.Shape.BoundBox.XLength + (2 * toolDiam)
        bW = JOB.Stock.Shape.BoundBox.YLength + (2 * toolDiam)
        bH = 1.0
        crnr = FreeCAD.Vector(xMin, yMin, zMin - 1.0)
        B = Part.makeBox(bL, bW, bH, crnr, FreeCAD.Vector(0, 0, 1))
        fuseShapes.append(B)

    if voidShapes:
        voidComp = Part.makeCompound(voidShapes)
        voidEnv = PathUtils.getEnvelope(
            partshape=voidComp, depthparams=self.depthParams
        )  # Produces .Shape
        fuseShapes.append(voidEnv)

    fused = Part.makeCompound(fuseShapes)

    if self.showDebugObjects:
        T = FreeCAD.ActiveDocument.addObject("Part::Feature", "safeSTLShape")
        T.Shape = fused
        T.purgeTouched()
        self.tempGroup.addObject(T)

    self.safeSTLs[mdlIdx] = _makeSTL(fused, obj, ocl)


def _makeSTL(model, obj, ocl, model_type=None):
    """Convert a mesh or shape into an OCL STL, using the tessellation
    tolerance specified in obj.LinearDeflection.
    Returns an ocl.STLSurf()."""
    if model_type == "M":
        facets = model.Mesh.Facets.Points
    else:
        if hasattr(model, "Shape"):
            shape = model.Shape
        else:
            shape = model
        vertices, facet_indices = shape.tessellate(obj.LinearDeflection.Value)
        facets = ((vertices[f[0]], vertices[f[1]], vertices[f[2]]) for f in facet_indices)
    stl = ocl.STLSurf()
    for tri in facets:
        v1, v2, v3 = tri
        t = ocl.Triangle(
            ocl.Point(v1[0], v1[1], v1[2]),
            ocl.Point(v2[0], v2[1], v2[2]),
            ocl.Point(v3[0], v3[1], v3[2]),
        )
        stl.addTriangle(t)
    return stl


# Functions to convert path geometry into line/arc segments for OCL input or directly to g-code
def pathGeomToLinesPointSet(self, obj, compGeoShp):
    """pathGeomToLinesPointSet(self, obj, compGeoShp)...
    Convert a compound set of sequential line segments to directionally-oriented collinear groupings.
    """
    Path.Log.debug("pathGeomToLinesPointSet()")
    # Extract intersection line segments for return value as []
    LINES = []
    inLine = []
    chkGap = False
    lnCnt = 0
    ec = len(compGeoShp.Edges)
    cpa = obj.CutPatternAngle

    edg0 = compGeoShp.Edges[0]
    p1 = (edg0.Vertexes[0].X, edg0.Vertexes[0].Y)
    p2 = (edg0.Vertexes[1].X, edg0.Vertexes[1].Y)
    if self.CutClimb is True:
        tup = (p2, p1)
        lst = FreeCAD.Vector(p1[0], p1[1], 0.0)
    else:
        tup = (p1, p2)
        lst = FreeCAD.Vector(p2[0], p2[1], 0.0)
    inLine.append(tup)
    sp = FreeCAD.Vector(p1[0], p1[1], 0.0)  # start point

    for ei in range(1, ec):
        chkGap = False
        edg = compGeoShp.Edges[ei]  # Get edge for vertexes
        v1 = (edg.Vertexes[0].X, edg.Vertexes[0].Y)  # vertex 0
        v2 = (edg.Vertexes[1].X, edg.Vertexes[1].Y)  # vertex 1

        ep = FreeCAD.Vector(v2[0], v2[1], 0.0)  # end point
        cp = FreeCAD.Vector(v1[0], v1[1], 0.0)  # check point (first / middle point)
        # iC = sp.isOnLineSegment(ep, cp)
        iC = cp.isOnLineSegment(sp, ep)
        if iC is True:
            inLine.append("BRK")
            chkGap = True
        else:
            if self.CutClimb is True:
                inLine.reverse()
            LINES.append(inLine)  # Save inLine segments
            lnCnt += 1
            inLine = []  # reset collinear container
            if self.CutClimb is True:
                sp = cp  # FreeCAD.Vector(v1[0], v1[1], 0.0)
            else:
                sp = ep

        if self.CutClimb is True:
            tup = (v2, v1)
            if chkGap:
                gap = abs(self.toolDiam - lst.sub(ep).Length)
            lst = cp
        else:
            tup = (v1, v2)
            if chkGap:
                gap = abs(self.toolDiam - lst.sub(cp).Length)
            lst = ep

        if chkGap:
            if gap < obj.GapThreshold.Value:
                inLine.pop()  # pop off 'BRK' marker
                (
                    vA,
                    vB,
                ) = inLine.pop()  # pop off previous line segment for combining with current
                tup = (vA, tup[1])
                self.closedGap = True
            else:
                gap = round(gap, 6)
                if gap < self.gaps[0]:
                    self.gaps.insert(0, gap)
                    self.gaps.pop()
        inLine.append(tup)

    # Efor
    lnCnt += 1
    if self.CutClimb is True:
        inLine.reverse()
    LINES.append(inLine)  # Save inLine segments

    # Handle last inLine set, reversing it.
    if obj.CutPatternReversed is True:
        if cpa != 0.0 and cpa % 90.0 == 0.0:
            F = LINES.pop(0)
            rev = []
            for iL in F:
                if iL == "BRK":
                    rev.append(iL)
                else:
                    (p1, p2) = iL
                    rev.append((p2, p1))
            rev.reverse()
            LINES.insert(0, rev)

    isEven = lnCnt % 2
    if isEven == 0:
        Path.Log.debug("Line count is ODD: {}.".format(lnCnt))
    else:
        Path.Log.debug("Line count is even: {}.".format(lnCnt))

    return LINES


def pathGeomToZigzagPointSet(self, obj, compGeoShp):
    """_pathGeomToZigzagPointSet(self, obj, compGeoShp)...
    Convert a compound set of sequential line segments to directionally-oriented collinear groupings
    with a ZigZag directional indicator included for each collinear group."""
    Path.Log.debug("_pathGeomToZigzagPointSet()")
    # Extract intersection line segments for return value as []
    LINES = []
    inLine = []
    lnCnt = 0
    chkGap = False
    ec = len(compGeoShp.Edges)
    dirFlg = 1

    if self.CutClimb:
        dirFlg = -1

    edg0 = compGeoShp.Edges[0]
    p1 = (edg0.Vertexes[0].X, edg0.Vertexes[0].Y)
    p2 = (edg0.Vertexes[1].X, edg0.Vertexes[1].Y)
    if dirFlg == 1:
        tup = (p1, p2)
        lst = FreeCAD.Vector(p2[0], p2[1], 0.0)
        sp = FreeCAD.Vector(p1[0], p1[1], 0.0)  # start point
    else:
        tup = (p2, p1)
        lst = FreeCAD.Vector(p1[0], p1[1], 0.0)
        sp = FreeCAD.Vector(p2[0], p2[1], 0.0)  # start point
    inLine.append(tup)

    for ei in range(1, ec):
        edg = compGeoShp.Edges[ei]
        v1 = (edg.Vertexes[0].X, edg.Vertexes[0].Y)
        v2 = (edg.Vertexes[1].X, edg.Vertexes[1].Y)

        cp = FreeCAD.Vector(v1[0], v1[1], 0.0)  # check point (start point of segment)
        ep = FreeCAD.Vector(v2[0], v2[1], 0.0)  # end point
        iC = cp.isOnLineSegment(sp, ep)
        if iC:
            inLine.append("BRK")
            chkGap = True
            gap = abs(self.toolDiam - lst.sub(cp).Length)
        else:
            chkGap = False
            if dirFlg == -1:
                inLine.reverse()
            LINES.append(inLine)
            lnCnt += 1
            dirFlg = -1 * dirFlg  # Change zig to zag
            inLine = []  # reset collinear container
            sp = cp  # FreeCAD.Vector(v1[0], v1[1], 0.0)

        lst = ep
        if dirFlg == 1:
            tup = (v1, v2)
        else:
            tup = (v2, v1)

        if chkGap:
            if gap < obj.GapThreshold.Value:
                inLine.pop()  # pop off 'BRK' marker
                (
                    vA,
                    vB,
                ) = inLine.pop()  # pop off previous line segment for combining with current
                if dirFlg == 1:
                    tup = (vA, tup[1])
                else:
                    tup = (tup[0], vB)
                self.closedGap = True
            else:
                gap = round(gap, 6)
                if gap < self.gaps[0]:
                    self.gaps.insert(0, gap)
                    self.gaps.pop()
        inLine.append(tup)
    # Efor
    lnCnt += 1

    # Fix directional issue with LAST line when line count is even
    isEven = lnCnt % 2
    if isEven == 0:  #  Changed to != with 90 degree CutPatternAngle
        Path.Log.debug("Line count is even: {}.".format(lnCnt))
    else:
        Path.Log.debug("Line count is ODD: {}.".format(lnCnt))
        dirFlg = -1 * dirFlg
        if not obj.CutPatternReversed:
            if self.CutClimb:
                dirFlg = -1 * dirFlg

    if obj.CutPatternReversed:
        dirFlg = -1 * dirFlg

    # Handle last inLine list
    if dirFlg == 1:
        rev = []
        for iL in inLine:
            if iL == "BRK":
                rev.append(iL)
            else:
                (p1, p2) = iL
                rev.append((p2, p1))

        if not obj.CutPatternReversed:
            rev.reverse()
        else:
            rev2 = []
            for iL in rev:
                if iL == "BRK":
                    rev2.append(iL)
                else:
                    (p1, p2) = iL
                    rev2.append((p2, p1))
            rev2.reverse()
            rev = rev2
        LINES.append(rev)
    else:
        LINES.append(inLine)

    return LINES


def pathGeomToCircularPointSet(self, obj, compGeoShp):
    """pathGeomToCircularPointSet(self, obj, compGeoShp)...
    Convert a compound set of arcs/circles to a set of directionally-oriented arc end points
    and the corresponding center point."""
    # Extract intersection line segments for return value as []
    Path.Log.debug("pathGeomToCircularPointSet()")
    ARCS = []
    stpOvrEI = []
    segEI = []
    isSame = False
    sameRad = None
    ec = len(compGeoShp.Edges)

    def gapDist(sp, ep):
        X = (ep[0] - sp[0]) ** 2
        Y = (ep[1] - sp[1]) ** 2
        return math.sqrt(X + Y)  # the 'z' value is zero in both points

    def dist_to_cent(item):
        # Sort incoming arcs by distance to center
        # item: edge type, direction flag, parts tuple
        # parts: start tuple, end tuple, center tuple
        s = item[2][0][0]
        p1 = FreeCAD.Vector(s[0], s[1], 0.0)
        e = item[2][0][2]
        p2 = FreeCAD.Vector(e[0], e[1], 0.0)
        return p1.sub(p2).Length

    if obj.CutPatternReversed:
        if self.CutClimb:
            self.CutClimb = False
        else:
            self.CutClimb = True

    # Separate arc data into Loops and Arcs
    for ei in range(0, ec):
        edg = compGeoShp.Edges[ei]
        if edg.Closed is True:
            stpOvrEI.append(("L", ei, False))
        else:
            if isSame is False:
                segEI.append(ei)
                isSame = True
                pnt = FreeCAD.Vector(edg.Vertexes[0].X, edg.Vertexes[0].Y, 0.0)
                sameRad = pnt.sub(self.tmpCOM).Length
            else:
                # Check if arc is co-radial to current SEGS
                pnt = FreeCAD.Vector(edg.Vertexes[0].X, edg.Vertexes[0].Y, 0.0)
                if abs(sameRad - pnt.sub(self.tmpCOM).Length) > 0.00001:
                    isSame = False

                if isSame is True:
                    segEI.append(ei)
                else:
                    # Move co-radial arc segments
                    stpOvrEI.append(["A", segEI, False])
                    # Start new list of arc segments
                    segEI = [ei]
                    isSame = True
                    pnt = FreeCAD.Vector(edg.Vertexes[0].X, edg.Vertexes[0].Y, 0.0)
                    sameRad = pnt.sub(self.tmpCOM).Length
    # Process trailing `segEI` data, if available
    if isSame is True:
        stpOvrEI.append(["A", segEI, False])

    # Identify adjacent arcs with y=0 start/end points that connect
    for so in range(0, len(stpOvrEI)):
        SO = stpOvrEI[so]
        if SO[0] == "A":
            startOnAxis = []
            endOnAxis = []
            EI = SO[1]  # list of corresponding compGeoShp.Edges indexes

            # Identify startOnAxis and endOnAxis arcs
            for i in range(0, len(EI)):
                ei = EI[i]  # edge index
                E = compGeoShp.Edges[ei]  # edge object
                if abs(self.tmpCOM.y - E.Vertexes[0].Y) < 0.00001:
                    startOnAxis.append((i, ei, E.Vertexes[0]))
                elif abs(self.tmpCOM.y - E.Vertexes[1].Y) < 0.00001:
                    endOnAxis.append((i, ei, E.Vertexes[1]))

            # Look for connections between startOnAxis and endOnAxis arcs. Consolidate data when connected
            lenSOA = len(startOnAxis)
            lenEOA = len(endOnAxis)
            if lenSOA > 0 and lenEOA > 0:
                for soa in range(0, lenSOA):
                    (iS, eiS, vS) = startOnAxis[soa]
                    for eoa in range(0, len(endOnAxis)):
                        (iE, eiE, vE) = endOnAxis[eoa]
                        dist = vE.X - vS.X
                        if abs(dist) < 0.00001:  # They connect on axis at same radius
                            SO[2] = (eiE, eiS)
                            break
                        elif dist > 0:
                            break  # stop searching
            # Eif
        # Eif
    # Efor

    # Construct arc data tuples for OCL
    dirFlg = 1
    if not self.CutClimb:  # True yields Climb when set to Conventional
        dirFlg = -1

    # Declare center point of circle pattern
    cp = (self.tmpCOM.x, self.tmpCOM.y, 0.0)

    # Cycle through stepOver data
    for so in range(0, len(stpOvrEI)):
        SO = stpOvrEI[so]
        if SO[0] == "L":  # L = Loop/Ring/Circle
            # Path.Log.debug("SO[0] == 'Loop'")
            lei = SO[1]  # loop Edges index
            v1 = compGeoShp.Edges[lei].Vertexes[0]

            # space = obj.SampleInterval.Value / 10.0
            # space = 0.000001
            space = self.toolDiam * 0.005  # If too small, OCL will fail to scan the loop

            # p1 = FreeCAD.Vector(v1.X, v1.Y, v1.Z)
            p1 = FreeCAD.Vector(v1.X, v1.Y, 0.0)  # z=0.0 for waterline; z=v1.Z for 3D Surface
            rad = p1.sub(self.tmpCOM).Length
            spcRadRatio = space / rad
            if spcRadRatio < 1.0:
                tolrncAng = math.asin(spcRadRatio)
            else:
                tolrncAng = 0.99999998 * math.pi
            EX = self.tmpCOM.x + (rad * math.cos(tolrncAng))
            EY = v1.Y - space  # rad * math.sin(tolrncAng)

            sp = (v1.X, v1.Y, 0.0)
            ep = (EX, EY, 0.0)
            if dirFlg == 1:
                arc = (sp, ep, cp)
            else:
                arc = (
                    ep,
                    sp,
                    cp,
                )  # OCL.Arc(firstPnt, lastPnt, centerPnt, dir=True(CCW direction))
            ARCS.append(("L", dirFlg, [arc]))
        elif SO[0] == "A":  # A = Arc
            # Path.Log.debug("SO[0] == 'Arc'")
            PRTS = []
            EI = SO[1]  # list of corresponding Edges indexes
            CONN = SO[2]  # list of corresponding connected edges tuples (iE, iS)
            chkGap = False
            lst = None

            if CONN:  # Connected edges(arcs)
                (iE, iS) = CONN
                v1 = compGeoShp.Edges[iE].Vertexes[0]
                v2 = compGeoShp.Edges[iS].Vertexes[1]
                sp = (v1.X, v1.Y, 0.0)
                ep = (v2.X, v2.Y, 0.0)
                if dirFlg == 1:
                    arc = (sp, ep, cp)
                    lst = ep
                else:
                    arc = (
                        ep,
                        sp,
                        cp,
                    )  # OCL.Arc(firstPnt, lastPnt, centerPnt, dir=True(CCW direction))
                    lst = sp
                PRTS.append(arc)
                # Pop connected edge index values from arc segments index list
                iEi = EI.index(iE)
                iSi = EI.index(iS)
                if iEi > iSi:
                    EI.pop(iEi)
                    EI.pop(iSi)
                else:
                    EI.pop(iSi)
                    EI.pop(iEi)
                if len(EI) > 0:
                    PRTS.append("BRK")
                    chkGap = True
            cnt = 0
            for ei in EI:
                if cnt > 0:
                    PRTS.append("BRK")
                    chkGap = True
                v1 = compGeoShp.Edges[ei].Vertexes[0]
                v2 = compGeoShp.Edges[ei].Vertexes[1]
                sp = (v1.X, v1.Y, 0.0)
                ep = (v2.X, v2.Y, 0.0)
                if dirFlg == 1:
                    arc = (sp, ep, cp)
                    if chkGap:
                        gap = abs(
                            self.toolDiam - gapDist(lst, sp)
                        )  # abs(self.toolDiam - lst.sub(sp).Length)
                    lst = ep
                else:
                    arc = (
                        ep,
                        sp,
                        cp,
                    )  # OCL.Arc(firstPnt, lastPnt, centerPnt, dir=True(CCW direction))
                    if chkGap:
                        gap = abs(
                            self.toolDiam - gapDist(lst, ep)
                        )  # abs(self.toolDiam - lst.sub(ep).Length)
                    lst = sp
                if chkGap:
                    if gap < obj.GapThreshold.Value:
                        PRTS.pop()  # pop off 'BRK' marker
                        (
                            vA,
                            vB,
                            vC,
                        ) = PRTS.pop()  # pop off previous arc segment for combining with current
                        arc = (vA, arc[1], vC)
                        self.closedGap = True
                    else:
                        gap = round(gap, 6)
                        if gap < self.gaps[0]:
                            self.gaps.insert(0, gap)
                            self.gaps.pop()
                PRTS.append(arc)
                cnt += 1

            if dirFlg == -1:
                PRTS.reverse()

            ARCS.append(("A", dirFlg, PRTS))
        # Eif
        if obj.CutPattern == "CircularZigZag":
            dirFlg = -1 * dirFlg
    # Efor

    ARCS.sort(key=dist_to_cent, reverse=obj.CutPatternReversed)

    return ARCS


def pathGeomToSpiralPointSet(obj, compGeoShp):
    """_pathGeomToSpiralPointSet(obj, compGeoShp)...
    Convert a compound set of sequential line segments to directional, connected groupings."""
    Path.Log.debug("_pathGeomToSpiralPointSet()")
    # Extract intersection line segments for return value as []
    LINES = []
    inLine = []
    lnCnt = 0
    ec = len(compGeoShp.Edges)
    start = 2

    if obj.CutPatternReversed:
        edg1 = compGeoShp.Edges[
            0
        ]  # Skip first edge, as it is the closing edge: center to outer tail
        ec -= 1
        start = 1
    else:
        edg1 = compGeoShp.Edges[
            1
        ]  # Skip first edge, as it is the closing edge: center to outer tail
    p1 = FreeCAD.Vector(edg1.Vertexes[0].X, edg1.Vertexes[0].Y, 0.0)
    p2 = FreeCAD.Vector(edg1.Vertexes[1].X, edg1.Vertexes[1].Y, 0.0)
    tup = ((p1.x, p1.y), (p2.x, p2.y))
    inLine.append(tup)

    for ei in range(start, ec):  # Skipped first edge, started with second edge above as edg1
        edg = compGeoShp.Edges[ei]  # Get edge for vertexes
        sp = FreeCAD.Vector(
            edg.Vertexes[0].X, edg.Vertexes[0].Y, 0.0
        )  # check point (first / middle point)
        ep = FreeCAD.Vector(edg.Vertexes[1].X, edg.Vertexes[1].Y, 0.0)  # end point
        tup = ((sp.x, sp.y), (ep.x, ep.y))

        if sp.sub(p2).Length < 0.000001:
            inLine.append(tup)
        else:
            LINES.append(inLine)  # Save inLine segments
            lnCnt += 1
            inLine = []  # reset container
            inLine.append(tup)
        # p1 = sp
        p2 = ep
    # Efor

    lnCnt += 1
    LINES.append(inLine)  # Save inLine segments

    return LINES


def pathGeomToOffsetPointSet(obj, compGeoShp):
    """pathGeomToOffsetPointSet(obj, compGeoShp)...
    Convert a compound set of 3D profile segmented wires to 2D segments, applying linear optimization.
    """
    Path.Log.debug("pathGeomToOffsetPointSet()")

    LINES = []
    optimize = obj.OptimizeLinearPaths
    ofstCnt = len(compGeoShp)

    # Cycle through offset loops
    iPOL = False
    for ei in range(0, ofstCnt):
        OS = compGeoShp[ei]
        lenOS = len(OS)

        if ei > 0:
            LINES.append("BRK")

        fp = FreeCAD.Vector(OS[0].x, OS[0].y, OS[0].z)
        OS.append(fp)

        # Cycle through points in each loop
        prev = OS[0]
        pnt = OS[1]
        for v in range(1, lenOS):
            nxt = OS[v + 1]
            if optimize:
                # iPOL = prev.isOnLineSegment(nxt, pnt)
                iPOL = pnt.isOnLineSegment(prev, nxt)
                if iPOL:
                    pnt = nxt
                else:
                    tup = ((prev.x, prev.y), (pnt.x, pnt.y))
                    LINES.append(tup)
                    prev = pnt
                    pnt = nxt
            else:
                tup = ((prev.x, prev.y), (pnt.x, pnt.y))
                LINES.append(tup)
                prev = pnt
                pnt = nxt
        if iPOL:
            tup = ((prev.x, prev.y), (pnt.x, pnt.y))
            LINES.append(tup)
    # Efor

    return [LINES]


class FindUnifiedRegions:
    """FindUnifiedRegions() This class requires a list of face shapes.
    It finds the unified horizontal unified regions, if they exist."""

    def __init__(self, facesList, geomToler):
        self.FACES = facesList  # format is tuple (faceShape, faceIndex_on_base)
        self.geomToler = geomToler
        self.tempGroup = None
        self.topFaces = []
        self.edgeData = []
        self.circleData = []
        self.noSharedEdges = True
        self.topWires = []
        self.REGIONS = []
        self.INTERNALS = []
        self.idGroups = []
        self.sharedEdgeIdxs = []
        self.fusedFaces = None
        self.internalsReady = False

        if self.geomToler == 0.0:
            self.geomToler = 0.00001

    # Internal processing methods
    def _showShape(self, shape, name):
        if self.tempGroup:
            S = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmp" + name)
            S.Shape = shape
            S.purgeTouched()
            self.tempGroup.addObject(S)

    def _extractTopFaces(self):
        for F, fcIdx in self.FACES:  # format is tuple (faceShape, faceIndex_on_base)
            cont = True
            fNum = fcIdx + 1
            # Extrude face
            fBB = F.BoundBox
            extFwd = math.floor(2.0 * fBB.ZLength) + 10.0
            ef = F.extrude(FreeCAD.Vector(0.0, 0.0, extFwd))
            ef = Part.makeSolid(ef)

            # Cut top off of extrusion with Part.box
            efBB = ef.BoundBox
            ZLen = efBB.ZLength / 2.0
            cutBox = Part.makeBox(efBB.XLength + 2.0, efBB.YLength + 2.0, ZLen)
            zHght = efBB.ZMin + ZLen
            cutBox.translate(FreeCAD.Vector(efBB.XMin - 1.0, efBB.YMin - 1.0, zHght))
            base = ef.cut(cutBox)

            if base.Volume == 0:
                Path.Log.debug(
                    "Ignoring Face{}.  It is likely vertical with no horizontal exposure.".format(
                        fcIdx
                    )
                )
                cont = False

            if cont:
                # Identify top face of base
                fIdx = 0
                zMin = base.Faces[fIdx].BoundBox.ZMin
                for bfi in range(0, len(base.Faces)):
                    fzmin = base.Faces[bfi].BoundBox.ZMin
                    if fzmin > zMin:
                        fIdx = bfi
                        zMin = fzmin

                # Translate top face to Z=0.0 and save to topFaces list
                topFace = base.Faces[fIdx]
                # self._showShape(topFace, 'topFace_{}'.format(fNum))
                tfBB = topFace.BoundBox
                tfBB_Area = tfBB.XLength * tfBB.YLength
                fBB_Area = fBB.XLength * fBB.YLength
                if tfBB_Area < (fBB_Area * 0.9):
                    # attempt alternate methods
                    topFace = self._getCompleteCrossSection(ef)
                    tfBB = topFace.BoundBox
                    tfBB_Area = tfBB.XLength * tfBB.YLength
                    # self._showShape(topFace, 'topFaceAlt_1_{}'.format(fNum))
                    if tfBB_Area < (fBB_Area * 0.9):
                        topFace = getShapeSlice(ef)
                        tfBB = topFace.BoundBox
                        tfBB_Area = tfBB.XLength * tfBB.YLength
                        # self._showShape(topFace, 'topFaceAlt_2_{}'.format(fNum))
                        if tfBB_Area < (fBB_Area * 0.9):
                            msg = "Failed to extract processing region for Face {}\n".format(fNum)
                            FreeCAD.Console.PrintError(msg)
                            cont = False
            # Eif

            if cont:
                topFace.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - zMin))
                self.topFaces.append((topFace, fcIdx))

    def _fuseTopFaces(self):
        (one, baseFcIdx) = self.topFaces.pop(0)
        base = one
        for face, fcIdx in self.topFaces:
            base = base.fuse(face)
        self.topFaces.insert(0, (one, baseFcIdx))
        self.fusedFaces = base

    def _getEdgesData(self):
        topFaces = self.fusedFaces.Faces
        tfLen = len(topFaces)
        count = [0, 0]

        # Get length and center of mass for each edge in all top faces
        for fi in range(0, tfLen):
            F = topFaces[fi]
            edgCnt = len(F.Edges)
            for ei in range(0, edgCnt):
                E = F.Edges[ei]
                tup = (E.Length, E.CenterOfMass, E, fi)
                if len(E.Vertexes) == 1:
                    self.circleData.append(tup)
                    count[0] += 1
                else:
                    self.edgeData.append(tup)
                    count[1] += 1

    def _groupEdgesByLength(self):
        Path.Log.debug("_groupEdgesByLength()")
        threshold = self.geomToler
        grp = []
        processLast = False

        def keyFirst(tup):
            return tup[0]

        # Sort edgeData data and prepare proxy indexes
        self.edgeData.sort(key=keyFirst)
        DATA = self.edgeData
        lenDATA = len(DATA)
        indexes = [i for i in range(0, lenDATA)]
        idxCnt = len(indexes)

        while idxCnt > 0:
            processLast = True
            # Pop off index for first edge
            actvIdx = indexes.pop(0)
            actvItem = DATA[actvIdx][0]  # 0 index is length
            grp.append(actvIdx)
            idxCnt -= 1

            while idxCnt > 0:
                tstIdx = indexes[0]
                tstItem = DATA[tstIdx][0]

                # test case(s) goes here
                absLenDiff = abs(tstItem - actvItem)
                if absLenDiff < threshold:
                    # Remove test index from indexes
                    indexes.pop(0)
                    idxCnt -= 1
                    grp.append(tstIdx)
                else:
                    if len(grp) > 1:
                        # grp.sort()
                        self.idGroups.append(grp)
                    grp = []
                    break
            # Ewhile
        # Ewhile
        if processLast:
            if len(grp) > 1:
                # grp.sort()
                self.idGroups.append(grp)

    def _identifySharedEdgesByLength(self, grp):
        Path.Log.debug("_identifySharedEdgesByLength()")
        holds = []
        specialIndexes = []
        threshold = self.geomToler

        def keyFirst(tup):
            return tup[0]

        # Sort edgeData data
        self.edgeData.sort(key=keyFirst)
        DATA = self.edgeData
        lenGrp = len(grp)

        while lenGrp > 0:
            # Pop off index for first edge
            actvIdx = grp.pop(0)
            actvItem = DATA[actvIdx][0]  # 0 index is length
            lenGrp -= 1
            while lenGrp > 0:
                isTrue = False
                # Pop off index for test edge
                tstIdx = grp.pop(0)
                tstItem = DATA[tstIdx][0]
                lenGrp -= 1

                # test case(s) goes here
                lenDiff = tstItem - actvItem
                absLenDiff = abs(lenDiff)
                if lenDiff > threshold:
                    break
                if absLenDiff < threshold:
                    com1 = DATA[actvIdx][1]
                    com2 = DATA[tstIdx][1]
                    comDiff = com2.sub(com1).Length
                    if comDiff < threshold:
                        isTrue = True

                # Action if test is true (finds special case)
                if isTrue:
                    specialIndexes.append(actvIdx)
                    specialIndexes.append(tstIdx)
                    break
                else:
                    holds.append(tstIdx)

            # Put hold indexes back in search group
            holds.extend(grp)
            grp = holds
            lenGrp = len(grp)
            holds = []

        if len(specialIndexes) > 0:
            # Remove shared edges from EDGES data
            uniqueShared = list(set(specialIndexes))
            self.sharedEdgeIdxs.extend(uniqueShared)
            self.noSharedEdges = False

    def _extractWiresFromEdges(self):
        Path.Log.debug("_extractWiresFromEdges()")
        DATA = self.edgeData
        holds = []
        firstEdge = None
        cont = True
        connectedEdges = []
        connectedIndexes = []
        connectedCnt = 0
        LOOPS = []

        def faceIndex(tup):
            return tup[3]

        def faceArea(face):
            return face.Area

        # Sort by face index on original model base
        DATA.sort(key=faceIndex)
        lenDATA = len(DATA)
        indexes = [i for i in range(0, lenDATA)]
        idxCnt = len(indexes)

        # Add circle edges into REGIONS list
        if len(self.circleData) > 0:
            for C in self.circleData:
                face = Part.Face(Part.Wire(C[2]))
                self.REGIONS.append(face)

        actvIdx = indexes.pop(0)
        actvEdge = DATA[actvIdx][2]
        firstEdge = actvEdge  # DATA[connectedIndexes[0]][2]
        idxCnt -= 1
        connectedIndexes.append(actvIdx)
        connectedEdges.append(actvEdge)
        connectedCnt = 1

        safety = 750
        while cont:  # safety > 0
            safety -= 1
            notConnected = True
            while idxCnt > 0:
                isTrue = False
                # Pop off index for test edge
                tstIdx = indexes.pop(0)
                tstEdge = DATA[tstIdx][2]
                idxCnt -= 1
                if self._edgesAreConnected(actvEdge, tstEdge):
                    isTrue = True

                if isTrue:
                    notConnected = False
                    connectedIndexes.append(tstIdx)
                    connectedEdges.append(tstEdge)
                    connectedCnt += 1
                    actvIdx = tstIdx
                    actvEdge = tstEdge
                    break
                else:
                    holds.append(tstIdx)
            # Ewhile

            if connectedCnt > 2:
                if self._edgesAreConnected(actvEdge, firstEdge):
                    notConnected = False
                    # Save loop components
                    LOOPS.append(connectedEdges)
                    # reset connected variables and re-assess
                    connectedEdges = []
                    connectedIndexes = []
                    connectedCnt = 0
                    indexes.sort()
                    idxCnt = len(indexes)
                    if idxCnt > 0:
                        # Pop off index for first edge
                        actvIdx = indexes.pop(0)
                        actvEdge = DATA[actvIdx][2]
                        idxCnt -= 1
                        firstEdge = actvEdge
                        connectedIndexes.append(actvIdx)
                        connectedEdges.append(actvEdge)
                        connectedCnt = 1
            # Eif

            # Put holds indexes back in search stack
            if notConnected:
                holds.append(actvIdx)
            holds.extend(indexes)
            indexes = holds
            idxCnt = len(indexes)
            holds = []
            if idxCnt == 0:
                cont = False
            if safety == 0:
                cont = False
        # Ewhile

        numLoops = len(LOOPS)
        Path.Log.debug(" -numLoops: {}.".format(numLoops))
        if numLoops > 0:
            for li in range(0, numLoops):
                Edges = LOOPS[li]
                # for e in Edges:
                #    self._showShape(e, 'Loop_{}_Edge'.format(li))
                wire = Part.Wire(Part.__sortEdges__(Edges))
                if wire.isClosed():
                    # This simple Part.Face() method fails to catch
                    # wires with tangent closed wires, or an external
                    # wire with one or more internal tangent wires.
                    # face = Part.Face(wire)

                    # This method works with the complex tangent
                    # closed wires mentioned above.
                    extWire = wire.extrude(FreeCAD.Vector(0.0, 0.0, 2.0))
                    wireSolid = Part.makeSolid(extWire)
                    extdBBFace1 = makeExtendedBoundBox(
                        wireSolid.BoundBox, 5.0, wireSolid.BoundBox.ZMin + 1.0
                    )
                    extdBBFace2 = makeExtendedBoundBox(
                        wireSolid.BoundBox, 5.0, wireSolid.BoundBox.ZMin + 1.0
                    )
                    inverse = extdBBFace1.cut(wireSolid)
                    face = extdBBFace2.cut(inverse)
                    self.REGIONS.append(face)
            self.REGIONS.sort(key=faceArea, reverse=True)

    def _identifyInternalFeatures(self):
        Path.Log.debug("_identifyInternalFeatures()")
        remList = []

        for top, fcIdx in self.topFaces:
            big = Part.Face(top.OuterWire)
            for s in range(0, len(self.REGIONS)):
                if s not in remList:
                    small = self.REGIONS[s]
                    if self._isInBoundBox(big, small):
                        cmn = big.common(small)
                        if cmn.Area > 0.0:
                            self.INTERNALS.append(small)
                            remList.append(s)
                            break
                        else:
                            Path.Log.debug(" - No common area.\n")

        remList.sort(reverse=True)
        for ri in remList:
            self.REGIONS.pop(ri)

    def _processNestedRegions(self):
        Path.Log.debug("_processNestedRegions()")
        cont = True
        hold = []
        Ids = []
        remList = []
        for i in range(0, len(self.REGIONS)):
            Ids.append(i)
        idsCnt = len(Ids)

        while cont:
            while idsCnt > 0:
                hi = Ids.pop(0)
                high = self.REGIONS[hi]
                idsCnt -= 1
                while idsCnt > 0:
                    isTrue = False
                    li = Ids.pop(0)
                    idsCnt -= 1
                    low = self.REGIONS[li]
                    # Test case here
                    if self._isInBoundBox(high, low):
                        cmn = high.common(low)
                        if cmn.Area > 0.0:
                            isTrue = True
                    # if True action here
                    if isTrue:
                        self.REGIONS[hi] = high.cut(low)
                        remList.append(li)
                    else:
                        hold.append(hi)
                # Ewhile
                hold.extend(Ids)
                Ids = hold
                hold = []
                idsCnt = len(Ids)
                if len(Ids) == 0:
                    cont = False
            # Ewhile
        # Ewhile
        remList.sort(reverse=True)
        for ri in remList:
            self.REGIONS.pop(ri)

    # Accessory methods
    def _getCompleteCrossSection(self, shape):
        Path.Log.debug("_getCompleteCrossSection()")
        wires = []
        bb = shape.BoundBox
        mid = (bb.ZMin + bb.ZMax) / 2.0

        for i in shape.slice(FreeCAD.Vector(0, 0, 1), mid):
            wires.append(i)

        if len(wires) > 0:
            comp = Part.Compound(wires)  # produces correct cross-section wire !
            CS = Part.Face(comp.Wires[0])
            CS.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - CS.BoundBox.ZMin))
            return CS

        Path.Log.debug(" -No wires from .slice() method")
        return False

    def _edgesAreConnected(self, e1, e2):
        # Assumes edges are flat and are at Z=0.0

        def isSameVertex(v1, v2):
            # Assumes vertexes at Z=0.0
            if abs(v1.X - v2.X) < 0.000001:
                if abs(v1.Y - v2.Y) < 0.000001:
                    return True
            return False

        if isSameVertex(e1.Vertexes[0], e2.Vertexes[0]):
            return True
        if isSameVertex(e1.Vertexes[0], e2.Vertexes[1]):
            return True
        if isSameVertex(e1.Vertexes[1], e2.Vertexes[0]):
            return True
        if isSameVertex(e1.Vertexes[1], e2.Vertexes[1]):
            return True

        return False

    def _isInBoundBox(self, outShp, inShp):
        obb = outShp.BoundBox
        ibb = inShp.BoundBox

        if obb.XMin < ibb.XMin:
            if obb.XMax > ibb.XMax:
                if obb.YMin < ibb.YMin:
                    if obb.YMax > ibb.YMax:
                        return True
        return False

    # Public methods
    def setTempGroup(self, grpObj):
        """setTempGroup(grpObj)... For debugging, pass temporary object group."""
        self.tempGroup = grpObj

    def getUnifiedRegions(self):
        """getUnifiedRegions()... Returns a list of unified regions from list
        of tuples (faceShape, faceIndex) received at instantiation of the class object."""
        Path.Log.debug("getUnifiedRegions()")
        if len(self.FACES) == 0:
            msg = "No FACE data tuples received at instantiation of class.\n"
            FreeCAD.Console.PrintError(msg)
            return []

        self._extractTopFaces()
        lenFaces = len(self.topFaces)
        if lenFaces == 0:
            return []

        # if single topFace, return it
        if lenFaces == 1:
            topFace = self.topFaces[0][0]
            self._showShape(topFace, "TopFace")
            # prepare inner wires as faces for internal features
            lenWrs = len(topFace.Wires)
            if lenWrs > 1:
                for w in range(1, lenWrs):
                    wr = topFace.Wires[w]
                    self.INTERNALS.append(Part.Face(wr))
            self.internalsReady = True
            # Flatten face and extract outer wire, then convert to face
            extWire = getExtrudedShape(topFace)
            wCS = getCrossSection(extWire)
            if wCS:
                face = Part.Face(wCS)
                return [face]
            else:
                (faceShp, fcIdx) = self.FACES[0]
                msg = translate(
                    "PathSurfaceSupport",
                    "Failed to identify a horizontal cross-section for Face",
                )
                msg += "{}.\n".format(fcIdx + 1)
                FreeCAD.Console.PrintWarning(msg)
                return []

        # process multiple top faces, unifying if possible
        self._fuseTopFaces()
        for F in self.fusedFaces.Faces:
            self._showShape(F, "TopFaceFused")

        self._getEdgesData()
        self._groupEdgesByLength()
        for grp in self.idGroups:
            self._identifySharedEdgesByLength(grp)

        if self.noSharedEdges:
            Path.Log.debug("No shared edges by length detected.")
            allTopFaces = []
            for topFace, fcIdx in self.topFaces:
                allTopFaces.append(topFace)
                # Identify internal features
                lenWrs = len(topFace.Wires)
                if lenWrs > 1:
                    for w in range(1, lenWrs):
                        wr = topFace.Wires[w]
                        self.INTERNALS.append(Part.Face(wr))
            self.internalsReady = True
            return allTopFaces
        else:
            # Delete shared edges from edgeData list
            self.sharedEdgeIdxs.sort(reverse=True)
            for se in self.sharedEdgeIdxs:
                self.edgeData.pop(se)

        self._extractWiresFromEdges()
        self._identifyInternalFeatures()
        self._processNestedRegions()
        # for ri in range(0, len(self.REGIONS)):
        #    self._showShape(self.REGIONS[ri], 'UnifiedRegion_{}'.format(ri))

        self.internalsReady = True
        return self.REGIONS

    def getInternalFeatures(self):
        """getInternalFeatures()... Returns internal features identified
        after calling getUnifiedRegions()."""
        if self.internalsReady:
            if len(self.INTERNALS) > 0:
                return self.INTERNALS
            else:
                return False

        msg = "getUnifiedRegions() must be called before getInternalFeatures().\n"
        FreeCAD.Console.PrintError(msg)
        return False


class OCL_Tool:
    """The OCL_Tool class is designed to translate a FreeCAD standard ToolBit shape
    in the active Tool Controller, into an OCL tool type."""

    def __init__(self, ocl, obj, safe=False):
        self.ocl = ocl
        self.obj = obj
        self.tool = None
        self.tiltCutter = False
        self.safe = safe
        self.oclTool = None
        self.toolType = None
        self.toolMode = None
        self.toolMethod = None

        self.diameter = -1.0
        self.cornerRadius = -1.0
        self.flatRadius = -1.0
        self.cutEdgeHeight = -1.0
        self.cutEdgeAngle = -1.0
        # Default to zero. ToolBit likely is without.
        self.lengthOffset = 0.0

        if hasattr(obj, "ToolController"):
            if hasattr(obj.ToolController, "Tool"):
                self.tool = obj.ToolController.Tool
                if hasattr(self.tool, "ShapeType"):
                    self.toolType = self.tool.ShapeType.lower()
                    self.toolMode = "ToolBit"
                elif hasattr(self.tool, "ShapeName"):  # backward compatibility
                    self.toolType = self.tool.ShapeName  # Indicates ToolBit tool
                    self.toolMode = "ToolBit"
        if self.toolType:
            Path.Log.debug("OCL_Tool tool mode, type: {}, {}".format(self.toolMode, self.toolType))

    """
        #### FreeCAD Legacy tool shape properties per tool type
        shape = EndMill
        Diameter
        CuttingEdgeHeight
        LengthOffset

        shape = Drill
        Diameter
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = CenterDrill
        Diameter
        FlatRadius
        CornerRadius
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = CounterSink
        Diameter
        FlatRadius
        CornerRadius
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = CounterBore
        Diameter
        FlatRadius
        CornerRadius
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = FlyCutter
        Diameter
        FlatRadius
        CornerRadius
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = Reamer
        Diameter
        FlatRadius
        CornerRadius
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = Tap
        Diameter
        FlatRadius
        CornerRadius
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = SlotCutter
        Diameter
        FlatRadius
        CornerRadius
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = BallEndMill
        Diameter
        FlatRadius
        CornerRadius
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = ChamferMill
        Diameter
        FlatRadius
        CornerRadius
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = CornerRound
        Diameter
        FlatRadius
        CornerRadius
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset

        shape = Engraver
        Diameter
        CuttingEdgeAngle  # TipAngle from above, center shaft. 180 = flat tip (endmill)
        CuttingEdgeHeight
        LengthOffset


        #### FreeCAD packaged ToolBit named constraints per shape files
        shape = endmill
        Diameter; Endmill diameter
        Length; Overall length of the endmill
        ShankDiameter; diameter of the shank
        CuttingEdgeHeight

        shape = ballend
        Diameter; Endmill diameter
        Length; Overall length of the endmill
        ShankDiameter; diameter of the shank
        CuttingEdgeHeight

        shape = bullnose
        Diameter; Endmill diameter
        Length; Overall length of the endmill
        ShankDiameter; diameter of the shank
        FlatRadius;Radius of the bottom flat part.
        CuttingEdgeHeight

        shape = drill
        TipAngle; Full angle of the drill tip
        Diameter; Drill bit diameter
        Length; Overall length of the drillbit

        shape = v-bit
        Diameter; Overall diameter of the V-bit
        CuttingEdgeAngle;Full angle of the v-bit
        Length; Overall  bit length
        ShankDiameter
        FlatHeight;Height of the flat extension of the v-bit
        FlatRadius; Diameter of the flat end of the tip
    """

    # Private methods
    def _setDimensions(self):
        """_setDimensions() ... Set values for possible dimensions."""
        if hasattr(self.tool, "Diameter"):
            self.diameter = float(self.tool.Diameter)
        else:
            msg = translate("PathSurfaceSupport", "Diameter dimension missing from ToolBit shape.")
            FreeCAD.Console.PrintError(msg + "\n")
            return False
        if hasattr(self.tool, "LengthOffset"):
            self.lengthOffset = float(self.tool.LengthOffset)
        # Derive flatRadius from diameter and cornerRadius if both are present
        if hasattr(self.tool, "FlatRadius"):
            self.flatRadius = float(self.tool.FlatRadius)
        if hasattr(self.tool, "CornerRadius") and hasattr(self.tool, "Diameter"):
            self.cornerRadius = float(self.tool.CornerRadius)
            self.flatRadius = (self.diameter / 2.0) - self.cornerRadius
        if hasattr(self.tool, "CuttingEdgeHeight"):
            self.cutEdgeHeight = float(self.tool.CuttingEdgeHeight)
        if hasattr(self.tool, "CuttingEdgeAngle"):
            self.cutEdgeAngle = float(self.tool.CuttingEdgeAngle)
        return True

    def _makeSafeCutter(self):
        # Make safeCutter with 25% buffer around physical cutter
        if self.safe:
            self.diameter = self.diameter * 1.25
            if self.flatRadius == 0.0:
                self.flatRadius = self.diameter * 0.25
            elif self.flatRadius > 0.0:
                self.flatRadius = self.flatRadius * 1.25

    def _oclCylCutter(self):
        # Standard End Mill, Slot cutter, or Fly cutter
        # OCL -> CylCutter::CylCutter(diameter, length)
        if self.diameter == -1.0 or self.cutEdgeHeight == -1.0:
            return
        self.oclTool = self.ocl.CylCutter(self.diameter, self.cutEdgeHeight + self.lengthOffset)

    def _oclBallCutter(self):
        # Standard Ball End Mill
        # OCL -> BallCutter::BallCutter(diameter, length)
        if self.diameter == -1.0 or self.cutEdgeHeight == -1.0:
            return
        self.tiltCutter = True
        if self.cutEdgeHeight == 0:
            self.cutEdgeHeight = self.diameter / 2
        self.oclTool = self.ocl.BallCutter(self.diameter, self.cutEdgeHeight + self.lengthOffset)

    def _oclBullCutter(self):
        # Standard Bull Nose cutter
        # Reference: https://www.fine-tools.com/halbstabfraeser.html
        # OCL -> BullCutter::BullCutter(diameter, minor radius, length)
        if self.diameter == -1.0 or self.flatRadius == -1.0 or self.cutEdgeHeight == -1.0:
            return
        self.oclTool = self.ocl.BullCutter(
            self.diameter,
            self.diameter / 2 - self.flatRadius,
            self.cutEdgeHeight + self.lengthOffset,
        )

    def _oclConeCutter(self):
        # Engraver or V-bit cutter
        # OCL -> ConeCutter::ConeCutter(diameter, angle, length)
        if self.diameter == -1.0 or self.cutEdgeAngle == -1.0 or self.cutEdgeHeight == -1.0:
            return
        self.oclTool = self.ocl.ConeCutter(self.diameter, self.cutEdgeAngle / 2, self.lengthOffset)

    def _setToolMethod(self):
        toolMap = dict()

        if self.toolMode == "ToolBit":
            toolMap = {
                "endmill": "CylCutter",
                "ballend": "BallCutter",
                "bullnose": "BullCutter",
                "taperedballnose": "BallCutter",
                "drill": "ConeCutter",
                "engraver": "ConeCutter",
                "v_bit": "ConeCutter",
                "v-bit": "ConeCutter",
                "vbit": "ConeCutter",
                "chamfer": "None",
            }
        self.toolMethod = "None"
        if self.toolType in toolMap:
            self.toolMethod = toolMap[self.toolType]

    # Public methods
    def getOclTool(self):
        """getOclTool()... Call this method after class instantiation
        to return OCL tool object."""
        # Check for tool controller and tool object
        if not self.tool or not self.toolMode:
            msg = translate("PathSurface", "Failed to identify tool for operation.")
            FreeCAD.Console.PrintError(msg + "\n")
            return False

        if not self._setDimensions():
            return False

        self._setToolMethod()

        if self.toolMethod == "None":
            err = translate("PathSurface", "Failed to map selected tool to an OCL tool type.")
            FreeCAD.Console.PrintError(err + "\n")
            return False
        else:
            Path.Log.debug("OCL_Tool tool method: {}".format(self.toolMethod))
            oclToolMethod = getattr(self, "_ocl" + self.toolMethod)
            oclToolMethod()

        if self.oclTool:
            return self.oclTool

        # Set error messages
        err = translate("PathSurface", "Failed to translate active tool to OCL tool type.")
        FreeCAD.Console.PrintError(err + "\n")
        return False

    def useTiltCutter(self):
        """useTiltCutter()... Call this method after getOclTool() method
        to return status of cutter tilt availability - generally this
        is for a ball end mill."""
        if not self.tool or not self.oclTool:
            err = translate(
                "PathSurface",
                "OCL tool not available. Cannot determine is cutter has tilt available.",
            )
            FreeCAD.Console.PrintError(err + "\n")
            return False
        return self.tiltCutter


# Eclass


# Support functions
def makeExtendedBoundBox(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]))
