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

# ***************************************************************************
# *   Copyright (c) 2017 sliptonic <shopinthewoods@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                                                                   *
# *                                                                         *
# ***************************************************************************


from PySide import QtCore, QtGui
from collections import Counter
from contextlib import contextmanager
from pivy import coin
import FreeCAD
import FreeCADGui
import Path
import Path.Base.Gui.SetupSheet as PathSetupSheetGui
import Path.Base.Util as PathUtil
import Path.GuiInit as PathGuiInit
import Path.Main.Gui.JobCmd as PathJobCmd
import Path.Main.Gui.JobDlg as PathJobDlg
import Path.Main.Job as PathJob
import Path.Main.Stock as PathStock
import Path.Tool.Gui.Controller as PathToolControllerGui
import PathScripts.PathUtils as PathUtils
from Path.Tool.toolbit.ui.selector import ToolBitSelector
import math
import traceback
from PySide import QtWidgets

import MatGui
import Materials

# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader

Draft = LazyLoader("Draft", globals(), "Draft")
Part = LazyLoader("Part", globals(), "Part")
DraftVecUtils = LazyLoader("DraftVecUtils", globals(), "DraftVecUtils")

translate = FreeCAD.Qt.translate

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


def _OpenCloseResourceEditor(obj, vobj, edit):
    job = PathUtils.findParentJob(obj)
    if job and job.ViewObject and job.ViewObject.Proxy:
        if edit:
            job.ViewObject.Proxy.editObject(obj)
        else:
            job.ViewObject.Proxy.uneditObject(obj)
    else:
        missing = "Job"
        if job:
            missing = "ViewObject"
            if job.ViewObject:
                missing = "Proxy"
        Path.Log.warning("Cannot edit %s - no %s" % (obj.Label, missing))


@contextmanager
def selectionEx():
    sel = FreeCADGui.Selection.getSelectionEx()
    try:
        yield sel
    finally:
        FreeCADGui.Selection.clearSelection()
        for s in sel:
            if s.SubElementNames:
                FreeCADGui.Selection.addSelection(s.Object, s.SubElementNames)
            else:
                FreeCADGui.Selection.addSelection(s.Object)


class ViewProvider:
    def __init__(self, vobj):
        mode = 2
        vobj.setEditorMode("BoundingBox", mode)
        vobj.setEditorMode("DisplayMode", mode)
        vobj.setEditorMode("Selectable", mode)
        vobj.setEditorMode("ShapeAppearance", mode)
        vobj.setEditorMode("Transparency", mode)
        self.deleteOnReject = True

        # initialized later
        self.axs = None
        self.mat = None
        self.obj = None
        self.sca = None
        self.scs = None
        self.sep = None
        self.sph = None
        self.switch = None
        self.taskPanel = None
        self.vobj = None
        self.baseVisibility = {}
        self.stockVisibility = False

    def attach(self, vobj):
        self.vobj = vobj
        self.obj = vobj.Object
        self.taskPanel = None
        if not hasattr(self, "baseVisibility"):
            self.baseVisibility = {}
        if not hasattr(self, "stockVisibility"):
            self.stockVisibility = False

        # Setup the axis display at the origin
        self.switch = coin.SoSwitch()
        self.sep = coin.SoType.fromName("So3DAnnotation").createInstance()

        self.axs = coin.SoType.fromName("SoFCPlacementIndicatorKit").createInstance()
        self.axs.axisLength.setValue(1.2)

        # enum values for SoFCPlacementIndicatorKit
        parts = FreeCADGui.PlacementIndicatorParts
        self.axs.parts.setValue(
            parts.Axes | parts.Labels | parts.ArrowHeads | parts.OriginIndicator
        )

        self.sep.addChild(self.axs)
        self.switch.addChild(self.sep)

        vobj.RootNode.addChild(self.switch)
        self.showOriginAxis(True)

        for base in self.obj.Model.Group:
            Path.Log.debug(f"{base.Name}: {base.ViewObject.Visibility}")

    def onChanged(self, vobj, prop):
        if prop == "Visibility":
            self.showOriginAxis(vobj.Visibility)

            # if we're currently restoring the document we do NOT want to call
            # hideXXX as this would mark all currently hidden children as
            # explicitly hidden by the user and prevent showing them when
            # showing the job

            if self.obj.Document.Restoring:
                return

            if vobj.Visibility:
                self.restoreOperationsVisibility()
                self.restoreModelsVisibility()
                self.restoreStockVisibility()
                self.restoreToolsVisibility()
            else:
                self.hideOperations()
                self.hideModels()
                self.hideStock()
                self.hideTools()

    def hideOperations(self):
        self.operationsVisibility = {}
        for op in self.obj.Operations.Group:
            self.operationsVisibility[op.Name] = op.Visibility
            op.Visibility = False

    def restoreOperationsVisibility(self):
        if hasattr(self, "operationsVisibility"):
            for op in self.obj.Operations.Group:
                if self.operationsVisibility.get(op.Name, True):
                    op.Visibility = True
        else:
            for op in self.obj.Operations.Group:
                op.Visibility = True

    def hideModels(self):
        self.modelsVisibility = {}
        for model in self.obj.Model.Group:
            self.modelsVisibility[model.Name] = model.Visibility
            model.Visibility = False

    def restoreModelsVisibility(self):
        if hasattr(self, "modelsVisibility"):
            for model in self.obj.Model.Group:
                if self.modelsVisibility.get(model.Name, True):
                    model.Visibility = True
        else:
            for model in self.obj.Model.Group:
                model.Visibility = True

    def hideStock(self):
        self.stockVisibility = self.obj.Stock.Visibility
        self.obj.Stock.Visibility = False

    def restoreStockVisibility(self):
        if hasattr(self, "stockVisibility"):
            if self.stockVisibility:
                self.obj.Stock.Visibility = True

    def hideTools(self):
        self.toolsVisibility = {}
        for tc in self.obj.Tools.Group:
            self.toolsVisibility[tc.Tool.Name] = tc.Tool.Visibility
            tc.Tool.Visibility = False

    def restoreToolsVisibility(self):
        if hasattr(self, "toolsVisibility"):
            for tc in self.obj.Tools.Group:
                if self.toolsVisibility.get(tc.Tool.Name, True):
                    tc.Tool.Visibility = True

    def showOriginAxis(self, yes):
        sw = coin.SO_SWITCH_ALL if yes else coin.SO_SWITCH_NONE
        self.switch.whichChild = sw

    def dumps(self):
        return None

    def loads(self, state):
        return None

    def deleteObjectsOnReject(self):
        return hasattr(self, "deleteOnReject") and self.deleteOnReject

    def setEdit(self, vobj=None, mode=0):
        Path.Log.track(mode)
        if 0 == mode:
            job = self.vobj.Object
            if not job.Proxy.integrityCheck(job):
                return False
            self.openTaskPanel()
        return True

    def openTaskPanel(self, activate=None):
        self.taskPanel = TaskPanel(self.vobj, self.deleteObjectsOnReject())
        FreeCADGui.Control.closeDialog()
        FreeCADGui.Control.showDialog(self.taskPanel)
        self.taskPanel.setupUi(activate)
        self.showOriginAxis(True)
        self.deleteOnReject = False

    def resetTaskPanel(self):
        self.showOriginAxis(False)
        self.taskPanel = None

    def unsetEdit(self, arg1, arg2):
        if self.taskPanel:
            self.taskPanel.reject(False)

    def editObject(self, obj):
        if obj:
            if obj in self.obj.Model.Group:
                return self.openTaskPanel("Model")
            if obj == self.obj.Stock:
                return self.openTaskPanel("Stock")
            Path.Log.info("Expected a specific object to edit - %s not recognized" % obj.Label)
        return self.openTaskPanel()

    def uneditObject(self, obj=None):
        self.unsetEdit(None, None)

    def getIcon(self):
        return ":/icons/CAM_Job.svg"

    def claimChildren(self):
        children = []
        if hasattr(self.obj, "Operations"):
            children.append(self.obj.Operations)
        if hasattr(self.obj, "Model"):
            # unfortunately this function is called before the object has been fully loaded
            # which means we could be dealing with an old job which doesn't have the new Model
            # yet.
            children.append(self.obj.Model)
        if hasattr(self.obj, "Stock"):
            children.append(self.obj.Stock)
        if hasattr(self.obj, "SetupSheet"):
            # when loading a job that didn't have a setup sheet they might not've been created yet
            children.append(self.obj.SetupSheet)
        if hasattr(self.obj, "Tools"):
            children.append(self.obj.Tools)
        return children

    def onDelete(self, vobj, arg2=None):
        Path.Log.track(vobj.Object.Label, arg2)
        self.obj.Proxy.onDelete(self.obj, arg2)
        return True

    def updateData(self, obj, prop):
        Path.Log.track(obj.Label, prop)
        # make sure the resource view providers are setup properly
        if prop == "Model" and self.obj.Model:
            for base in self.obj.Model.Group:
                if base.ViewObject and hasattr(base.ViewObject, "Proxy") and base.ViewObject.Proxy:
                    base.ViewObject.Proxy.onEdit(_OpenCloseResourceEditor)
        if (
            prop == "Stock"
            and self.obj.Stock
            and self.obj.Stock.ViewObject
            and self.obj.Stock.ViewObject.Proxy
        ):
            self.obj.Stock.ViewObject.Proxy.onEdit(_OpenCloseResourceEditor)

    def rememberBaseVisibility(self, obj, base):
        Path.Log.track()
        if base.ViewObject:
            orig = PathUtil.getPublicObject(obj.Proxy.baseObject(obj, base))
            self.baseVisibility[base.Name] = (
                base,
                base.ViewObject.Visibility,
                orig,
                orig.ViewObject.Visibility,
            )
            orig.ViewObject.Visibility = False
            base.ViewObject.Visibility = True

    def forgetBaseVisibility(self, obj, base):
        Path.Log.track()
        # if self.baseVisibility.get(base.Name):
        #     visibility = self.baseVisibility[base.Name]
        #     visibility[0].ViewObject.Visibility = visibility[1]
        #     visibility[2].ViewObject.Visibility = visibility[3]
        #     del self.baseVisibility[base.Name]

    def setupEditVisibility(self, obj):
        Path.Log.track()
        self.baseVisibility = {}
        for base in obj.Model.Group:
            self.rememberBaseVisibility(obj, base)

        self.stockVisibility = False
        if obj.Stock and obj.Stock.ViewObject:
            self.stockVisibility = obj.Stock.ViewObject.Visibility
            self.obj.Stock.ViewObject.Visibility = True

    def resetEditVisibility(self, obj):
        Path.Log.track()
        for base in obj.Model.Group:
            self.forgetBaseVisibility(obj, base)
        if obj.Stock and obj.Stock.ViewObject:
            obj.Stock.ViewObject.Visibility = self.stockVisibility

    def setupContextMenu(self, vobj, menu):
        Path.Log.track()
        for action in menu.actions():
            menu.removeAction(action)
        action = QtGui.QAction(translate("CAM_Job", "Edit"), menu)
        action.triggered.connect(self._editInContextMenuTriggered)
        menu.addAction(action)

    def _editInContextMenuTriggered(self, checked):
        self.setEdit()


class MaterialDialog(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super(MaterialDialog, self).__init__(parent)

        self.setWindowTitle("Assign Material")

        self.materialTree = FreeCADGui.UiLoader().createWidget("MatGui::MaterialTreeWidget")
        self.materialTreeWidget = MatGui.MaterialTreeWidget(self.materialTree)

        material_filter = Materials.MaterialFilter()
        material_filter.Name = "Machining Materials"
        material_filter.RequiredModels = [Materials.UUIDs().Machinability]
        self.materialTreeWidget.setFilter(material_filter)
        self.materialTreeWidget.selectFilter("Machining Materials")

        # Create OK and Cancel buttons
        self.okButton = QtWidgets.QPushButton("OK")
        self.cancelButton = QtWidgets.QPushButton("Cancel")

        # Connect buttons to their actions
        self.okButton.clicked.connect(self.accept)
        self.cancelButton.clicked.connect(self.reject)

        # Layout setup
        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(self.materialTree)

        buttonLayout = QtWidgets.QHBoxLayout()
        buttonLayout.addStretch()
        buttonLayout.addWidget(self.okButton)
        buttonLayout.addWidget(self.cancelButton)

        layout.addLayout(buttonLayout)
        self.setLayout(layout)
        self.materialTree.onMaterial.connect(self.onMaterial)

    def onMaterial(self, uuid):
        try:
            print("Selected '{0}'".format(uuid))
            self.uuid = uuid
        except Exception as e:
            print(e)


class StockEdit(object):
    Index = -1
    StockType = PathStock.StockType.Unknown

    def __init__(self, obj, form, force):
        Path.Log.track(obj.Label, force)
        self.obj = obj
        self.form = form
        self.force = force
        self.setupUi(obj)

    @classmethod
    def IsStock(cls, obj):
        return PathStock.StockType.FromStock(obj.Stock) == cls.StockType

    def activate(self, obj, select=False):
        Path.Log.track(obj.Label, select)

        def showHide(widget, activeWidget):
            if widget == activeWidget:
                widget.show()
            else:
                widget.hide()

        if select:
            self.form.stock.setCurrentIndex(self.Index)
        editor = self.editorFrame()
        showHide(self.form.stockFromExisting, editor)
        showHide(self.form.stockFromBase, editor)
        showHide(self.form.stockCreateBox, editor)
        showHide(self.form.stockCreateCylinder, editor)
        self.setFields(obj)

    def setStock(self, obj, stock):
        Path.Log.track(obj.Label, stock)
        if obj.Stock:
            Path.Log.track(obj.Stock.Name)
            obj.Document.removeObject(obj.Stock.Name)
        Path.Log.track(stock.Name)
        obj.Stock = stock
        if stock.ViewObject and stock.ViewObject.Proxy:
            stock.ViewObject.Proxy.onEdit(_OpenCloseResourceEditor)

    def setLengthField(self, widget, prop):
        widget.setText(FreeCAD.Units.Quantity(prop.Value, FreeCAD.Units.Length).UserString)

    # the following members must be overwritten by subclasses
    def editorFrame(self):
        return None

    def setFields(self, obj):
        pass

    def setupUi(self, obj):
        pass


class StockFromBaseBoundBoxEdit(StockEdit):
    Index = 2
    StockType = PathStock.StockType.FromBase

    def __init__(self, obj, form, force):
        super(StockFromBaseBoundBoxEdit, self).__init__(obj, form, force)

        self.trackXpos = None
        self.trackYpos = None
        self.trackZpos = None

    def editorFrame(self):
        Path.Log.track()
        return self.form.stockFromBase

    def getFieldsStock(self, stock, fields=None):
        if fields is None:
            fields = ["xneg", "xpos", "yneg", "ypos", "zneg", "zpos"]
        try:
            if "xneg" in fields:
                stock.ExtXneg = FreeCAD.Units.Quantity(self.form.stockExtXneg.text())
            if "xpos" in fields:
                stock.ExtXpos = FreeCAD.Units.Quantity(self.form.stockExtXpos.text())
            if "yneg" in fields:
                stock.ExtYneg = FreeCAD.Units.Quantity(self.form.stockExtYneg.text())
            if "ypos" in fields:
                stock.ExtYpos = FreeCAD.Units.Quantity(self.form.stockExtYpos.text())
            if "zneg" in fields:
                stock.ExtZneg = FreeCAD.Units.Quantity(self.form.stockExtZneg.text())
            if "zpos" in fields:
                stock.ExtZpos = FreeCAD.Units.Quantity(self.form.stockExtZpos.text())
        except Exception:
            pass

    def getFields(self, obj, fields=None):
        if fields is None:
            fields = ["xneg", "xpos", "yneg", "ypos", "zneg", "zpos"]
        Path.Log.track(obj.Label, fields)
        if self.IsStock(obj):
            self.getFieldsStock(obj.Stock, fields)
        else:
            Path.Log.error("Stock not from Base bound box!")

    def setFields(self, obj):
        Path.Log.track()
        if self.force or not self.IsStock(obj):
            Path.Log.track()
            stock = PathStock.CreateFromBase(obj)
            if self.force and self.editorFrame().isVisible():
                self.getFieldsStock(stock)
            self.setStock(obj, stock)
            self.force = False
        self.setLengthField(self.form.stockExtXneg, obj.Stock.ExtXneg)
        self.setLengthField(self.form.stockExtXpos, obj.Stock.ExtXpos)
        self.setLengthField(self.form.stockExtYneg, obj.Stock.ExtYneg)
        self.setLengthField(self.form.stockExtYpos, obj.Stock.ExtYpos)
        self.setLengthField(self.form.stockExtZneg, obj.Stock.ExtZneg)
        self.setLengthField(self.form.stockExtZpos, obj.Stock.ExtZpos)

    def setupUi(self, obj):
        Path.Log.track()
        self.setFields(obj)
        self.checkXpos()
        self.checkYpos()
        self.checkZpos()
        self.form.stockExtXneg.textChanged.connect(self.updateXpos)
        self.form.stockExtYneg.textChanged.connect(self.updateYpos)
        self.form.stockExtZneg.textChanged.connect(self.updateZpos)
        self.form.stockExtXpos.textChanged.connect(self.checkXpos)
        self.form.stockExtYpos.textChanged.connect(self.checkYpos)
        self.form.stockExtZpos.textChanged.connect(self.checkZpos)
        if hasattr(self.form, "linkStockAndModel"):
            self.form.linkStockAndModel.setChecked(False)

    def checkXpos(self):
        self.trackXpos = self.form.stockExtXneg.text() == self.form.stockExtXpos.text()
        self.getFields(self.obj, ["xpos"])

    def checkYpos(self):
        self.trackYpos = self.form.stockExtYneg.text() == self.form.stockExtYpos.text()
        self.getFields(self.obj, ["ypos"])

    def checkZpos(self):
        self.trackZpos = self.form.stockExtZneg.text() == self.form.stockExtZpos.text()
        self.getFields(self.obj, ["zpos"])

    def updateXpos(self):
        fields = ["xneg"]
        if self.trackXpos:
            self.form.stockExtXpos.setText(self.form.stockExtXneg.text())
            fields.append("xpos")
        self.getFields(self.obj, fields)

    def updateYpos(self):
        fields = ["yneg"]
        if self.trackYpos:
            self.form.stockExtYpos.setText(self.form.stockExtYneg.text())
            fields.append("ypos")
        self.getFields(self.obj, fields)

    def updateZpos(self):
        fields = ["zneg"]
        if self.trackZpos:
            self.form.stockExtZpos.setText(self.form.stockExtZneg.text())
            fields.append("zpos")
        self.getFields(self.obj, fields)


class StockCreateBoxEdit(StockEdit):
    Index = 0
    StockType = PathStock.StockType.CreateBox

    def editorFrame(self):
        return self.form.stockCreateBox

    def getFields(self, obj, fields=None):
        if fields is None:
            fields = ["length", "width", "height"]
        try:
            if self.IsStock(obj):
                if "length" in fields:
                    obj.Stock.Length = FreeCAD.Units.Quantity(self.form.stockBoxLength.text())
                if "width" in fields:
                    obj.Stock.Width = FreeCAD.Units.Quantity(self.form.stockBoxWidth.text())
                if "height" in fields:
                    obj.Stock.Height = FreeCAD.Units.Quantity(self.form.stockBoxHeight.text())
            else:
                Path.Log.error("Stock not a box!")
        except Exception:
            pass

    def setFields(self, obj):
        if self.force or not self.IsStock(obj):
            self.setStock(obj, PathStock.CreateBox(obj))
            self.force = False
        self.setLengthField(self.form.stockBoxLength, obj.Stock.Length)
        self.setLengthField(self.form.stockBoxWidth, obj.Stock.Width)
        self.setLengthField(self.form.stockBoxHeight, obj.Stock.Height)

    def setupUi(self, obj):
        self.setFields(obj)
        self.form.stockBoxLength.textChanged.connect(lambda: self.getFields(obj, ["length"]))
        self.form.stockBoxWidth.textChanged.connect(lambda: self.getFields(obj, ["width"]))
        self.form.stockBoxHeight.textChanged.connect(lambda: self.getFields(obj, ["height"]))


class StockCreateCylinderEdit(StockEdit):
    Index = 1
    StockType = PathStock.StockType.CreateCylinder

    def editorFrame(self):
        return self.form.stockCreateCylinder

    def getFields(self, obj, fields=None):
        if fields is None:
            fields = ["radius", "height"]
        try:
            if self.IsStock(obj):
                if "radius" in fields:
                    obj.Stock.Radius = FreeCAD.Units.Quantity(self.form.stockCylinderRadius.text())
                if "height" in fields:
                    obj.Stock.Height = FreeCAD.Units.Quantity(self.form.stockCylinderHeight.text())
            else:
                Path.Log.error(translate("CAM_Job", "Stock not a cylinder!"))
        except Exception:
            pass

    def setFields(self, obj):
        if self.force or not self.IsStock(obj):
            self.setStock(obj, PathStock.CreateCylinder(obj))
            self.force = False
        self.setLengthField(self.form.stockCylinderRadius, obj.Stock.Radius)
        self.setLengthField(self.form.stockCylinderHeight, obj.Stock.Height)

    def setupUi(self, obj):
        self.setFields(obj)
        self.form.stockCylinderRadius.textChanged.connect(lambda: self.getFields(obj, ["radius"]))
        self.form.stockCylinderHeight.textChanged.connect(lambda: self.getFields(obj, ["height"]))


class StockFromExistingEdit(StockEdit):
    Index = 3
    StockType = PathStock.StockType.Unknown
    StockLabelPrefix = "Stock"

    def editorFrame(self):
        return self.form.stockFromExisting

    def getFields(self, obj):
        stock = self.form.stockExisting.itemData(self.form.stockExisting.currentIndex())
        if not (
            hasattr(obj.Stock, "Objects")
            and len(obj.Stock.Objects) == 1
            and obj.Stock.Objects[0] == stock
        ):
            if stock:
                stock = PathJob.createResourceClone(obj, stock, self.StockLabelPrefix, "Stock")
                stock.ViewObject.Visibility = True
                PathStock.SetupStockObject(stock, PathStock.StockType.Unknown)
                stock.Proxy.execute(stock)
                self.setStock(obj, stock)

    def candidates(self, obj):
        solids = [o for o in obj.Document.Objects if PathUtil.isSolid(o)]
        if hasattr(obj, "Model"):
            job = obj
        else:
            job = PathUtils.findParentJob(obj)
        for base in job.Model.Group:
            if base in solids and PathJob.isResourceClone(job, base, "Model"):
                solids.remove(base)
        if job.Stock in solids:
            # regardless, what stock is/was, it's not a valid choice
            solids.remove(job.Stock)
        excludeIndexes = []
        for index, model in enumerate(solids):
            if [ob.Name for ob in model.InListRecursive if "Tools" in ob.Name]:
                excludeIndexes.append(index)
            elif hasattr(model, "PathResource"):
                excludeIndexes.append(index)
            elif model.InList and hasattr(model.InList[0], "ToolBitID"):
                excludeIndexes.append(index)
            elif hasattr(model, "ToolBitID"):
                excludeIndexes.append(index)
            elif model.TypeId == "App::DocumentObjectGroup":
                excludeIndexes.append(index)
            elif hasattr(model, "StockType"):
                excludeIndexes.append(index)
            elif not model.ViewObject.ShowInTree:
                excludeIndexes.append(index)

        for i in sorted(excludeIndexes, reverse=True):
            del solids[i]

        return sorted(solids, key=lambda c: c.Label)

    def setFields(self, obj):
        # Block signal propagation during stock dropdown population. This prevents
        # the `currentIndexChanged` signal from being emitted while populating the
        # dropdown list. This is important because the `currentIndexChanged` signal
        # will in the end result in the stock object being recreated in `getFields`
        # method, discarding any changes made (like position in respect to origin).
        try:
            self.form.stockExisting.blockSignals(True)
            self.form.stockExisting.clear()
            stockName = obj.Stock.Label if obj.Stock else None
            index = -1
            for i, solid in enumerate(self.candidates(obj)):
                self.form.stockExisting.addItem(solid.Label, solid)
                label = "{}-{}".format(self.StockLabelPrefix, solid.Label)
                if label == stockName:
                    index = i

            self.form.stockExisting.setCurrentIndex(index if index != -1 else 0)
        finally:
            self.form.stockExisting.blockSignals(False)

        if not self.IsStock(obj):
            self.getFields(obj)

    def setupUi(self, obj):
        self.setFields(obj)
        self.form.stockExisting.currentIndexChanged.connect(lambda: self.getFields(obj))


class TaskPanel:
    DataObject = QtCore.Qt.ItemDataRole.UserRole
    DataProperty = QtCore.Qt.ItemDataRole.UserRole + 1

    def __init__(self, vobj, deleteOnReject):
        FreeCAD.ActiveDocument.openTransaction("Edit Job")
        self.vobj = vobj
        self.vproxy = vobj.Proxy
        self.obj = vobj.Object
        self.deleteOnReject = deleteOnReject
        self.form = FreeCADGui.PySideUic.loadUi(":/panels/PathEdit.ui")
        self.template = PathJobDlg.JobTemplateExport(self.obj, self.form.jobBox.widget(1))
        self.name = self.obj.Name

        vUnit = FreeCAD.Units.Quantity(1, FreeCAD.Units.Velocity).getUserPreferred()[2]
        self.form.toolControllerList.horizontalHeaderItem(1).setText("#")
        self.form.toolControllerList.horizontalHeaderItem(2).setText(
            translate("Path", "H", "H is horizontal feed rate. Must be as short as possible")
        )
        self.form.toolControllerList.horizontalHeaderItem(3).setText(
            translate("Path", "V", "V is vertical feed rate. Must be as short as possible")
        )
        self.form.toolControllerList.horizontalHeader().setResizeMode(0, QtGui.QHeaderView.Stretch)
        self.form.toolControllerList.horizontalHeaderItem(1).setToolTip(
            translate("Path", "Tool number") + " "
        )
        self.form.toolControllerList.horizontalHeaderItem(2).setToolTip(
            translate("Path", "Horizontal feedrate") + " " + vUnit
        )
        self.form.toolControllerList.horizontalHeaderItem(3).setToolTip(
            translate("Path", "Vertical feedrate") + " " + vUnit
        )
        self.form.toolControllerList.horizontalHeaderItem(4).setToolTip(
            translate("Path", "Spindle RPM") + " "
        )

        # ensure correct ellisis behaviour on tool controller names.
        self.form.toolControllerList.setWordWrap(False)

        self.form.toolControllerList.resizeColumnsToContents()

        currentPostProcessor = self.obj.PostProcessor
        postProcessors = Path.Preferences.allEnabledPostProcessors(["", currentPostProcessor])
        for post in postProcessors:
            self.form.postProcessor.addItem(post)
        # update the enumeration values, just to make sure all selections are valid
        self.obj.PostProcessor = postProcessors
        self.obj.PostProcessor = currentPostProcessor

        self.postProcessorDefaultTooltip = self.form.postProcessor.toolTip()
        self.postProcessorArgsDefaultTooltip = self.form.postProcessorArguments.toolTip()

        # Populate the other comboboxes with enums from the job class
        comboToPropertyMap = [("orderBy", "OrderOutputBy")]
        enumTups = PathJob.ObjectJob.propertyEnumerations(dataType="raw")
        self.populateCombobox(self.form, enumTups, comboToPropertyMap)

        self.vproxy.setupEditVisibility(self.obj)

        self.stockFromBase = None
        self.stockFromExisting = None
        self.stockCreateBox = None
        self.stockCreateCylinder = None
        self.stockEdit = None

        self.setupGlobal = PathSetupSheetGui.GlobalEditor(self.obj.SetupSheet, self.form)
        self.setupOps = PathSetupSheetGui.OpsDefaultEditor(self.obj.SetupSheet, self.form)

    def populateCombobox(self, form, enumTups, comboBoxesPropertyMap):
        """populateCombobox(form, enumTups, comboBoxesPropertyMap) ... populate comboboxes with translated enumerations
        ** comboBoxesPropertyMap will be unnecessary if UI files use strict combobox naming protocol.
        Args:
            form = UI form
            enumTups = list of (translated_text, data_string) tuples
            comboBoxesPropertyMap = list of (translated_text, data_string) tuples
        """
        # Load appropriate enumerations in each combobox
        for cb, prop in comboBoxesPropertyMap:
            box = getattr(form, cb)  # Get the combobox
            box.clear()  # clear the combobox
            for text, data in enumTups[prop]:  #  load enumerations
                box.addItem(text, data)

    def assignMaterial(self):
        dialog = MaterialDialog()
        result = dialog.exec_()

        if result == QtWidgets.QDialog.Accepted:
            FreeCAD.Console.PrintMessage("Material assigned\n")
            # Add code to handle the material assignment

            if dialog.uuid is not None:
                material_manager = Materials.MaterialManager()
                material = material_manager.getMaterial(dialog.uuid)
                self.obj.Stock.ShapeMaterial = material

    def preCleanup(self):
        Path.Log.track()
        FreeCADGui.Selection.removeObserver(self)
        self.vproxy.resetEditVisibility(self.obj)
        self.vproxy.resetTaskPanel()

    def accept(self, resetEdit=True):
        Path.Log.track()
        self._jobIntegrityCheck()  # Check existence of Model and Tools
        self.preCleanup()
        self.getFields()
        self.setupGlobal.accept()
        self.setupOps.accept()
        FreeCAD.ActiveDocument.commitTransaction()
        self.cleanup(resetEdit)

    def reject(self, resetEdit=True):
        Path.Log.track()
        self.preCleanup()
        self.setupGlobal.reject()
        self.setupOps.reject()
        FreeCAD.ActiveDocument.abortTransaction()
        if self.deleteOnReject and FreeCAD.ActiveDocument.getObject(self.name):
            Path.Log.info("Uncreate Job")
            FreeCAD.ActiveDocument.openTransaction("Uncreate Job")
            if self.obj.ViewObject.Proxy.onDelete(self.obj.ViewObject, None):
                FreeCAD.ActiveDocument.removeObject(self.obj.Name)
            FreeCAD.ActiveDocument.commitTransaction()
        else:
            Path.Log.track(
                self.name,
                self.deleteOnReject,
                FreeCAD.ActiveDocument.getObject(self.name),
            )
        self.cleanup(resetEdit)
        return True

    def cleanup(self, resetEdit):
        Path.Log.track()
        FreeCADGui.Control.closeDialog()
        if resetEdit:
            FreeCADGui.ActiveDocument.resetEdit()
        FreeCAD.ActiveDocument.recompute()

    def updateTooltips(self):
        if (
            hasattr(self.obj, "Proxy")
            and hasattr(self.obj.Proxy, "tooltip")
            and self.obj.Proxy.tooltip
        ):
            self.form.postProcessor.setToolTip(self.obj.Proxy.tooltip)
            if hasattr(self.obj.Proxy, "tooltipArgs") and self.obj.Proxy.tooltipArgs:
                self.form.postProcessorArguments.setToolTip(self.obj.Proxy.tooltipArgs)
            else:
                self.form.postProcessorArguments.setToolTip(self.postProcessorArgsDefaultTooltip)
        else:
            self.form.postProcessor.setToolTip(self.postProcessorDefaultTooltip)
            self.form.postProcessorArguments.setToolTip(self.postProcessorArgsDefaultTooltip)

    def getFields(self):
        """sets properties in the object to match the form"""
        if self.obj:
            self.obj.PostProcessor = str(self.form.postProcessor.currentText())
            self.obj.PostProcessorArgs = str(self.form.postProcessorArguments.displayText())
            self.obj.PostProcessorOutputFile = str(self.form.postProcessorOutputFile.text())

            self.obj.Label = str(self.form.jobLabel.text())
            self.obj.Description = str(self.form.jobDescription.toPlainText())
            self.obj.Operations.Group = [
                self.form.operationsList.item(i).data(self.DataObject)
                for i in range(self.form.operationsList.count())
            ]
            try:
                self.obj.SplitOutput = self.form.splitOutput.isChecked()
                self.obj.OrderOutputBy = str(self.form.orderBy.currentData())

                flist = []
                for i in range(self.form.wcslist.count()):
                    if self.form.wcslist.item(i).checkState() == QtCore.Qt.CheckState.Checked:
                        flist.append(self.form.wcslist.item(i).text())
                self.obj.Fixtures = flist
            except Exception as e:
                Path.Log.debug(e)
                FreeCAD.Console.PrintWarning(
                    "The Job was created without fixture support.  Please delete and recreate the job\r\n"
                )

            self.updateTooltips()
            self.stockEdit.getFields(self.obj)

            self.obj.Proxy.execute(self.obj)

        self.setupGlobal.getFields()
        self.setupOps.getFields()

    def selectComboBoxText(self, widget, text):
        """selectInComboBox(name, combo) ...
        helper function to select a specific value in a combo box."""
        index = widget.currentIndex()  # Save initial index

        # Search using currentData and return if found
        newindex = widget.findData(text)
        if newindex >= 0:

            widget.blockSignals(True)
            widget.setCurrentIndex(newindex)
            widget.blockSignals(False)
            return

        # if not found, search using current text
        newindex = widget.findText(text, QtCore.Qt.MatchFixedString)
        if newindex >= 0:
            widget.blockSignals(True)
            widget.setCurrentIndex(newindex)
            widget.blockSignals(False)
            return

        widget.blockSignals(True)
        widget.setCurrentIndex(index)
        widget.blockSignals(False)
        return

    def updateToolController(self):
        tcRow = self.form.toolControllerList.currentRow()
        tcCol = self.form.toolControllerList.currentColumn()

        self.form.toolControllerList.blockSignals(True)
        self.form.toolControllerList.clearContents()
        self.form.toolControllerList.setRowCount(0)

        self.form.activeToolController.blockSignals(True)
        index = self.form.activeToolController.currentIndex()
        select = None if index == -1 else self.form.activeToolController.itemData(index)
        self.form.activeToolController.clear()

        vUnit = FreeCAD.Units.Quantity(1, FreeCAD.Units.Velocity).getUserPreferred()[2]

        for row, tc in enumerate(sorted(self.obj.Tools.Group, key=lambda tc: tc.Label)):
            self.form.activeToolController.addItem(tc.Label, tc)
            if tc == select:
                index = row

            self.form.toolControllerList.insertRow(row)

            item = QtGui.QTableWidgetItem(tc.Label)
            item.setData(self.DataObject, tc)
            item.setData(self.DataProperty, "Label")
            self.form.toolControllerList.setItem(row, 0, item)

            item = QtGui.QTableWidgetItem("%d" % tc.ToolNumber)
            item.setTextAlignment(QtCore.Qt.AlignRight)
            item.setData(self.DataObject, tc)
            item.setData(self.DataProperty, "Number")
            self.form.toolControllerList.setItem(row, 1, item)

            item = QtGui.QTableWidgetItem("%g" % tc.HorizFeed.getValueAs(vUnit))
            item.setTextAlignment(QtCore.Qt.AlignRight)
            item.setData(self.DataObject, tc)
            item.setData(self.DataProperty, "HorizFeed")
            self.form.toolControllerList.setItem(row, 2, item)

            item = QtGui.QTableWidgetItem("%g" % tc.VertFeed.getValueAs(vUnit))
            item.setTextAlignment(QtCore.Qt.AlignRight)
            item.setData(self.DataObject, tc)
            item.setData(self.DataProperty, "VertFeed")
            self.form.toolControllerList.setItem(row, 3, item)

            item = QtGui.QTableWidgetItem(
                "%s%g" % ("+" if tc.SpindleDir == "Forward" else "-", tc.SpindleSpeed)
            )
            item.setTextAlignment(QtCore.Qt.AlignRight)
            item.setData(self.DataObject, tc)
            item.setData(self.DataProperty, "Spindle")
            self.form.toolControllerList.setItem(row, 4, item)

        if index != -1:
            self.form.activeToolController.setCurrentIndex(index)
        if tcRow != -1 and tcCol != -1:
            self.form.toolControllerList.setCurrentCell(tcRow, tcCol)

        self.form.activeToolController.blockSignals(False)
        self.form.toolControllerList.blockSignals(False)

    def setFields(self):
        """sets fields in the form to match the object"""

        self.form.jobLabel.setText(self.obj.Label)
        self.form.jobDescription.setPlainText(self.obj.Description)

        if hasattr(self.obj, "SplitOutput"):
            self.form.splitOutput.setChecked(self.obj.SplitOutput)
        if hasattr(self.obj, "OrderOutputBy"):
            self.selectComboBoxText(self.form.orderBy, self.obj.OrderOutputBy)

        if hasattr(self.obj, "Fixtures"):
            for f in self.obj.Fixtures:
                item = self.form.wcslist.findItems(f, QtCore.Qt.MatchExactly)[0]
                item.setCheckState(QtCore.Qt.Checked)

        self.form.postProcessorOutputFile.setText(self.obj.PostProcessorOutputFile)
        self.selectComboBoxText(self.form.postProcessor, self.obj.PostProcessor)
        self.form.postProcessorArguments.setText(self.obj.PostProcessorArgs)
        # self.obj.Proxy.onChanged(self.obj, "PostProcessor")
        self.updateTooltips()

        self.form.operationsList.clear()
        for child in self.obj.Operations.Group:
            item = QtGui.QListWidgetItem(child.Label)
            item.setData(self.DataObject, child)
            self.form.operationsList.addItem(item)

        self.form.jobModel.clear()
        for name, count in Counter(
            [self.obj.Proxy.baseObject(self.obj, o).Label for o in self.obj.Model.Group]
        ).items():
            if count == 1:
                self.form.jobModel.addItem(name)
            else:
                self.form.jobModel.addItem("%s (%d)" % (name, count))

        self.updateToolController()
        self.stockEdit.setFields(self.obj)
        self.setupGlobal.setFields()
        self.setupOps.setFields()

    def setPostProcessorOutputFile(self):
        filename = QtGui.QFileDialog.getSaveFileName(
            self.form,
            translate("CAM_Job", "Select Output File"),
            None,
            translate("CAM_Job", "All Files (*.*)"),
        )
        if filename and filename[0]:
            self.obj.PostProcessorOutputFile = str(filename[0])
            self.setFields()

    def operationSelect(self):
        if self.form.operationsList.selectedItems():
            self.form.operationModify.setEnabled(True)
            self.form.operationMove.setEnabled(True)
            row = self.form.operationsList.currentRow()
            self.form.operationUp.setEnabled(row > 0)
            self.form.operationDown.setEnabled(row < self.form.operationsList.count() - 1)
        else:
            self.form.operationModify.setEnabled(False)
            self.form.operationMove.setEnabled(False)

    def objectDelete(self, widget):
        for item in widget.selectedItems():
            obj = item.data(self.DataObject)
            if (
                obj.ViewObject
                and hasattr(obj.ViewObject, "Proxy")
                and hasattr(obj.ViewObject.Proxy, "onDelete")
            ):
                obj.ViewObject.Proxy.onDelete(obj.ViewObject, None)
            FreeCAD.ActiveDocument.removeObject(obj.Name)
        self.setFields()

    def operationDelete(self):
        self.objectDelete(self.form.operationsList)

    def operationMoveUp(self):
        row = self.form.operationsList.currentRow()
        if row > 0:
            item = self.form.operationsList.takeItem(row)
            self.form.operationsList.insertItem(row - 1, item)
            self.form.operationsList.setCurrentRow(row - 1)
            self.getFields()

    def operationMoveDown(self):
        row = self.form.operationsList.currentRow()
        if row < self.form.operationsList.count() - 1:
            item = self.form.operationsList.takeItem(row)
            self.form.operationsList.insertItem(row + 1, item)
            self.form.operationsList.setCurrentRow(row + 1)
            self.getFields()

    def toolControllerSelect(self):
        def canDeleteTC(tc):
            # if the TC is referenced anywhere but the job we don't want to delete it
            return len(tc.InList) == 1

        # if anything is selected it can be edited
        edit = True if self.form.toolControllerList.selectedItems() else False
        self.form.toolControllerEdit.setEnabled(edit)

        # can only delete what is selected
        delete = edit
        # ... but we want to make sure there's at least one TC left
        if len(self.obj.Tools.Group) == len(self.form.toolControllerList.selectedItems()):
            delete = False
        # ... also don't want to delete any TCs that are already used
        if delete:
            for item in self.form.toolControllerList.selectedItems():
                if not canDeleteTC(item.data(self.DataObject)):
                    delete = False
                    break
        self.form.toolControllerDelete.setEnabled(delete)

    def toolControllerEdit(self):
        for item in self.form.toolControllerList.selectedItems():
            tc = item.data(self.DataObject)
            dlg = PathToolControllerGui.DlgToolControllerEdit(tc)
            dlg.exec_()
        self.setFields()
        self.toolControllerSelect()

    def toolControllerAdd(self):
        selector = ToolBitSelector(compact=True)
        if not selector.exec_():
            return
        toolbit = selector.get_selected_tool()
        toolbit.attach_to_doc(FreeCAD.ActiveDocument)
        toolNum = self.obj.Proxy.nextToolNumber()
        tc = PathToolControllerGui.Create(
            name=f"TC: {toolbit.label}", tool=toolbit.obj, toolNumber=toolNum
        )
        self.obj.Proxy.addToolController(tc)

        FreeCAD.ActiveDocument.recompute()
        self.updateToolController()

    def toolControllerDelete(self):
        self.objectDelete(self.form.toolControllerList)

    def toolControllerChanged(self, item):
        tc = item.data(self.DataObject)
        prop = item.data(self.DataProperty)
        if "Label" == prop:
            tc.Label = item.text()
            item.setText(tc.Label)
        elif "Number" == prop:
            try:
                tc.ToolNumber = int(item.text())
            except Exception:
                pass
            item.setText("%d" % tc.ToolNumber)
        elif "Spindle" == prop:
            try:
                speed = float(item.text())
                rot = "Forward"
                if speed < 0:
                    rot = "Reverse"
                    speed = -speed
                tc.SpindleDir = rot
                tc.SpindleSpeed = speed
            except Exception:
                pass
            item.setText("%s%g" % ("+" if tc.SpindleDir == "Forward" else "-", tc.SpindleSpeed))
        elif "HorizFeed" == prop or "VertFeed" == prop:
            vUnit = FreeCAD.Units.Quantity(1, FreeCAD.Units.Velocity).getUserPreferred()[2]
            try:
                val = FreeCAD.Units.Quantity(item.text())
                if FreeCAD.Units.Velocity == val.Unit:
                    setattr(tc, prop, val)
                elif FreeCAD.Units.Unit() == val.Unit:
                    val = FreeCAD.Units.Quantity(item.text() + vUnit)
                    setattr(tc, prop, val)
            except Exception:
                pass
            item.setText("%g" % getattr(tc, prop).getValueAs(vUnit))
        else:
            try:
                val = FreeCAD.Units.Quantity(item.text())
                setattr(tc, prop, val)
            except Exception:
                pass
            item.setText("%g" % getattr(tc, prop).Value)

        self.template.updateUI()

    def modelSetAxis(self, axis):
        Path.Log.track(axis)

        def alignSel(sel, normal, flip=False):
            Path.Log.track("Vector(%.2f, %.2f, %.2f)" % (normal.x, normal.y, normal.z), flip)
            v = axis
            if flip:
                v = axis.negative()

            if Path.Geom.pointsCoincide(abs(v), abs(normal)):
                # Selection is already aligned with the axis of rotation leading
                # to a (0,0,0) cross product for rotation.
                # --> Need to flip the object around one of the "other" axis.
                # Simplest way to achieve that is to rotate the coordinate system
                # of the axis and use that to rotate the object.
                r = FreeCAD.Vector(v.y, v.z, v.x)
                a = 180
            else:
                r = v.cross(normal)  # rotation axis
                a = DraftVecUtils.angle(normal, v, r) * 180 / math.pi
            Path.Log.debug(
                "oh boy: (%.2f, %.2f, %.2f) x (%.2f, %.2f, %.2f) -> (%.2f, %.2f, %.2f) -> %.2f"
                % (v.x, v.y, v.z, normal.x, normal.y, normal.z, r.x, r.y, r.z, a)
            )
            Draft.rotate(sel.Object, a, axis=r)

        selObject = None
        selFeature = None
        with selectionEx() as selection:
            for sel in selection:
                selObject = sel.Object
                for feature in sel.SubElementNames:
                    selFeature = feature
                    Path.Log.track(selObject.Label, feature)
                    sub = sel.Object.Shape.getElement(feature)

                    if "Face" == sub.ShapeType:
                        normal = sub.normalAt(0, 0)
                        if sub.Orientation == "Reversed":
                            normal = FreeCAD.Vector() - normal
                            Path.Log.debug(
                                "(%.2f, %.2f, %.2f) -> reversed (%s)"
                                % (normal.x, normal.y, normal.z, sub.Orientation)
                            )
                        else:
                            Path.Log.debug(
                                "(%.2f, %.2f, %.2f) -> forward  (%s)"
                                % (normal.x, normal.y, normal.z, sub.Orientation)
                            )

                        if Path.Geom.pointsCoincide(axis, normal):
                            alignSel(sel, normal, True)
                        elif Path.Geom.pointsCoincide(axis, FreeCAD.Vector() - normal):
                            alignSel(sel, FreeCAD.Vector() - normal, True)
                        else:
                            alignSel(sel, normal)

                    elif "Edge" == sub.ShapeType:
                        normal = (sub.Vertexes[1].Point - sub.Vertexes[0].Point).normalize()
                        if Path.Geom.pointsCoincide(axis, normal) or Path.Geom.pointsCoincide(
                            axis, FreeCAD.Vector() - normal
                        ):
                            # Don't really know the orientation of an edge, so let's just flip the object
                            # and if the user doesn't like it they can flip again
                            alignSel(sel, normal, True)
                        else:
                            alignSel(sel, normal)

                    else:
                        Path.Log.track(sub.ShapeType)

        if selObject and selFeature:
            FreeCADGui.Selection.clearSelection()
            FreeCADGui.Selection.addSelection(selObject, selFeature)

    def restoreSelection(self, selection):
        FreeCADGui.Selection.clearSelection()
        for sel in selection:
            FreeCADGui.Selection.addSelection(sel.Object, sel.SubElementNames)

    def modelSet0(self, axis):
        Path.Log.track(axis)
        with selectionEx() as selection:
            for sel in selection:
                selObject = sel.Object
                Path.Log.track(selObject.Label)
                for name in sel.SubElementNames:
                    Path.Log.track(selObject.Label, name)
                    feature = selObject.Shape.getElement(name)
                    bb = feature.BoundBox
                    offset = FreeCAD.Vector(axis.x * bb.XMax, axis.y * bb.YMax, axis.z * bb.ZMax)
                    Path.Log.track(feature.BoundBox.ZMax, offset)
                    p = selObject.Placement
                    p.move(offset)
                    selObject.Placement = p

                    if self.form.linkStockAndModel.isChecked():
                        # Also move the objects not selected
                        # if selection is not model, move the model too
                        # if the selection is not stock and there is a stock, move the stock too
                        for model in self.obj.Model.Group:
                            if model != selObject:
                                Draft.move(model, offset)
                            if selObject != self.obj.Stock and self.obj.Stock:
                                Draft.move(self.obj.Stock, offset)

    def modelMove(self, axis):
        scale = self.form.modelMoveValue.value()
        with selectionEx() as selection:
            for sel in selection:
                offset = axis * scale
                Draft.move(sel.Object, offset)

    def modelRotate(self, axis):
        angle = self.form.modelRotateValue.value()
        with selectionEx() as selection:
            if self.form.modelRotateCompound.isChecked() and len(selection) > 1:
                bb = PathStock.shapeBoundBox([sel.Object for sel in selection])
                for sel in selection:
                    Draft.rotate(sel.Object, angle, bb.Center, axis)
            else:
                for sel in selection:
                    Draft.rotate(sel.Object, angle, sel.Object.Shape.BoundBox.Center, axis)

    def alignSetOrigin(self):
        (obj, by) = self.alignMoveToOrigin()

        for base in self.obj.Model.Group:
            if base != obj:
                Draft.move(base, by)

        if obj != self.obj.Stock and self.obj.Stock:
            Draft.move(self.obj.Stock, by)

        placement = FreeCADGui.ActiveDocument.ActiveView.viewPosition()
        placement.Base = placement.Base + by
        FreeCADGui.ActiveDocument.ActiveView.viewPosition(placement, 0)

    def alignMoveToOrigin(self):
        selObject = None
        selFeature = None
        p = None
        for sel in FreeCADGui.Selection.getSelectionEx():
            selObject = sel.Object
            for feature in sel.SubElementNames:
                selFeature = feature
                sub = sel.Object.Shape.getElement(feature)
                if "Vertex" == sub.ShapeType:
                    p = FreeCAD.Vector() - sub.Point
                if "Edge" == sub.ShapeType:
                    p = FreeCAD.Vector() - sub.Curve.Location
                if "Face" == sub.ShapeType:
                    p = FreeCAD.Vector() - sub.BoundBox.Center

                if p:
                    Draft.move(sel.Object, p)

        if selObject and selFeature:
            FreeCADGui.Selection.clearSelection()
            FreeCADGui.Selection.addSelection(selObject, selFeature)
        return (selObject, p)

    def updateStockEditor(self, index, force=False):
        def setupFromBaseEdit():
            Path.Log.track(index, force)
            if force or not self.stockFromBase:
                self.stockFromBase = StockFromBaseBoundBoxEdit(self.obj, self.form, force)
            self.stockEdit = self.stockFromBase

        def setupCreateBoxEdit():
            Path.Log.track(index, force)
            if force or not self.stockCreateBox:
                self.stockCreateBox = StockCreateBoxEdit(self.obj, self.form, force)
            self.stockEdit = self.stockCreateBox

        def setupCreateCylinderEdit():
            Path.Log.track(index, force)
            if force or not self.stockCreateCylinder:
                self.stockCreateCylinder = StockCreateCylinderEdit(self.obj, self.form, force)
            self.stockEdit = self.stockCreateCylinder

        def setupFromExisting():
            Path.Log.track(index, force)
            if force or not self.stockFromExisting:
                self.stockFromExisting = StockFromExistingEdit(self.obj, self.form, force)
            if self.stockFromExisting.candidates(self.obj):
                self.stockEdit = self.stockFromExisting
                return True
            return False

        if index == -1:
            if self.obj.Stock is None or StockFromBaseBoundBoxEdit.IsStock(self.obj):
                setupFromBaseEdit()
            elif StockCreateBoxEdit.IsStock(self.obj):
                setupCreateBoxEdit()
            elif StockCreateCylinderEdit.IsStock(self.obj):
                setupCreateCylinderEdit()
            elif StockFromExistingEdit.IsStock(self.obj):
                setupFromExisting()
            else:
                Path.Log.error(
                    translate("CAM_Job", "Unsupported stock object %s") % self.obj.Stock.Label
                )
        else:
            if index == StockFromBaseBoundBoxEdit.Index:
                setupFromBaseEdit()
            elif index == StockCreateBoxEdit.Index:
                setupCreateBoxEdit()
            elif index == StockCreateCylinderEdit.Index:
                setupCreateCylinderEdit()
            elif index == StockFromExistingEdit.Index:
                if not setupFromExisting():
                    setupFromBaseEdit()
                    index = -1
            else:
                Path.Log.error(
                    translate("CAM_Job", "Unsupported stock type %s (%d)")
                    % (self.form.stock.currentText(), index)
                )
        self.stockEdit.activate(self.obj, index == -1)

        if -1 != index:
            self.template.updateUI()

    def refreshStock(self):
        self.updateStockEditor(self.form.stock.currentIndex(), True)

    def alignCenterInStock(self):
        bbs = self.obj.Stock.Shape.BoundBox
        for sel in FreeCADGui.Selection.getSelectionEx():
            bbb = sel.Object.Shape.BoundBox
            by = bbs.Center - bbb.Center
            Draft.move(sel.Object, by)

    def alignCenterInStockXY(self):
        bbs = self.obj.Stock.Shape.BoundBox
        for sel in FreeCADGui.Selection.getSelectionEx():
            bbb = sel.Object.Shape.BoundBox
            by = bbs.Center - bbb.Center
            by.z = 0
            Draft.move(sel.Object, by)

    def isValidDatumSelection(self, sel):
        if sel.ShapeType in ["Vertex", "Edge", "Face"]:
            if hasattr(sel, "Curve") and not isinstance(sel.Curve, Part.Circle):
                return False
            return True

        # no valid selection
        return False

    def isValidAxisSelection(self, sel):
        if sel.ShapeType in ["Vertex", "Edge", "Face"]:
            if hasattr(sel, "Curve") and isinstance(sel.Curve, Part.Circle):
                return False
            if hasattr(sel, "Surface") and sel.Surface.curvature(0, 0, "Max") != 0:
                return False
            return True

        # no valid selection
        return False

    def updateSelection(self):
        # Remove Job object if present in Selection: source of phantom paths
        if self.obj in FreeCADGui.Selection.getSelection():
            FreeCADGui.Selection.removeSelection(self.obj)

        sel = FreeCADGui.Selection.getSelectionEx()

        self.form.setOrigin.setEnabled(False)
        self.form.moveToOrigin.setEnabled(False)
        self.form.modelSetXAxis.setEnabled(False)
        self.form.modelSetYAxis.setEnabled(False)
        self.form.modelSetZAxis.setEnabled(False)

        if len(sel) == 1 and len(sel[0].SubObjects) == 1:
            subObj = sel[0].SubObjects[0]
            if self.isValidDatumSelection(subObj):
                self.form.setOrigin.setEnabled(True)
                self.form.moveToOrigin.setEnabled(True)
            if self.isValidAxisSelection(subObj):
                self.form.modelSetXAxis.setEnabled(True)
                self.form.modelSetYAxis.setEnabled(True)
                self.form.modelSetZAxis.setEnabled(True)

        if len(sel) == 0 or self.obj.Stock in [s.Object for s in sel]:
            self.form.centerInStock.setEnabled(False)
            self.form.centerInStockXY.setEnabled(False)
        else:
            self.form.centerInStock.setEnabled(True)
            self.form.centerInStockXY.setEnabled(True)

        if len(sel) > 0:
            self.form.modelSetX0.setEnabled(True)
            self.form.modelSetY0.setEnabled(True)
            self.form.modelSetZ0.setEnabled(True)
            self.form.modelMoveGroup.setEnabled(True)
            self.form.modelRotateGroup.setEnabled(True)
            self.form.modelRotateCompound.setEnabled(len(sel) > 1)
        else:
            self.form.modelSetX0.setEnabled(False)
            self.form.modelSetY0.setEnabled(False)
            self.form.modelSetZ0.setEnabled(False)
            self.form.modelMoveGroup.setEnabled(False)
            self.form.modelRotateGroup.setEnabled(False)

    def jobModelEdit(self):
        dialog = PathJobDlg.JobCreate()
        dialog.setupTitle(translate("CAM_Job", "Model Selection"))
        dialog.setupModel(self.obj)
        if dialog.exec_() == 1:
            models = dialog.getModels()
            if models:
                obj = self.obj
                proxy = obj.Proxy

                want = Counter(models)
                have = Counter([proxy.baseObject(obj, o) for o in obj.Model.Group])

                obsolete = have - want
                additions = want - have

                # first remove all obsolete base models
                for model, count in obsolete.items():
                    for i in range(count):
                        # it seems natural to remove the last of all the base objects for a given model
                        base = [b for b in obj.Model.Group if proxy.baseObject(obj, b) == model][-1]
                        self.vproxy.forgetBaseVisibility(obj, base)
                        self.obj.Proxy.removeBase(obj, base, True)
                # do not access any of the retired objects after this point, they don't exist anymore

                # then add all rookie base models
                for model, count in additions.items():
                    for i in range(count):
                        base = PathJob.createModelResourceClone(obj, model)
                        obj.Model.addObject(base)
                        self.vproxy.rememberBaseVisibility(obj, base)

                # refresh the view
                if obsolete or additions:
                    self.setFields()
                else:
                    Path.Log.track("no changes to model")

    def tabPageChanged(self, index):
        if index == 0:
            # update the template with potential changes
            self.getFields()
            self.setupGlobal.accept()
            self.setupOps.accept()
            self.obj.Document.recompute()
            self.template.updateUI()

    def setupUi(self, activate):
        self.setupGlobal.setupUi()
        try:
            self.setupOps.setupUi()
        except Exception as ee:
            Path.Log.error(str(ee))
        self.updateStockEditor(-1, False)
        self.setFields()

        # Info
        self.form.jobLabel.editingFinished.connect(self.getFields)
        self.form.jobModelEdit.clicked.connect(self.jobModelEdit)

        # Post Processor
        self.form.postProcessor.currentIndexChanged.connect(self.getFields)
        self.form.postProcessorArguments.editingFinished.connect(self.getFields)
        self.form.postProcessorOutputFile.editingFinished.connect(self.getFields)
        self.form.postProcessorSetOutputFile.clicked.connect(self.setPostProcessorOutputFile)

        # Workplan
        self.form.operationsList.itemSelectionChanged.connect(self.operationSelect)
        self.form.operationsList.indexesMoved.connect(self.getFields)
        self.form.operationDelete.clicked.connect(self.operationDelete)
        self.form.operationUp.clicked.connect(self.operationMoveUp)
        self.form.operationDown.clicked.connect(self.operationMoveDown)

        self.form.operationEdit.hide()  # not supported yet
        self.form.activeToolGroup.hide()  # not supported yet

        # Tool controller
        self.form.toolControllerList.itemSelectionChanged.connect(self.toolControllerSelect)
        self.form.toolControllerList.itemChanged.connect(self.toolControllerChanged)
        self.form.toolControllerEdit.clicked.connect(self.toolControllerEdit)
        self.form.toolControllerDelete.clicked.connect(self.toolControllerDelete)
        self.form.toolControllerAdd.clicked.connect(self.toolControllerAdd)

        self.operationSelect()
        self.toolControllerSelect()

        # Stock, Orientation and Alignment
        self.form.btnMaterial.clicked.connect(self.assignMaterial)
        self.form.centerInStock.clicked.connect(self.alignCenterInStock)
        self.form.centerInStockXY.clicked.connect(self.alignCenterInStockXY)

        self.form.stock.currentIndexChanged.connect(self.updateStockEditor)
        self.form.refreshStock.clicked.connect(self.refreshStock)

        self.form.modelSetXAxis.clicked.connect(lambda: self.modelSetAxis(FreeCAD.Vector(1, 0, 0)))
        self.form.modelSetYAxis.clicked.connect(lambda: self.modelSetAxis(FreeCAD.Vector(0, 1, 0)))
        self.form.modelSetZAxis.clicked.connect(lambda: self.modelSetAxis(FreeCAD.Vector(0, 0, 1)))
        self.form.modelSetX0.clicked.connect(lambda: self.modelSet0(FreeCAD.Vector(-1, 0, 0)))
        self.form.modelSetY0.clicked.connect(lambda: self.modelSet0(FreeCAD.Vector(0, -1, 0)))
        self.form.modelSetZ0.clicked.connect(lambda: self.modelSet0(FreeCAD.Vector(0, 0, -1)))

        self.form.setOrigin.clicked.connect(self.alignSetOrigin)
        self.form.moveToOrigin.clicked.connect(self.alignMoveToOrigin)

        self.form.modelMoveLeftUp.clicked.connect(lambda: self.modelMove(FreeCAD.Vector(-1, 1, 0)))
        self.form.modelMoveLeft.clicked.connect(lambda: self.modelMove(FreeCAD.Vector(-1, 0, 0)))
        self.form.modelMoveLeftDown.clicked.connect(
            lambda: self.modelMove(FreeCAD.Vector(-1, -1, 0))
        )

        self.form.modelMoveUp.clicked.connect(lambda: self.modelMove(FreeCAD.Vector(0, 1, 0)))
        self.form.modelMoveDown.clicked.connect(lambda: self.modelMove(FreeCAD.Vector(0, -1, 0)))

        self.form.modelMoveRightUp.clicked.connect(lambda: self.modelMove(FreeCAD.Vector(1, 1, 0)))
        self.form.modelMoveRight.clicked.connect(lambda: self.modelMove(FreeCAD.Vector(1, 0, 0)))
        self.form.modelMoveRightDown.clicked.connect(
            lambda: self.modelMove(FreeCAD.Vector(1, -1, 0))
        )

        self.form.modelRotateLeft.clicked.connect(lambda: self.modelRotate(FreeCAD.Vector(0, 0, 1)))
        self.form.modelRotateRight.clicked.connect(
            lambda: self.modelRotate(FreeCAD.Vector(0, 0, -1))
        )

        self.updateSelection()

        # set active page
        if activate in ["Layout", "Stock"]:
            self.form.setCurrentIndex(0)
        if activate in ["General", "Model"]:
            self.form.setCurrentIndex(1)
        if activate in ["Output", "Post Processor"]:
            self.form.setCurrentIndex(2)
        if activate in ["Tools", "Tool Controller"]:
            self.form.setCurrentIndex(3)
        if activate in ["Workplan", "Operations"]:
            self.form.setCurrentIndex(4)

        self.form.currentChanged.connect(self.tabPageChanged)
        self.template.exportButton().clicked.connect(self.templateExport)

    def templateExport(self):
        self.getFields()
        PathJobCmd.CommandJobTemplateExport.SaveDialog(self.obj, self.template)

    def open(self):
        FreeCADGui.Selection.addObserver(self)

    def _jobIntegrityCheck(self):
        """_jobIntegrityCheck() ... Check Job object for existence of Model and Tools
        If either Model or Tools is empty, change GUI tab, issue appropriate warning,
        and offer chance to add appropriate item."""

        def _displayWarningWindow(msg):
            """Display window with warning message and Add action button.
            Return action state."""
            txtHeader = translate("CAM_Job", "Warning")
            txtPleaseAddOne = translate("CAM_Job", "Please add one.")
            txtOk = translate("CAM_Job", "Ok")
            txtAdd = translate("CAM_Job", "Add")

            msgbox = QtGui.QMessageBox(
                QtGui.QMessageBox.Warning, txtHeader, msg + " " + txtPleaseAddOne
            )
            msgbox.addButton(txtOk, QtGui.QMessageBox.AcceptRole)  # Add 'Ok' button
            msgbox.addButton(txtAdd, QtGui.QMessageBox.ActionRole)  # Add 'Add' button
            return msgbox.exec_()

        # Check if at least on base model is present
        if len(self.obj.Model.Group) == 0:
            self.form.setCurrentIndex(1)  # Change tab to General tab
            no_model_txt = translate("CAM_Job", "This job has no base model.")
            if _displayWarningWindow(no_model_txt) == 1:
                self.jobModelEdit()

        # Check if at least one tool is present
        if len(self.obj.Tools.Group) == 0:
            self.form.setCurrentIndex(3)  # Change tab to Tools tab
            no_tool_txt = translate("CAM_Job", "This job has no tool.")
            if _displayWarningWindow(no_tool_txt) == 1:
                self.toolControllerAdd()

    # SelectionObserver interface
    def addSelection(self, doc, obj, sub, pnt):
        self.updateSelection()

    def removeSelection(self, doc, obj, sub):
        self.updateSelection()

    def setSelection(self, doc):
        self.updateSelection()

    def clearSelection(self, doc):
        self.updateSelection()


def Create(base, template=None, openTaskPanel=True):
    """Create(base, template) ... creates a job instance for the given base object
    using template to configure it."""
    FreeCADGui.addModule("Path.Main.Job")
    FreeCAD.ActiveDocument.openTransaction("Create Job")
    try:
        obj = PathJob.Create("Job", base, template)
        obj.ViewObject.Proxy = ViewProvider(obj.ViewObject)
        obj.ViewObject.addExtension("Gui::ViewProviderGroupExtensionPython")
        FreeCAD.ActiveDocument.commitTransaction()
        obj.Document.recompute()
        if openTaskPanel:
            obj.ViewObject.Proxy.editObject(obj.Stock)
        else:
            obj.ViewObject.Proxy.deleteOnReject = False
        return obj
    except Exception as exc:
        Path.Log.error(exc)
        traceback.print_exc()
        FreeCAD.ActiveDocument.abortTransaction()


# make sure the UI has been initialized
PathGuiInit.Startup()
