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

# ***************************************************************************
# *   Copyright (c) 2018 Kresimir Tusek <kresimir.tusek@gmail.com>          *
# *   Copyright (c) 2019-2021 Schildkroet                                   *
# *                                                                         *
# *   This file is part of the FreeCAD CAx development system.              *
# *                                                                         *
# *   This library is free software; you can redistribute it and/or         *
# *   modify it under the terms of the GNU Library General Public           *
# *   License as published by the Free Software Foundation; either          *
# *   version 2 of the License, or (at your option) any later version.      *
# *                                                                         *
# *   This library  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 library; see the file COPYING.LIB. If not,    *
# *   write to the Free Software Foundation, Inc., 59 Temple Place,         *
# *   Suite 330, Boston, MA  02111-1307, USA                                *
# *                                                                         *
# ***************************************************************************

# NOTE: "isNull() note"
# After performing cut operations, checking the resulting shape.isNull() will
# sometimes return False even when the resulting shape is infinitesimal and
# further operations with it will raise exceptions. Instead checking if the
# shape.Wires list is non-empty bypasses this issue.

import Path
import Path.Op.Base as PathOp
import PathScripts.PathUtils as PathUtils
import FreeCAD
import time
import json
import math
import area
from PySide.QtCore import QT_TRANSLATE_NOOP

if FreeCAD.GuiUp:
    from pivy import coin
    import FreeCADGui

__doc__ = "Class and implementation of the Adaptive CAM operation."

# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader

Part = LazyLoader("Part", globals(), "Part")
TechDraw = LazyLoader("TechDraw", globals(), "TechDraw")
FeatureExtensions = LazyLoader("Path.Op.FeatureExtension", globals(), "Path.Op.FeatureExtension")
DraftGeomUtils = LazyLoader("DraftGeomUtils", globals(), "DraftGeomUtils")


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


def convertTo2d(pathArray):
    output = []
    for path in pathArray:
        pth2 = []
        for edge in path:
            for pt in edge:
                pth2.append([pt[0], pt[1]])
        output.append(pth2)
    return output


sceneGraph = None
scenePathNodes = []  # for scene cleanup afterwards
topZ = 10

# Constants to avoid magic numbers in the code
_ADAPTIVE_MIN_STEPDOWN = 0.1


def sceneDrawPath(path, color=(0, 0, 1)):
    coPoint = coin.SoCoordinate3()

    pts = []
    for pt in path:
        pts.append([pt[0], pt[1], topZ])

    coPoint.point.setValues(0, len(pts), pts)
    ma = coin.SoBaseColor()
    ma.rgb = color
    li = coin.SoLineSet()
    li.numVertices.setValue(len(pts))
    pathNode = coin.SoSeparator()
    pathNode.addChild(coPoint)
    pathNode.addChild(ma)
    pathNode.addChild(li)
    sceneGraph.addChild(pathNode)
    scenePathNodes.append(pathNode)  # for scene cleanup afterwards


def sceneClean():
    for n in scenePathNodes:
        sceneGraph.removeChild(n)

    del scenePathNodes[:]


def discretize(edge, flipDirection=False):
    pts = edge.discretize(Deflection=0.002)
    if flipDirection:
        pts.reverse()

    return pts


def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
    if not adaptiveResults or not adaptiveResults[0]["AdaptivePaths"]:
        return

    # minLiftDistance = op.tool.Diameter
    helixRadius = 0
    for region in adaptiveResults:
        p1 = region["HelixCenterPoint"]
        p2 = region["StartPoint"]
        helixRadius = max(math.dist(p1[:2], p2[:2]), helixRadius)

    stepDown = max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN)

    length = 2 * math.pi * helixRadius

    obj.HelixAngle = min(89.99, max(obj.HelixAngle.Value, 0.01))
    obj.HelixConeAngle = max(obj.HelixConeAngle, 0)

    helixAngleRad = math.radians(obj.HelixAngle)
    depthPerOneCircle = length * math.tan(helixAngleRad)

    stepUp = max(obj.LiftDistance.Value, 0)

    # TODO: finishStep is of limited utility with how regions are now broken
    # up based on the model geometry- the "finish" step gets applied to each
    # region separately, which results in excessive "finish" steps being taken
    # where they really need not be. Leaving stock in Z generally makes more
    # sense, but both technically have their uses, so leaving this here as
    # option. Implementing flat area detection would make better use of both.
    finishStep = min(obj.FinishDepth.Value, stepDown) if hasattr(obj, "FinishDepth") else 0.0

    # Track Z position to determine when changing height is necessary prior to a move
    lz = obj.StartDepth.Value

    for region in adaptiveResults:
        passStartDepth = region.get("TopDepth", obj.StartDepth.Value)

        depthParams = PathUtils.depth_params(
            clearance_height=obj.ClearanceHeight.Value,
            safe_height=obj.SafeHeight.Value,
            start_depth=passStartDepth,
            step_down=stepDown,
            z_finish_step=finishStep,
            final_depth=region.get("BottomDepth", obj.FinalDepth.Value),
            user_depths=None,
        )

        for passEndDepth in depthParams.data:
            pass_start_angle = math.atan2(
                region["StartPoint"][1] - region["HelixCenterPoint"][1],
                region["StartPoint"][0] - region["HelixCenterPoint"][0],
            )

            passDepth = passStartDepth - passEndDepth

            p1 = region["HelixCenterPoint"]
            p2 = region["StartPoint"]
            helixRadius = math.dist(p1[:2], p2[:2])

            # Helix ramp
            if helixRadius > 0.01:
                r = helixRadius - 0.01
                op.commandlist.append(Path.Command("(Helix to depth: %f)" % passEndDepth))

                if obj.UseHelixArcs is False:
                    helix_down_angle = passDepth / depthPerOneCircle * 2 * math.pi

                    r_bottom = r - (passStartDepth - passEndDepth) * math.tan(
                        math.radians(obj.HelixConeAngle.Value)
                    )
                    r_bottom = max(
                        r_bottom, r * 0.5
                    )  # put a limit on how small the cone tip can be
                    step_over = obj.StepOver * 0.01 * op.tool.Diameter.Value
                    spiral_out_angle = (r - r_bottom) / step_over * 2 * math.pi

                    helix_base_angle = pass_start_angle - helix_down_angle - spiral_out_angle
                    helix_angular_progress = 0

                    helixStart = [
                        region["HelixCenterPoint"][0] + r * math.cos(helix_base_angle),
                        region["HelixCenterPoint"][1] + r * math.sin(helix_base_angle),
                    ]

                    # rapid move to start point
                    op.commandlist.append(Path.Command("G0", {"Z": obj.ClearanceHeight.Value}))
                    op.commandlist.append(
                        Path.Command(
                            "G0",
                            {
                                "X": helixStart[0],
                                "Y": helixStart[1],
                                "Z": obj.ClearanceHeight.Value,
                            },
                        )
                    )

                    # rapid move to safe height
                    op.commandlist.append(
                        Path.Command(
                            "G0",
                            {
                                "X": helixStart[0],
                                "Y": helixStart[1],
                                "Z": obj.SafeHeight.Value,
                            },
                        )
                    )

                    # move to start depth
                    op.commandlist.append(
                        Path.Command(
                            "G1",
                            {
                                "X": helixStart[0],
                                "Y": helixStart[1],
                                "Z": passStartDepth,
                                "F": op.vertFeed,
                            },
                        )
                    )

                    # helix down
                    while helix_angular_progress < helix_down_angle:
                        progress = helix_angular_progress / helix_down_angle
                        r_current = r * (1 - progress) + r_bottom * progress
                        x = region["HelixCenterPoint"][0] + r_current * math.cos(
                            helix_angular_progress + helix_base_angle
                        )
                        y = region["HelixCenterPoint"][1] + r_current * math.sin(
                            helix_angular_progress + helix_base_angle
                        )
                        z = passStartDepth - progress * (passStartDepth - passEndDepth)
                        op.commandlist.append(
                            Path.Command("G1", {"X": x, "Y": y, "Z": z, "F": op.vertFeed})
                        )
                        helix_angular_progress = min(
                            helix_angular_progress + math.pi / 16, helix_down_angle
                        )

                    # spiral out, plus a full extra circle
                    max_angle = helix_down_angle + spiral_out_angle + 2 * math.pi
                    while helix_angular_progress < max_angle:
                        if spiral_out_angle == 0:
                            progress = 1
                        else:
                            progress = min(
                                1, (helix_angular_progress - helix_down_angle) / spiral_out_angle
                            )
                        r_current = r_bottom * (1 - progress) + r * progress
                        x = region["HelixCenterPoint"][0] + r_current * math.cos(
                            helix_angular_progress + helix_base_angle
                        )
                        y = region["HelixCenterPoint"][1] + r_current * math.sin(
                            helix_angular_progress + helix_base_angle
                        )
                        z = passEndDepth
                        op.commandlist.append(
                            Path.Command("G1", {"X": x, "Y": y, "Z": z, "F": op.horizFeed})
                        )
                        helix_angular_progress = min(
                            helix_angular_progress + math.pi / 16, max_angle
                        )
                else:
                    # Use arcs for helix - no conical shape support
                    helixStart = [
                        region["HelixCenterPoint"][0] + r,
                        region["HelixCenterPoint"][1],
                    ]

                    # rapid move to start point
                    op.commandlist.append(Path.Command("G0", {"Z": obj.ClearanceHeight.Value}))
                    op.commandlist.append(
                        Path.Command(
                            "G0",
                            {
                                "X": helixStart[0],
                                "Y": helixStart[1],
                                "Z": obj.ClearanceHeight.Value,
                            },
                        )
                    )

                    # rapid move to safe height
                    op.commandlist.append(
                        Path.Command(
                            "G0",
                            {
                                "X": helixStart[0],
                                "Y": helixStart[1],
                                "Z": obj.SafeHeight.Value,
                            },
                        )
                    )

                    # move to start depth
                    op.commandlist.append(
                        Path.Command(
                            "G1",
                            {
                                "X": helixStart[0],
                                "Y": helixStart[1],
                                "Z": passStartDepth,
                                "F": op.vertFeed,
                            },
                        )
                    )

                    x = region["HelixCenterPoint"][0] + r
                    y = region["HelixCenterPoint"][1]

                    curDep = passStartDepth
                    while curDep > (passEndDepth + depthPerOneCircle):
                        op.commandlist.append(
                            Path.Command(
                                "G2",
                                {
                                    "X": x - (2 * r),
                                    "Y": y,
                                    "Z": curDep - (depthPerOneCircle / 2),
                                    "I": -r,
                                    "F": op.vertFeed,
                                },
                            )
                        )
                        op.commandlist.append(
                            Path.Command(
                                "G2",
                                {
                                    "X": x,
                                    "Y": y,
                                    "Z": curDep - depthPerOneCircle,
                                    "I": r,
                                    "F": op.vertFeed,
                                },
                            )
                        )
                        curDep = curDep - depthPerOneCircle

                    lastStep = curDep - passEndDepth
                    if lastStep > (depthPerOneCircle / 2):
                        op.commandlist.append(
                            Path.Command(
                                "G2",
                                {
                                    "X": x - (2 * r),
                                    "Y": y,
                                    "Z": curDep - (lastStep / 2),
                                    "I": -r,
                                    "F": op.vertFeed,
                                },
                            )
                        )
                        op.commandlist.append(
                            Path.Command(
                                "G2",
                                {
                                    "X": x,
                                    "Y": y,
                                    "Z": passEndDepth,
                                    "I": r,
                                    "F": op.vertFeed,
                                },
                            )
                        )
                    else:
                        op.commandlist.append(
                            Path.Command(
                                "G2",
                                {
                                    "X": x - (2 * r),
                                    "Y": y,
                                    "Z": passEndDepth,
                                    "I": -r,
                                    "F": op.vertFeed,
                                },
                            )
                        )
                        op.commandlist.append(
                            Path.Command(
                                "G1",
                                {"X": x, "Y": y, "Z": passEndDepth, "F": op.vertFeed},
                            )
                        )

                    # one more circle at target depth to make sure center is cleared
                    op.commandlist.append(
                        Path.Command(
                            "G2",
                            {
                                "X": x - (2 * r),
                                "Y": y,
                                "Z": passEndDepth,
                                "I": -r,
                                "F": op.horizFeed,
                            },
                        )
                    )
                    op.commandlist.append(
                        Path.Command(
                            "G2",
                            {
                                "X": x,
                                "Y": y,
                                "Z": passEndDepth,
                                "I": r,
                                "F": op.horizFeed,
                            },
                        )
                    )

            else:  # no helix entry
                # rapid move to clearance height
                op.commandlist.append(Path.Command("G0", {"Z": obj.ClearanceHeight.Value}))
                op.commandlist.append(
                    Path.Command(
                        "G0",
                        {
                            "X": region["StartPoint"][0],
                            "Y": region["StartPoint"][1],
                            "Z": obj.ClearanceHeight.Value,
                        },
                    )
                )
                # straight plunge to target depth
                op.commandlist.append(
                    Path.Command(
                        "G1",
                        {
                            "X": region["StartPoint"][0],
                            "Y": region["StartPoint"][1],
                            "Z": passEndDepth,
                            "F": op.vertFeed,
                        },
                    )
                )

            lz = passEndDepth
            z = obj.ClearanceHeight.Value
            op.commandlist.append(Path.Command("(Adaptive - depth: %f)" % passEndDepth))

            # add adaptive paths
            for pth in region["AdaptivePaths"]:
                motionType = pth[0]  # [0] contains motion type

                for pt in pth[1]:  # [1] contains list of points
                    x = pt[0]
                    y = pt[1]

                    if motionType == area.AdaptiveMotionType.Cutting:
                        z = passEndDepth
                        if z != lz:
                            op.commandlist.append(Path.Command("G1", {"Z": z, "F": op.vertFeed}))

                        op.commandlist.append(
                            Path.Command("G1", {"X": x, "Y": y, "F": op.horizFeed})
                        )

                    elif motionType == area.AdaptiveMotionType.LinkClear:
                        z = passEndDepth + stepUp
                        if z != lz:
                            op.commandlist.append(Path.Command("G0", {"Z": z}))

                        op.commandlist.append(Path.Command("G0", {"X": x, "Y": y}))

                    elif motionType == area.AdaptiveMotionType.LinkNotClear:
                        z = obj.ClearanceHeight.Value
                        if z != lz:
                            op.commandlist.append(Path.Command("G0", {"Z": z}))

                        op.commandlist.append(Path.Command("G0", {"X": x, "Y": y}))

                    lz = z

            # return to safe height in this Z pass
            z = obj.ClearanceHeight.Value
            if z != lz:
                op.commandlist.append(Path.Command("G0", {"Z": z}))

            lz = z

            passStartDepth = passEndDepth

            # return to safe height in this Z pass
            z = obj.ClearanceHeight.Value
            if z != lz:
                op.commandlist.append(Path.Command("G0", {"Z": z}))

            lz = z

    z = obj.ClearanceHeight.Value
    if z != lz:
        op.commandlist.append(Path.Command("G0", {"Z": z}))


def Execute(op, obj):
    global sceneGraph
    global topZ

    if FreeCAD.GuiUp:
        sceneGraph = FreeCADGui.ActiveDocument.ActiveView.getSceneGraph()

    Path.Log.info("*** Adaptive toolpath processing started...\n")

    # hide old toolpaths during recalculation
    obj.Path = Path.Path("(Calculating...)")

    oldObjVisibility = oldJobVisibility = False
    if FreeCAD.GuiUp:
        # store old visibility state
        job = op.getJob(obj)
        oldObjVisibility = obj.ViewObject.Visibility
        oldJobVisibility = job.ViewObject.Visibility

        obj.ViewObject.Visibility = False
        job.ViewObject.Visibility = False

        FreeCADGui.updateGui()

    try:
        helixDiameter = obj.HelixDiameterLimit.Value
        topZ = op.stock.Shape.BoundBox.ZMax
        obj.Stopped = False
        obj.StopProcessing = False
        if obj.Tolerance < 0.001:
            obj.Tolerance = 0.001

        # Get list of working edges for adaptive algorithm
        pathArray = op.pathArray
        if not pathArray:
            msg = translate(
                "CAM",
                "Adaptive operation couldn't determine the boundary wire. Did you select base geometry?",
            )
            FreeCAD.Console.PrintUserWarning(msg)
            return

        path2d = convertTo2d(pathArray)

        # Use the 2D outline of the stock as the stock
        # FIXME: This does not account for holes in the middle of stock!
        outer_wire = TechDraw.findShapeOutline(op.stock.Shape, 1, FreeCAD.Vector(0, 0, 1))
        stockPaths = [[discretize(outer_wire)]]

        stockPath2d = convertTo2d(stockPaths)

        # opType = area.AdaptiveOperationType.ClearingInside  # Commented out per LGTM suggestion
        if obj.OperationType == "Clearing":
            if obj.Side == "Outside":
                opType = area.AdaptiveOperationType.ClearingOutside

            else:
                opType = area.AdaptiveOperationType.ClearingInside

        else:  # profiling
            if obj.Side == "Outside":
                opType = area.AdaptiveOperationType.ProfilingOutside

            else:
                opType = area.AdaptiveOperationType.ProfilingInside

        keepToolDownRatio = 3.0
        if hasattr(obj, "KeepToolDownRatio"):
            keepToolDownRatio = float(obj.KeepToolDownRatio)

        # put here all properties that influence calculation of adaptive base paths,

        inputStateObject = {
            "tool": float(op.tool.Diameter),
            "tolerance": float(obj.Tolerance),
            "geometry": path2d,
            "stockGeometry": stockPath2d,
            "stepover": float(obj.StepOver),
            "effectiveHelixDiameter": float(helixDiameter),
            "operationType": obj.OperationType,
            "side": obj.Side,
            "forceInsideOut": obj.ForceInsideOut,
            "finishingProfile": obj.FinishingProfile,
            "keepToolDownRatio": keepToolDownRatio,
            "stockToLeave": float(obj.StockToLeave),
            "modelAwareExperiment": obj.ModelAwareExperiment,
        }

        inputStateChanged = False
        adaptiveResults = None

        if obj.AdaptiveOutputState is not None and obj.AdaptiveOutputState != "":
            adaptiveResults = obj.AdaptiveOutputState

        if json.dumps(obj.AdaptiveInputState) != json.dumps(inputStateObject):
            inputStateChanged = True
            adaptiveResults = None

        # progress callback fn, if return true it will stop processing
        def progressFn(tpaths):
            if FreeCAD.GuiUp:
                for (
                    path
                ) in tpaths:  # path[0] contains the MotionType, #path[1] contains list of points
                    if path[0] == area.AdaptiveMotionType.Cutting:
                        sceneDrawPath(path[1], (0, 0, 1))

                    else:
                        sceneDrawPath(path[1], (1, 0, 1))

                FreeCADGui.updateGui()

            return obj.StopProcessing

        start = time.time()

        if inputStateChanged or adaptiveResults is None:
            a2d = area.Adaptive2d()
            a2d.stepOverFactor = 0.01 * obj.StepOver
            a2d.toolDiameter = float(op.tool.Diameter)
            a2d.helixRampDiameter = helixDiameter
            a2d.keepToolDownDistRatio = keepToolDownRatio
            a2d.stockToLeave = float(obj.StockToLeave)
            a2d.tolerance = float(obj.Tolerance)
            a2d.forceInsideOut = obj.ForceInsideOut
            a2d.finishingProfile = obj.FinishingProfile
            a2d.opType = opType

            # EXECUTE
            results = a2d.Execute(stockPath2d, path2d, progressFn)

            # need to convert results to python object to be JSON serializable
            adaptiveResults = []
            for result in results:
                adaptiveResults.append(
                    {
                        "HelixCenterPoint": result.HelixCenterPoint,
                        "StartPoint": result.StartPoint,
                        "AdaptivePaths": result.AdaptivePaths,
                        "ReturnMotionType": result.ReturnMotionType,
                    }
                )

        # GENERATE
        GenerateGCode(op, obj, adaptiveResults, helixDiameter)

        if not obj.StopProcessing:
            Path.Log.info("*** Done. Elapsed time: %f sec\n\n" % (time.time() - start))
            obj.AdaptiveOutputState = adaptiveResults
            obj.AdaptiveInputState = inputStateObject

        else:
            Path.Log.info("*** Processing cancelled (after: %f sec).\n\n" % (time.time() - start))

    finally:
        if FreeCAD.GuiUp:
            obj.ViewObject.Visibility = oldObjVisibility
            job.ViewObject.Visibility = oldJobVisibility
            sceneClean()


def ExecuteModelAware(op, obj):
    global sceneGraph
    global topZ

    if FreeCAD.GuiUp:
        sceneGraph = FreeCADGui.ActiveDocument.ActiveView.getSceneGraph()

    Path.Log.info("*** Adaptive toolpath processing started...\n")

    # hide old toolpaths during recalculation
    obj.Path = Path.Path("(Calculating...)")

    oldObjVisibility = oldJobVisibility = False
    if FreeCAD.GuiUp:
        # store old visibility state
        job = op.getJob(obj)
        oldObjVisibility = obj.ViewObject.Visibility
        oldJobVisibility = job.ViewObject.Visibility

        obj.ViewObject.Visibility = False
        job.ViewObject.Visibility = False

        FreeCADGui.updateGui()

    try:
        helixDiameter = obj.HelixDiameterLimit.Value
        topZ = op.stock.Shape.BoundBox.ZMax
        obj.Stopped = False
        obj.StopProcessing = False
        obj.Tolerance = max(0.001, obj.Tolerance)

        # NOTE: Reminder that stock is formatted differently than inside/outside!
        stockPaths = {d: convertTo2d(op.stockPathArray[d]) for d in op.stockPathArray}

        outsideClearing = area.AdaptiveOperationType.ClearingOutside
        insideClearing = area.AdaptiveOperationType.ClearingInside
        outsideProfiling = area.AdaptiveOperationType.ProfilingOutside
        insideProfiling = area.AdaptiveOperationType.ProfilingInside

        # List every REGION separately- we can then calculate a toolpath based
        # on the region. One or more stepdowns may use that same toolpath by
        # keeping a reference to the region without requiring we calculate the
        # toolpath once per step down OR forcing all stepdowns of a region into
        # a single list.
        regionOps = list()
        outsidePathArray2dDepthTuples = list()
        insidePathArray2dDepthTuples = list()
        # NOTE: Make sure the depth lists are sorted for use in order-by-depth
        # and order-by-region algorithms below
        # NOTE: Pretty sure sorting is already guaranteed by how these are
        # created, but best to not assume that
        for rdict in op.outsidePathArray:
            regionOps.append(
                {
                    "opType": (
                        outsideClearing if obj.OperationType == "Clearing" else outsideProfiling
                    ),
                    "path2d": convertTo2d(rdict["edges"]),
                    "id": rdict["id"],
                    "children": rdict["children"],
                    # FIXME: Kinda gross- just use this to match up with the
                    # appropriate stockpaths entry...
                    "startdepth": rdict["depths"][0],
                }
            )
            outsidePathArray2dDepthTuples.append(
                (sorted(rdict["depths"], reverse=True), regionOps[-1])
            )
        for rdict in op.insidePathArray:
            regionOps.append(
                {
                    "opType": (
                        insideClearing if obj.OperationType == "Clearing" else insideProfiling
                    ),
                    "path2d": convertTo2d(rdict["edges"]),
                    "id": rdict["id"],
                    "children": rdict["children"],
                    # FIXME: Kinda gross- just use this to match up with the
                    # appropriate stockpaths entry...
                    "startdepth": rdict["depths"][0],
                }
            )
            insidePathArray2dDepthTuples.append(
                (sorted(rdict["depths"], reverse=True), regionOps[-1])
            )

        keepToolDownRatio = 3.0
        if hasattr(obj, "KeepToolDownRatio"):
            keepToolDownRatio = obj.KeepToolDownRatio.Value

        # These fields are used to determine if toolpaths should be recalculated
        outsideInputStateObject = {
            "tool": op.tool.Diameter.Value,
            "tolerance": obj.Tolerance,
            "geometry": [
                k["path2d"] for k in regionOps if k["opType"] in [outsideClearing, outsideProfiling]
            ],
            "stockGeometry": stockPaths,
            "stepover": obj.StepOver,
            "effectiveHelixDiameter": helixDiameter,
            "operationType": "Clearing",
            "side": "Outside",
            "forceInsideOut": obj.ForceInsideOut,
            "finishingProfile": obj.FinishingProfile,
            "keepToolDownRatio": keepToolDownRatio,
            "stockToLeave": obj.StockToLeave.Value,
            "zStockToLeave": obj.ZStockToLeave.Value,
            "orderCutsByRegion": obj.OrderCutsByRegion,
        }

        insideInputStateObject = {
            "tool": op.tool.Diameter.Value,
            "tolerance": obj.Tolerance,
            "geometry": [
                k["path2d"] for k in regionOps if k["opType"] in [insideClearing, insideProfiling]
            ],
            "stockGeometry": stockPaths,
            "stepover": obj.StepOver,
            "effectiveHelixDiameter": helixDiameter,
            "operationType": "Clearing",
            "side": "Inside",
            "forceInsideOut": obj.ForceInsideOut,
            "finishingProfile": obj.FinishingProfile,
            "keepToolDownRatio": keepToolDownRatio,
            "stockToLeave": obj.StockToLeave.Value,
            "zStockToLeave": obj.ZStockToLeave.Value,
            "orderCutsByRegion": obj.OrderCutsByRegion,
            "modelAwareExperiment": obj.ModelAwareExperiment,
        }

        inputStateObject = [outsideInputStateObject, insideInputStateObject]

        inputStateChanged = False
        adaptiveResults = None

        # If we have a valid... path? Something. Generated, make that
        # tentatively the output
        if obj.AdaptiveOutputState:
            adaptiveResults = obj.AdaptiveOutputState

        # If ANYTHING in our input-cutting parameters, cutting regions,
        # etc.- changes, force recalculating
        if json.dumps(obj.AdaptiveInputState) != json.dumps(inputStateObject):
            inputStateChanged = True
            adaptiveResults = None

        # progress callback fn, if return true it will stop processing
        def progressFn(tpaths):
            if FreeCAD.GuiUp:
                for (
                    path
                ) in tpaths:  # path[0] contains the MotionType, #path[1] contains list of points
                    if path[0] == area.AdaptiveMotionType.Cutting:
                        sceneDrawPath(path[1], (0, 0, 1))

                    else:
                        sceneDrawPath(path[1], (1, 0, 1))

                FreeCADGui.updateGui()

            return obj.StopProcessing

        start = time.time()

        if inputStateChanged or adaptiveResults is None:
            # NOTE: Seem to need to create a new a2d for each area when we're
            # stepping down depths like this. If we don't, it will keep history
            # from the last region we did.

            # TODO: QThread/QRunnable trigger Python's global interpreter lock
            # (GIL). To calculate toolpaths in parallel, making a C++ shim that
            # takes in the array of regions/stock paths and parallelizes in
            # C++-land is probably the way to do it.

            # Create a toolpath for each region to avoid re-calculating for
            # identical stepdowns
            for rdict in regionOps:
                path2d = rdict["path2d"]
                opType = rdict["opType"]

                a2d = area.Adaptive2d()
                a2d.stepOverFactor = 0.01 * obj.StepOver
                a2d.toolDiameter = op.tool.Diameter.Value
                a2d.helixRampDiameter = helixDiameter
                a2d.keepToolDownDistRatio = keepToolDownRatio
                # NOTE: Z stock is handled in our stepdowns
                a2d.stockToLeave = obj.StockToLeave.Value
                a2d.tolerance = obj.Tolerance
                a2d.forceInsideOut = obj.ForceInsideOut
                a2d.finishingProfile = obj.FinishingProfile
                a2d.opType = opType

                rdict["toolpaths"] = a2d.Execute(
                    stockPaths[rdict["startdepth"]], path2d, progressFn
                )

            # Sort regions to cut by either depth or area.
            # TODO: Bonus points for ordering to minimize rapids
            cutlist = list()
            # Region IDs that have been cut already
            cutids = list()
            # Create sorted list of unique depths
            # NOTE: reverse because we cut top-down!
            depths = list()
            # NOTE: alltuples is sorted by depth already
            alltuples = outsidePathArray2dDepthTuples + insidePathArray2dDepthTuples
            for t in alltuples:
                depths += [d for d in t[0]]
            depths = sorted(list(set(depths)), reverse=True)
            if obj.OrderCutsByRegion:
                # Translate child ID numbers to an actual reference to the
                # associated tuple
                for rdict in regionOps:
                    rdict["childTuples"] = [t for t in alltuples if t[1]["id"] in rdict["children"]]

                # Helper function to recurse down children
                def addToCutList(tuples):
                    for k in tuples:
                        if k in cutlist:
                            continue
                        cutlist.append(k)
                        addToCutList(k[1]["childTuples"])

                addToCutList(alltuples)
            else:
                for d in depths:
                    cutlist += [([d], o[1]) for o in outsidePathArray2dDepthTuples if d in o[0]]
                    cutlist += [([d], i[1]) for i in insidePathArray2dDepthTuples if d in i[0]]

            # need to convert results to python object to be JSON serializable
            stepdown = max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN)
            adaptiveResults = list()
            for depths, region in cutlist:
                for result in region["toolpaths"]:
                    # Top depth is the height where the helix starts for a
                    # region.
                    # We want the lowest of 3 possibilities:
                    # - the top of the stock OR
                    # - the region's first cut depth + stepdown OR
                    # - the operation's starting depth
                    # The starting depth option covers the case where the user
                    # has a previous operations that cleared some stock and
                    # wants the adaptive toolpath to pick up where the previous
                    # operation left off.
                    # Regions are only generated where stock needs to be
                    # removed, so we can't start at the cut level- we know
                    # there's material there.
                    # TODO: Due to the adaptive algorithm currently not
                    # processing holes in the stock when finding entry points,
                    # this may result in a helix up to stepdown in height where
                    # one isn't required. This should be fixed in FindEntryPoint
                    # or FindEntryPointOutside in Adaptive.cpp, not bandaged
                    # here.

                    TopDepth = min(
                        topZ,
                        depths[0] + stepdown,
                        obj.StartDepth.Value,
                    )

                    adaptiveResults.append(
                        {
                            "HelixCenterPoint": result.HelixCenterPoint,
                            "StartPoint": result.StartPoint,
                            "AdaptivePaths": result.AdaptivePaths,
                            "ReturnMotionType": result.ReturnMotionType,
                            "TopDepth": TopDepth,
                            "BottomDepth": depths[-1],
                        }
                    )

        # GENERATE
        GenerateGCode(op, obj, adaptiveResults, helixDiameter)

        if not obj.StopProcessing:
            Path.Log.info("*** Done. Elapsed time: %f sec\n\n" % (time.time() - start))
            obj.AdaptiveOutputState = adaptiveResults
            obj.AdaptiveInputState = inputStateObject

        else:
            Path.Log.info("*** Processing cancelled (after: %f sec).\n\n" % (time.time() - start))

    finally:
        if FreeCAD.GuiUp:
            obj.ViewObject.Visibility = oldObjVisibility
            job.ViewObject.Visibility = oldJobVisibility
            sceneClean()


def projectFacesToXY(faces, minEdgeLength=1e-10):
    """projectFacesToXY(faces, minEdgeLength)
    Calculates the projection of the provided list of faces onto the XY plane.
    The returned value is a single shape that may contain multiple faces if
    there were disjoint projections. Each individual face will be clean, without
    triangulated geometry, etc., and will be at Z=0 on the XY plane

    minEdgeLength is provided to (eg) filter out the tips of cones that are
    internally represented as arbitrarily-small circular faces- using those for
    additional operations causes problems.
    """
    projdir = FreeCAD.Vector(0, 0, 1)
    outfaces = []
    for f in faces:
        # Vertical cones and spheres will still have a projection on the XY
        # plane. Cylinders and flat faces will not.
        if Path.Geom.isVertical(f) and type(f.Surface) not in [Part.Cone, Part.Sphere]:
            continue

        # NOTE: Wires/edges get clipped if we have an "exact fit" bounding box
        projface = Path.Geom.makeBoundBoxFace(f.BoundBox, offset=1, zHeight=0)

        # NOTE: Cylinders, cones, and spheres are messy:
        # - Internal representation of non-truncted cones and spheres includes
        # the "tip" with a ~0-area closed edge. This is different than the
        # "isNull() note" at the top in magnitude
        # - Projecting edges doesn't naively work due to the way seams are handled
        # - There may be holes at either end that may or may not line up- any
        # overlap is a hole in the projection
        if type(f.Surface) in [Part.Cone, Part.Cylinder, Part.Sphere]:
            # This gets most of the face outline, but since cylinder/cone faces
            # are hollow, if the ends overlap in the projection there may be a
            # hole we need to remove from the solid projection
            oface = Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir))

            # "endfacewires" is JUST the end faces of a cylinder/cone, used to
            # determine if there's a hole we can see through the shape that
            # should NOT be solid in the projection
            endfacewires = DraftGeomUtils.findWires(
                [e for e in f.Edges if not e.isSeam(f) and e.Length > minEdgeLength]
            )

            # Need to verify that there actually is a projection before taking
            # a wire from the list, else this could nicely be one line.
            projwires = []
            for w in endfacewires:
                pp = projface.makeParallelProjection(w, projdir).Wires
                if pp:
                    projwires.append(pp[0])

            if len(projwires) > 1:
                faces = [Part.makeFace(x) for x in projwires]
                overlap = faces[0].common(faces[1:])
                outfaces.append(oface.cut(overlap))
            else:
                outfaces.append(oface)
        # For other cases, projecting the wires to a plane should suffice
        else:
            facewires = list()
            for w in f.Wires:
                if w.isClosed():
                    projwire = projface.makeParallelProjection(w, projdir).Wires[0]
                    if projwire.isClosed():
                        facewires.append(projwire)
            if facewires:
                outfaces.append(Part.makeFace(facewires))
    if outfaces:
        fusion = outfaces[0].fuse(outfaces[1:])
        # removeSplitter fixes occasional concatenate issues for some face orders
        return DraftGeomUtils.concatenate(fusion.removeSplitter())
    else:
        return Part.Shape()


def _getSolidProjection(shp, z):
    """_getSolidProjection(shp, z)
    Calculates a shape obtained by slicing shp at the height z, then projecting
    the solids above that height onto a region of proj_face, and creating a
    simplified face
    """
    bb = shp.BoundBox

    # Find all faces above the machining depth. This is used to mask future
    # interior cuts, and the outer wire is used as the external wire
    bbCutTop = Part.makeBox(
        bb.XLength,
        bb.YLength,
        max(bb.ZLength, bb.ZLength - z),
        FreeCAD.Vector(bb.XMin, bb.YMin, z),
    )
    aboveSolids = shp.common(bbCutTop).Solids

    faces = list()
    for s in aboveSolids:
        faces += s.Faces

    return projectFacesToXY(faces)


def _workingEdgeHelperRoughing(op, obj, depths):
    # Final calculated regions- list of dicts with entries:
    # "region" - actual shape
    # "depths" - list of depths this region applies to
    insideRegions = list()
    outsideRegions = list()

    # Multiple input solids can be selected- make a single part out of them,
    # will process each solid separately as appropriate
    shps = op.model[0].Shape.fuse([k.Shape for k in op.model[1:]])

    projdir = FreeCAD.Vector(0, 0, 1)

    # Take outline of entire model as our baseline machining region. No need to
    # do this repeatedly inside the loop.
    modelOutlineFaces = [
        Part.makeFace(TechDraw.findShapeOutline(s, 1, projdir)) for s in shps.Solids
    ]

    lastdepth = obj.StartDepth.Value

    for depth in depths:
        # If we have no stock to machine, just skip all the rest of the math
        if depth >= op.stock.Shape.BoundBox.ZMax:
            lastdepth = depth
            continue

        # NOTE: To "leave" stock along Z without actually checking any face
        # depths, we simply slice the model "lower" than our actual cut depth by
        # the Z stock to leave, which ensures we stay at least that far from the
        # actual faces
        stockface = _getSolidProjection(op.stock.Shape, depth - obj.ZStockToLeave.Value)
        aboveRefined = _getSolidProjection(shps, depth - obj.ZStockToLeave.Value)

        # Outside is based on the outer wire of the above_faces
        # Insides are based on the remaining "below" regions, masked by the
        # "above"- if something is above an area, we can't machine it in 2.5D

        # OUTSIDE REGIONS
        # Outside: Take the outer wire of the above faces
        # NOTE: Exactly one entry per depth (not necessarily one depth entry per
        # stepdown, however), which is a LIST of the wires we're staying outside
        # NOTE: Do this FIRST- if any inside regions share enough of an edge
        # with an outside region for a tool to get through, we want to skip them
        # for the current stepdown
        if aboveModelFaces := [
            Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir)) for f in aboveRefined.Faces
        ]:
            aboveModelFaces = aboveModelFaces[0].fuse(aboveModelFaces[1:])
        else:
            aboveModelFaces = Part.Shape()
        # If this region exists in our list, it has to be the last entry, due to
        # proceeding in order and having only one per depth. If it's already
        # there, replace with the new, deeper depth, else add new
        # NOTE: Check for NULL regions to not barf on regions between the top of
        # the model and the top of the stock, which are "outside" of nothing
        # NOTE: See "isNull() note" at top of file
        if (
            outsideRegions
            and outsideRegions[-1]["region"].Wires
            and aboveModelFaces.Wires
            and not aboveModelFaces.cut(outsideRegions[-1]["region"]).Wires
        ):
            outsideRegions[-1]["depths"].append(depth)
        else:
            outsideRegions.append({"region": aboveModelFaces, "depths": [depth]})

        # NOTE: If you don't care about controlling depth vs region ordering,
        # you can actually just do everything with "outside" processing, if you
        # don't remove internal holes from the regions above

        # INSIDE REGIONS
        # NOTE: Nothing inside if there's no model above us
        # NOTE: See "isNull() note" at top of file
        if aboveModelFaces.Wires:
            # Remove any overlapping areas already machined from the outside.
            outsideface = stockface.cut(outsideRegions[-1]["region"].Faces)
            # NOTE: See "isNull() note" at top of file
            if outsideface.Wires:
                belowFaces = [f.cut(outsideface) for f in modelOutlineFaces]
            else:
                # NOTE: Doesn't matter here, but ensure we're making a new list so
                # we don't clobber modelOutlineFaces
                belowFaces = [f for f in modelOutlineFaces]

            # This isn't really necessary unless the user inputs bad data- eg, a
            # min depth above the top of the model. In which case we still want to
            # clear the stock
            if belowFaces:
                # Remove the overhangs from the desired region to cut
                belowCut = belowFaces[0].fuse(belowFaces[1:]).cut(aboveRefined)
                # NOTE: See "isNull() note" at top of file
                if belowCut.Wires:
                    # removeSplitter fixes occasional concatenate issues for
                    # some face orders
                    finalCut = DraftGeomUtils.concatenate(belowCut.removeSplitter())
                else:
                    finalCut = Part.Shape()
            else:
                # Make a dummy shape if we don't have anything actually below
                finalCut = Part.Shape()

            # Split up into individual faces if any are disjoint, then update
            # insideRegions- either by adding a new entry OR by updating the depth
            # of an existing entry
            for f in finalCut.Faces:
                addNew = True
                # Brute-force search all existing regions to see if any are the same
                newtop = lastdepth
                for rdict in insideRegions:
                    # FIXME: Smarter way to do this than a full cut operation?
                    if not rdict["region"].cut(f).Wires:
                        rdict["depths"].append(depth)
                        addNew = False
                        break
                if addNew:
                    insideRegions.append({"region": f, "depths": [depth]})

        # Update the last depth step
        lastdepth = depth
    # end for depth

    return insideRegions, outsideRegions


def _workingEdgeHelperManual(op, obj, depths):
    # Final calculated regions- list of dicts with entries:
    # "region" - actual shape
    # "depths" - list of depths this region applies to
    insideRegions = list()
    outsideRegions = list()

    # User selections, with extensions
    selectedRegions = list()
    selectedEdges = list()

    # Get extensions and identify faces to avoid
    extensions = FeatureExtensions.getExtensions(obj)
    avoidFeatures = [e for e in extensions if e.avoid]

    # Similarly, expand selected regions with extensions
    for ext in extensions:
        if not ext.avoid:
            if wire := ext.getWire():
                # NOTE: Can NOT just make a face directly, since that just gives
                # the outside profile and removes internal holes
                for f in ext.getExtensionFaces(wire):
                    selectedRegions.extend(f.Faces)

    for base, subs in obj.Base:
        for sub in subs:
            element = base.Shape.getElement(sub)
            if sub.startswith("Face") and sub not in avoidFeatures:
                shape = Part.Face(element.OuterWire) if obj.UseOutline else element
                selectedRegions.append(shape)
            # Omit vertical edges, since they project to nothing in the XY plane
            # and cause processing failures later if included
            elif sub.startswith("Edge") and not Path.Geom.isVertical(element):
                selectedEdges.append(element)

    # Multiple input solids can be selected- make a single part out of them,
    # will process each solid separately as appropriate
    shps = op.model[0].Shape.fuse([k.Shape for k in op.model[1:]])

    # Make a face to project onto
    # NOTE: Use 0 as the height, since that's what TechDraw.findShapeOutline
    # uses, which we use to find the machining boundary, and the actual depth
    # is tracked separately.
    # NOTE: Project to the PART bounding box- with some padding- not the stock,
    # since the stock may be smaller than the part
    projface = Path.Geom.makeBoundBoxFace(shps.BoundBox, offset=1, zHeight=0)
    projdir = FreeCAD.Vector(0, 0, 1)

    # When looking for selected edges, project to a single plane first, THEN try
    # to find wires. Take all the resulting wires and make faces in one shot to
    # make bullseye-style cutouts where selected wires nest.
    edgefaces = list()
    if selectedEdges:
        pp = [projface.makeParallelProjection(e, projdir).Wires[0] for e in selectedEdges]
        ppe = list()
        for w in pp:
            ppe += w.Edges
        edgeWires = DraftGeomUtils.findWires(ppe)
        edgefaces = Part.makeFace(edgeWires).Faces

    selectedRefined = projectFacesToXY(selectedRegions + edgefaces)

    # If the user selected only faces that don't have an XY projection AND no
    # edges, give a useful message
    if not selectedRefined.Wires:
        Path.Log.warning("Selected faces/wires have no projection on the XY plane")
        return insideRegions, outsideRegions

    lastdepth = obj.StartDepth.Value

    for depth in depths:
        # If our depth is above the top of the stock, there's nothing to machine
        if depth >= op.stock.Shape.BoundBox.ZMax:
            lastdepth = depth
            continue

        # NOTE: See note in _workingEdgeHelperRoughing- tl;dr slice stock
        # lower than cut depth to effectively leave (at least) obj.ZStockToLeave
        aboveRefined = _getSolidProjection(shps, depth - obj.ZStockToLeave.Value)

        # Create appropriate tuples and add to list, processing inside/outside
        # as requested by operation
        if obj.Side == "Outside":
            # Outside is based on the outer wire of the faces of aboveRefined
            # Insides are based on the remaining "below" regions, masked by the
            # "above"- if something is above an area, we can't machine it in 2.5D

            # Outside: Take the outer wire of the above faces, added to selected
            # edges and regions
            # NOTE: Exactly one entry per depth (not necessarily one depth entry per
            # stepdown, however), which is a LIST of the wires we're staying outside
            # NOTE: Do this FIRST- if any inside regions share enough of an edge
            # with an outside region for a tool to get through, we want to skip them
            # for the current stepdown
            # NOTE: This check naively seems unnecessary, but it's possible the
            # user selected a vertical face as the only face to stay outside of,
            # and we're above the model, causing keepOutFaces to be empty
            if keepOutFaces := [
                Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir))
                for f in aboveRefined.Faces + selectedRefined.Faces
            ]:
                finalMerge = keepOutFaces[0].fuse(keepOutFaces[1:])
            else:
                finalMerge = selectedRefined
            # Without removeSplitter(), concatenate will sometimes fail when
            # trying to merge faces that are (eg) connected A-B and B-C,
            # seemingly when trying to merge A-C
            regions = DraftGeomUtils.concatenate(finalMerge.removeSplitter())

            # If this region exists in our list, it has to be the last entry, due to
            # proceeding in order and having only one per depth. If it's already
            # there, replace with the new, deeper depth, else add new
            # NOTE: Do NOT need a check for whether outsideRegions[-1]["region"]
            # is valid since we have a user-specified region regardless of depth
            # NOTE: See "isNull() note" at top of file
            if (
                outsideRegions
                and regions.Wires
                and not regions.cut(outsideRegions[-1]["region"]).Wires
            ):
                outsideRegions[-1]["depths"].append(depth)
            else:
                outsideRegions.append({"region": regions, "depths": [depth]})

        # Inside
        # For every area selected by the user, project to a plane
        # NOTE: See "isNull() note" at top of file
        else:
            if aboveRefined.Wires:
                finalCut = selectedRefined.cut(aboveRefined)
            else:
                finalCut = selectedRefined

            # Split up into individual faces if any are disjoint, then update
            # insideRegions- either by adding a new entry OR by updating the depth
            # of an existing entry
            for f in finalCut.Faces:
                addNew = True
                # Brute-force search all existing regions to see if any are the same
                newtop = lastdepth
                for rdict in insideRegions:
                    # FIXME: Smarter way to do this than a full cut operation?
                    if not rdict["region"].cut(f).Wires:
                        rdict["depths"].append(depth)
                        addNew = False
                        break
                if addNew:
                    insideRegions.append({"region": f, "depths": [depth]})

        # Update the last depth step
        lastdepth = depth
    # end for depth

    return insideRegions, outsideRegions


def _get_working_edges(op, obj):
    """_get_working_edges(op, obj)...
    Compile all working edges from the Base Geometry selection (obj.Base)
    for the current operation.
    Additional modifications to selected region(face), such as extensions,
    should be placed within this function.
    """
    all_regions = list()
    edge_list = list()
    avoidFeatures = list()
    rawEdges = list()

    # Get extensions and identify faces to avoid
    extensions = FeatureExtensions.getExtensions(obj)
    for e in extensions:
        if e.avoid:
            avoidFeatures.append(e.feature)

    # Get faces selected by user
    for base, subs in obj.Base:
        for sub in subs:
            if sub.startswith("Face"):
                if sub not in avoidFeatures:
                    if obj.UseOutline:
                        face = base.Shape.getElement(sub)
                        # get outline with wire_A method used in PocketShape, but it does not play nicely later
                        # wire_A = TechDraw.findShapeOutline(face, 1, FreeCAD.Vector(0.0, 0.0, 1.0))
                        wire_B = face.OuterWire
                        shape = Part.Face(wire_B)
                    else:
                        shape = base.Shape.getElement(sub)
                    all_regions.append(shape)
            elif sub.startswith("Edge"):
                # Save edges for later processing
                rawEdges.append(base.Shape.getElement(sub))
    # Efor

    # Process selected edges
    if rawEdges:
        edgeWires = DraftGeomUtils.findWires(rawEdges)
        if edgeWires:
            for w in edgeWires:
                for e in w.Edges:
                    edge_list.append([discretize(e)])

    # Apply regular Extensions
    op.exts = []
    for ext in extensions:
        if not ext.avoid:
            wire = ext.getWire()
            if wire:
                for f in ext.getExtensionFaces(wire):
                    op.exts.append(f)
                    all_regions.append(f)

    # Second face-combining method attempted
    horizontal = Path.Geom.combineHorizontalFaces(all_regions)
    if horizontal:
        obj.removalshape = Part.makeCompound(horizontal)
        for f in horizontal:
            for w in f.Wires:
                for e in w.Edges:
                    edge_list.append([discretize(e)])

    return edge_list


def _getWorkingEdgesModelAware(op, obj):
    """_getWorkingEdgesModelAware(op, obj)...
    Compile all working edges from the Base Geometry selection (obj.Base)
    for the current operation (or the entire model if no selections).
    Additional modifications to selected region(face), such as extensions,
    should be placed within this function.
    This version will return two lists- one for outside (keepout) edges and one
    for inside ("machine inside") edges. Each list will be a dict with "region"
    and "depths" entries- the former being discretized geometry of the region,
    the latter being a list of every depth the geometry is machined on
    """

    # Find depth steps, throwing out all depths above anywhere we might cut
    # NOTE: Finish stepdown = 0 here- it's actually applied when gcode is
    # generated; doing so here would cause it to be applied twice.
    depthParams = PathUtils.depth_params(
        clearance_height=obj.ClearanceHeight.Value,
        safe_height=obj.SafeHeight.Value,
        start_depth=obj.StartDepth.Value,
        step_down=max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN),
        z_finish_step=0.0,
        final_depth=obj.FinalDepth.Value,
        user_depths=None,
    )

    # d < op.stock.Shape.BoundBox.ZMax may be true even if slicing at that
    # height causes no projection, which results in a NULL shape. Use the
    # operation tolerance to prevent that.
    depths = [d for d in depthParams.data if d - op.stock.Shape.BoundBox.ZMax < -obj.Tolerance]

    # Get the stock outline at each stepdown. Used to calculate toolpaths and
    # for calculating cut regions in some instances
    # NOTE: See note in _workingEdgeHelperRoughing- tl;dr slice stock lower
    # than cut depth to effectively leave (at least) obj.ZStockToLeave
    # NOTE: Stock is handled DIFFERENTLY than inside and outside regions!
    # Combining different depths just adds code to look up the correct outline
    # when computing inside/outside regions, for no real benefit.
    stockProjectionDict = {
        d: _getSolidProjection(op.stock.Shape, d - obj.ZStockToLeave.Value) for d in depths
    }

    # If user specified edges, calculate the machining regions based on that
    # input. Otherwise, process entire model
    # Output are lists of dicts with "region" and "depths" entries. Depths are
    # a list of Z depths that the region applies to
    # Inside regions are a single face; outside regions consist of ALL geometry
    # to be avoided at those depths.
    if obj.Base:
        insideRegions, outsideRegions = _workingEdgeHelperManual(op, obj, depths)
    else:
        insideRegions, outsideRegions = _workingEdgeHelperRoughing(op, obj, depths)

    # Find all children of each region. A child of region X is any region Y such
    # that Y is a subset of X AND Y starts within one stepdown of X (ie, direct
    # children only).
    # NOTE: Inside and outside regions are inverses of each other, so above
    # refers to the area to be machined!

    # Assign an ID number to track each region
    idnumber = 0
    for r in insideRegions + outsideRegions:
        r["id"] = idnumber
        r["children"] = list()
        idnumber += 1

    # NOTE: Inside and outside regions are inverses of each other
    # NOTE: Outside regions can't have parents
    for rx in insideRegions:
        for ry in [k for k in insideRegions if k != rx]:
            dist = min(rx["depths"]) - max(ry["depths"])
            # Ignore regions at our level or above, or more than one step down
            if dist <= 0 or dist > depthParams.step_down:
                continue
            if not ry["region"].cut(rx["region"]).Wires:
                rx["children"].append(ry["id"])
        # See which outside region this is a child of- basically inverse of above
        for ry in [k for k in outsideRegions]:
            dist = min(ry["depths"]) - max(rx["depths"])
            # Ignore regions at our level or above, or more than one step down
            if dist <= 0 or dist > depthParams.step_down:
                continue
            # child if there is NO overlap between the stay-outside and stay-
            # inside regions
            # Also a child if the outer region is NULL (includes everything)
            # NOTE: See "isNull() note" at top of file
            if not ry["region"].Wires or not rx["region"].common(ry["region"]).Wires:
                ry["children"].append(rx["id"])

    # Further split regions as necessary for when the stock changes- a region as
    # reported here is where a toolpath will be generated, and can be projected
    # along all of the depths associated with it. By doing this, we can minimize
    # the number of toolpaths that need to be generated AND avoid more complex
    # logic in depth-first vs region-first sorting of regions.
    # NOTE: For internal regions, stock is "the same" if the region cut with
    # the stock results in the same region.
    # NOTE: For external regions, stock is "the same" if the stock cut by the
    # region results in the same region
    def _regionChildSplitterHelper(regions, areInsideRegions):
        nonlocal stockProjectionDict
        nonlocal idnumber
        for r in regions:
            depths = sorted(r["depths"], reverse=True)
            if areInsideRegions:
                rcut = r["region"].cut(stockProjectionDict[depths[0]])
            else:
                # NOTE: We may end up with empty "outside" regions in the space
                # between the top of the stock and the top of the model- want
                # to machine the entire stock in that case
                # NOTE: See "isNull() note" at top of file
                if not r["region"].Wires:
                    rcut = stockProjectionDict[depths[0]]
                else:
                    rcut = stockProjectionDict[depths[0]].cut(r["region"])

            # If the region is already entirely within in the stock, there's no
            # way the region can change at a lower depth. That rcut is not
            # empty is an assumption for the check in the depth loop below
            if not rcut.Wires:
                continue

            # If the region cut with the stock at a new depth is different than
            # the original cut, we need to split this region
            # The new region gets all of the children, and becomes a child of
            # the existing region.
            parentdepths = depths[0:1]
            for d in depths[1:]:
                if (
                    areInsideRegions and r["region"].cut(stockProjectionDict[d]).cut(rcut).Wires
                ) or stockProjectionDict[d].cut(r["region"]).cut(rcut).Wires:
                    newregion = {
                        "id": idnumber,
                        "depths": [k for k in depths if k not in parentdepths],
                        "region": r["region"],
                        "children": r["children"],
                    }
                    # Update parent with the new region as a child, along with all
                    # the depths it was unchanged on
                    r["children"] = [idnumber]
                    r["depths"] = parentdepths

                    # Add the new region to the end of the list and stop processing
                    # this region
                    # When the new region is processed at the end, we'll effectively
                    # recurse and handle splitting that new region if required
                    regions.append(newregion)
                    idnumber += 1
                    continue
                # If we didn't split at this depth, the parent will keep "control"
                # of this depth
                parentdepths.append(d)

    _regionChildSplitterHelper(insideRegions, True)
    _regionChildSplitterHelper(outsideRegions, False)

    # Create discretized regions
    def _createDiscretizedRegions(regionDicts):
        discretizedRegions = list()
        for rdict in regionDicts:
            discretizedRegions.append(
                {
                    "edges": [[discretize(w)] for w in rdict["region"].Wires],
                    "depths": rdict["depths"],
                    "id": rdict["id"],
                    "children": rdict["children"],
                }
            )
        return discretizedRegions

    insideDiscretized = _createDiscretizedRegions(insideRegions)
    outsideDiscretized = _createDiscretizedRegions(outsideRegions)

    # NOTE: REMINDER: This is notably different from machining regions- just
    # a dict with depth: region entries, single depth for easy lookup
    stockDiscretized = {}
    for d in stockProjectionDict:
        discretizedEdges = list()
        for a in stockProjectionDict[d].Faces:
            for w in a.Wires:
                discretizedEdges.append([discretize(w)])
        stockDiscretized[d] = discretizedEdges

    # Return found inside and outside regions/depths. Up to the caller to decide
    # which ones it cares about.
    # NOTE: REMINDER: Stock is notably different from machining regions- just
    # a dict with depth: region entries, single depth for easy lookup
    return insideDiscretized, outsideDiscretized, stockDiscretized


class PathAdaptive(PathOp.ObjectOp):
    def opFeatures(self, obj):
        """opFeatures(obj) ... returns the OR'ed list of features used and supported by the operation.
        The default implementation returns "FeatureTool | FeatureDepths | FeatureHeights | FeatureStartPoint"
        Should be overwritten by subclasses."""
        return (
            PathOp.FeatureTool
            | PathOp.FeatureBaseEdges
            | PathOp.FeatureDepths
            | PathOp.FeatureFinishDepth
            | PathOp.FeatureStepDown
            | PathOp.FeatureHeights
            | PathOp.FeatureBaseGeometry
            | PathOp.FeatureCoolant
            | PathOp.FeatureLocations
        )

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

        # Enumeration lists for App::PropertyEnumeration properties
        enums = {
            "Side": [
                (translate("CAM_Adaptive", "Outside"), "Outside"),
                (translate("CAM_Adaptive", "Inside"), "Inside"),
            ],  # this is the direction that the profile runs
            "OperationType": [
                (translate("CAM_Adaptive", "Clearing"), "Clearing"),
                (translate("CAM_Adaptive", "Profiling"), "Profiling"),
            ],  # side of profile that cutter is on in relation to direction of profile
        }

        if dataType == "raw":
            return enums

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

        Path.Log.debug(enums)

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

        return data

    def initOperation(self, obj):
        """initOperation(obj) ... implement to create additional properties.
        Should be overwritten by subclasses."""
        obj.addProperty(
            "App::PropertyEnumeration",
            "Side",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Side of selected faces that tool should cut",
            ),
        )
        obj.addProperty(
            "App::PropertyEnumeration",
            "OperationType",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Type of adaptive operation",
            ),
        )
        obj.addProperty(
            "App::PropertyFloat",
            "Tolerance",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Influences calculation performance vs stability and accuracy.\n\nLarger values (further to the right) will calculate faster; smaller values (further to the left) will result in more accurate toolpaths.",
            ),
        )
        obj.addProperty(
            "App::PropertyPercent",
            "StepOver",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Percent of cutter diameter to step over on each pass",
            ),
        )
        obj.addProperty(
            "App::PropertyDistance",
            "LiftDistance",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Lift distance for rapid moves",
            ),
        )
        obj.addProperty(
            "App::PropertyDistance",
            "KeepToolDownRatio",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Max length of keep tool down path compared to direct distance between points",
            ),
        )
        obj.addProperty(
            "App::PropertyDistance",
            "StockToLeave",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "How much stock to leave in the XY plane (eg for finishing operation)",
            ),
        )
        obj.addProperty(
            "App::PropertyDistance",
            "ZStockToLeave",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "How much stock to leave along the Z axis (eg for finishing operation). This property is only used if the ModelAwareExperiment is enabled.",
            ),
        )
        obj.addProperty(
            "App::PropertyBool",
            "ForceInsideOut",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Force plunging into material inside and clearing towards the edges",
            ),
        )
        obj.addProperty(
            "App::PropertyBool",
            "FinishingProfile",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "To take a finishing profile path at the end",
            ),
        )
        obj.addProperty(
            "App::PropertyBool",
            "Stopped",
            "Adaptive",
            QT_TRANSLATE_NOOP("App::Property", "Stop processing"),
        )
        obj.setEditorMode("Stopped", 2)  # hide this property
        obj.addProperty(
            "App::PropertyBool",
            "StopProcessing",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Stop processing",
            ),
        )
        obj.setEditorMode("StopProcessing", 2)  # hide this property
        obj.addProperty(
            "App::PropertyBool",
            "UseHelixArcs",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Use Arcs (G2) for helix ramp",
            ),
        )
        obj.addProperty(
            "App::PropertyPythonObject",
            "AdaptiveInputState",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Internal input state",
            ),
        )
        obj.addProperty(
            "App::PropertyPythonObject",
            "AdaptiveOutputState",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Internal output state",
            ),
        )
        obj.setEditorMode("AdaptiveInputState", 2)  # hide this property
        obj.setEditorMode("AdaptiveOutputState", 2)  # hide this property
        obj.addProperty(
            "App::PropertyAngle",
            "HelixAngle",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Helix ramp entry angle (degrees)",
            ),
        )
        obj.addProperty(
            "App::PropertyAngle",
            "HelixConeAngle",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Helix cone angle (degrees)",
            ),
        )
        obj.addProperty(
            "App::PropertyLength",
            "HelixDiameterLimit",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Limit helix entry diameter, if limit larger than tool diameter or 0, tool diameter is used",
            ),
        )
        obj.addProperty(
            "App::PropertyBool",
            "UseOutline",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Uses the outline of the base geometry.",
            ),
        )
        obj.addProperty(
            "App::PropertyBool",
            "OrderCutsByRegion",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Orders cuts by region instead of depth. This property is only used if the ModelAwareExperiment is enabled.",
            ),
        )
        obj.addProperty(
            "Part::PropertyPartShape",
            "removalshape",
            "Path",
            QT_TRANSLATE_NOOP("App::Property", ""),
        )
        obj.addProperty(
            "App::PropertyBool",
            "ModelAwareExperiment",
            "Adaptive",
            QT_TRANSLATE_NOOP(
                "App::Property",
                "Enable the experimental model awareness feature to respect 3D geometry and prevent cutting under overhangs",
            ),
        )
        obj.setEditorMode("OrderCutsByRegion", 2)
        obj.setEditorMode("ZStockToLeave", 2)

        for n in self.propertyEnumerations():
            setattr(obj, n[0], n[1])

        obj.setEditorMode("removalshape", 2)  # hide

        FeatureExtensions.initialize_properties(obj)

    def opSetDefaultValues(self, obj, job):
        obj.Side = "Inside"
        obj.OperationType = "Clearing"
        obj.Tolerance = 0.1
        obj.StepOver = 20
        obj.LiftDistance = 0
        # obj.ProcessHoles = True
        obj.ForceInsideOut = False
        obj.FinishingProfile = True
        obj.Stopped = False
        obj.StopProcessing = False
        obj.HelixAngle = 5
        obj.HelixConeAngle = 0
        obj.HelixDiameterLimit = 0.0
        obj.AdaptiveInputState = ""
        obj.AdaptiveOutputState = ""
        obj.StockToLeave = 0
        obj.ZStockToLeave = 0
        obj.KeepToolDownRatio = 3.0
        obj.UseHelixArcs = False
        obj.UseOutline = False
        obj.OrderCutsByRegion = False
        obj.ModelAwareExperiment = False
        FeatureExtensions.set_default_property_values(obj, job)

    def opExecute(self, obj):
        """opExecute(obj) ... called whenever the receiver needs to be recalculated.
        See documentation of execute() for a list of base functionality provided.
        Should be overwritten by subclasses."""

        obj.setEditorMode("OrderCutsByRegion", 0 if obj.ModelAwareExperiment else 2)
        obj.setEditorMode("ZStockToLeave", 0 if obj.ModelAwareExperiment else 2)

        if obj.ModelAwareExperiment:
            # Contains both geometry to machine and the applicable depths
            # NOTE: Reminder that stock is formatted differently than inside/outside!
            inside, outside, stock = _getWorkingEdgesModelAware(self, obj)

            self.insidePathArray = inside
            self.outsidePathArray = outside
            self.stockPathArray = stock

            ExecuteModelAware(self, obj)
        else:
            self.pathArray = _get_working_edges(self, obj)
            Execute(self, obj)

    def opOnDocumentRestored(self, obj):
        if not hasattr(obj, "HelixConeAngle"):
            obj.addProperty(
                "App::PropertyAngle",
                "HelixConeAngle",
                "Adaptive",
                "Helix cone angle (degrees)",
            )

        if not hasattr(obj, "UseOutline"):
            obj.addProperty(
                "App::PropertyBool",
                "UseOutline",
                "Adaptive",
                "Uses the outline of the base geometry.",
            )

        if not hasattr(obj, "OrderCutsByRegion"):
            obj.addProperty(
                "App::PropertyBool",
                "OrderCutsByRegion",
                "Adaptive",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Orders cuts by region instead of depth.",
                ),
            )

        if not hasattr(obj, "ZStockToLeave"):
            obj.addProperty(
                "App::PropertyDistance",
                "ZStockToLeave",
                "Adaptive",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "How much stock to leave along the Z axis (eg for finishing operation)",
                ),
            )

        if not hasattr(obj, "ModelAwareExperiment"):
            obj.addProperty(
                "App::PropertyBool",
                "ModelAwareExperiment",
                "Adaptive",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "Enable the experimental model awareness feature to respect 3D geometry and prevent cutting under overhangs",
                ),
            )
        obj.setEditorMode("OrderCutsByRegion", 0 if obj.ModelAwareExperiment else 2)
        obj.setEditorMode("ZStockToLeave", 0 if obj.ModelAwareExperiment else 2)

        if not hasattr(obj, "removalshape"):
            obj.addProperty("Part::PropertyPartShape", "removalshape", "Path", "")
        obj.setEditorMode("removalshape", 2)  # hide

        FeatureExtensions.initialize_properties(obj)


def SetupProperties():
    setup = [
        "Side",
        "OperationType",
        "Tolerance",
        "StepOver",
        "LiftDistance",
        "KeepToolDownRatio",
        "StockToLeave",
        "ZStockToLeave",
        "ForceInsideOut",
        "FinishingProfile",
        "Stopped",
        "StopProcessing",
        "UseHelixArcs",
        "AdaptiveInputState",
        "AdaptiveOutputState",
        "HelixAngle",
        "HelixConeAngle",
        "HelixDiameterLimit",
        "UseOutline",
        "OrderCutsByRegion",
    ]
    return setup


def Create(name, obj=None, parentJob=None):
    """Create(name) ... Creates and returns a Adaptive operation."""
    if obj is None:
        obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name)
    obj.Proxy = PathAdaptive(obj, name, parentJob)
    return obj
