# ***************************************************************************
# *   Copyright (c) 2009, 2010 Ken Cline <cline@frii.com>                   *
# *   Copyright (c) 2023 FreeCAD Project Association                        *
# *                                                                         *
# *   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                                                                   *
# *                                                                         *
# ***************************************************************************
"""Provide the working plane code and utilities for the Draft Workbench.

This module provides the plane class which provides a virtual working plane
in FreeCAD and a couple of utility functions.
The working plane is mostly intended to be used in the Draft Workbench
to draw 2D objects in various orientations, not only in the standard XY,
YZ, and XZ planes.
"""
## @package WorkingPlane
#  \ingroup DRAFT
#  \brief This module handles the Working Plane and grid of the Draft module.
#
#  This module provides the plane class which provides a virtual working plane
#  in FreeCAD and a couple of utility functions.

import math
import lazy_loader.lazy_loader as lz
from PySide.QtCore import QT_TRANSLATE_NOOP

import FreeCAD
import DraftVecUtils
from FreeCAD import Vector
from draftutils import gui_utils
from draftutils import params
from draftutils import utils
from draftutils.messages import _wrn
from draftutils.translate import translate

DraftGeomUtils = lz.LazyLoader("DraftGeomUtils", globals(), "DraftGeomUtils")
Part = lz.LazyLoader("Part", globals(), "Part")
FreeCADGui = lz.LazyLoader("FreeCADGui", globals(), "FreeCADGui")

__title__ = "FreeCAD Working Plane utility"
__author__ = "Ken Cline"
__url__ = "https://www.freecad.org"


class PlaneBase:
    """PlaneBase is the base class for the Plane class and the PlaneGui class.

    Parameters
    ----------
    u: Base.Vector or WorkingPlane.PlaneBase, optional
        Defaults to Vector(1, 0, 0).
        If a WP is provided:
            A copy of the WP is created, all other parameters are then ignored.
        If a vector is provided:
            Unit vector for the `u` attribute (+X axis).

    v: Base.Vector, optional
        Defaults to Vector(0, 1, 0).
        Unit vector for the `v` attribute (+Y axis).

    w: Base.Vector, optional
        Defaults to Vector(0, 0, 1).
        Unit vector for the `axis` attribute (+Z axis).

    pos: Base.Vector, optional
        Defaults to Vector(0, 0, 0).
        Vector for the `position` attribute (origin).

    Note that the u, v and w vectors are not checked for validity.
    """

    def __init__(self,
                 u=Vector(1, 0, 0), v=Vector(0, 1, 0), w=Vector(0, 0, 1),
                 pos=Vector(0, 0, 0)):

        if isinstance(u, PlaneBase):
            self.match(u)
            return
        self.u = Vector(u)
        self.v = Vector(v)
        self.axis = Vector(w)
        self.position = Vector(pos)

    def __repr__(self):
        text = "Workplane"
        text += " x=" + str(DraftVecUtils.rounded(self.u))
        text += " y=" + str(DraftVecUtils.rounded(self.v))
        text += " z=" + str(DraftVecUtils.rounded(self.axis))
        text += " pos=" + str(DraftVecUtils.rounded(self.position))
        return text

    def copy(self):
        """Return a new WP that is a copy of the present object."""
        wp = PlaneBase()
        self.match(source=self, target=wp)
        return wp

    def _copy_value(self, val):
        """Return a copy of a value, primarily required for vectors."""
        return val.__class__(val)

    def match(self, source, target=None):
        """Match the main properties of two working planes.

        Parameters
        ----------
        source: WP object
            WP to copy properties from.
        target: WP object, optional
            Defaults to `None`.
            WP to copy properties to. If `None` the present object is used.
        """
        if target is None:
            target = self
        for prop in self._get_prop_list():
            setattr(target, prop, self._copy_value(getattr(source, prop)))

    def get_parameters(self):
        """Return a data dictionary with the main properties of the WP."""
        data = {}
        for prop in self._get_prop_list():
            data[prop] = self._copy_value(getattr(self, prop))
        return data

    def set_parameters(self, data):
        """Set the main properties of the WP according to a data dictionary."""
        for prop in self._get_prop_list():
            setattr(self, prop, self._copy_value(data[prop]))

    def align_to_3_points(self, p1, p2, p3, offset=0):
        """Align the WP to 3 points with an optional offset.

        The points must define a plane.

        Parameters
        ----------
        p1: Base.Vector
            New WP `position`.
        p2: Base.Vector
            Point on the +X axis. (p2 - p1) defines the WP `u` axis.
        p3: Base.Vector
            Defines the plane.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.

        Returns
        -------
        `True`/`False`
            `True` if successful.
        """
        return self.align_to_edge_or_wire(Part.makePolygon([p1, p2, p3]), offset)

    def align_to_edges_vertexes(self, shapes, offset=0):
        """Align the WP to the endpoints of edges and/or the points of vertexes
        with an optional offset.

        The points must define a plane.

        The first 2 points define the WP `position` and `u` axis.

        Parameters
        ----------
        shapes: iterable
            One or more edges and/or vertexes.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.

        Returns
        -------
        `True`/`False`
            `True` if successful.
        """
        points = [vert.Point for shape in shapes for vert in shape.Vertexes]
        if len(points) < 2:
            return False
        return self.align_to_edge_or_wire(Part.makePolygon(points), offset)

    def align_to_edge_or_wire(self, shape, offset=0):
        """Align the WP to an edge or wire with an optional offset.

        The shape must define a plane.

        If the shape is an edge with a `Center` then that defines the WP
        `position`. The vector between the center and its start point then
        defines the WP `u` axis.

        In other cases the start point of the first edge defines the WP
        `position` and the 1st derivative at that point the WP `u` axis.

        Parameters
        ----------
        shape: Part.Edge or Part.Wire
            Edge or wire.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.

        Returns
        -------
        `True`/`False`
            `True` if successful.
        """
        tol = 1e-7
        plane = shape.findPlane()
        if plane is None:
            return False
        self.axis = plane.Axis
        if shape.ShapeType == "Edge" and hasattr(shape.Curve, "Center"):
            pos = shape.Curve.Center
            vec = shape.Vertexes[0].Point - pos
            if vec.Length > tol:
                self.u = vec
                self.u.normalize()
                self.v = self.axis.cross(self.u)
            else:
                self.u, self.v, _ = self._axes_from_rotation(plane.Rotation)
        elif shape.Edges[0].Length > tol:
            pos = shape.Vertexes[0].Point
            self.u = shape.Edges[0].derivative1At(0)
            self.u.normalize()
            self.v = self.axis.cross(self.u)
        else:
            pos = shape.Vertexes[0].Point
            self.u, self.v, _ = self._axes_from_rotation(plane.Rotation)
        self.position = pos + (self.axis * offset)
        return True

    def align_to_face(self, shape, offset=0):
        """Align the WP to a face with an optional offset.

        The face must be planar.

        The center of gravity of the face defines the WP `position` and the
        normal of the face the WP `axis`. The WP `u` and `v` vectors are
        determined by the DraftGeomUtils.uv_vectors_from_face function.
        See there.

        Parameters
        ----------
        shape: Part.Face
            Face.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.

        Returns
        -------
        `True`/`False`
            `True` if successful.
        """
        if shape.Surface.isPlanar() is False:
            return False
        place = DraftGeomUtils.placement_from_face(shape)
        self.u, self.v, self.axis = self._axes_from_rotation(place.Rotation)
        self.position = place.Base + (self.axis * offset)
        return True

    def align_to_placement(self, place, offset=0):
        """Align the WP to a placement with an optional offset.

        Parameters
        ----------
        place: Base.Placement
            Placement.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.

        Returns
        -------
        `True`
        """
        self.u, self.v, self.axis = self._axes_from_rotation(place.Rotation)
        self.position = place.Base + (self.axis * offset)
        return True

    def align_to_point_and_axis(self, point, axis, offset=0, upvec=Vector(1, 0, 0)):
        """Align the WP to a point and an axis with an optional offset and an
        optional up-vector.

        If the axis and up-vector are parallel the FreeCAD.Rotation algorithm
        will replace the up-vector: Vector(0, 0, 1) is tried first, then
        Vector(0, 1, 0), and finally Vector(1, 0, 0).

        Parameters
        ----------
        point: Base.Vector
            New WP `position`.
        axis: Base.Vector
            New WP `axis`.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.
        upvec: Base.Vector, optional
            Defaults to Vector(1, 0, 0).
            Up-vector.

        Returns
        -------
        `True`
        """
        tol = 1e-7
        if axis.Length < tol:
            return False
        if upvec.Length < tol:
            return False
        axis = Vector(axis).normalize()
        upvec = Vector(upvec).normalize()
        if axis.isEqual(upvec, tol) or axis.isEqual(upvec.negative(), tol):
            upvec = axis
        rot = FreeCAD.Rotation(Vector(), upvec, axis, "ZYX")
        self.u, self.v, _ = self._axes_from_rotation(rot)
        self.axis = axis
        self.position = point + (self.axis * offset)
        return True

    def align_to_point_and_axis_svg(self, point, axis, offset=0):
        """Align the WP to a point and an axis with an optional offset.

        It aligns `u` and `v` based on the magnitude of the components
        of `axis`.

        Parameters
        ----------
        point: Base.Vector
            The new `position` of the plane, adjusted by
            the `offset`.
        axis: Base.Vector
            A vector whose unit vector will be used as the new `axis`
            of the plane.
            The magnitudes of the `x`, `y`, `z` components of the axis
            determine the orientation of `u` and `v` of the plane.
        offset: float, optional
            Defaults to zero. A value which will be used to offset
            the plane in the direction of its `axis`.

        Returns
        -------
        `True`

        Cases
        -----
        The `u` and `v` are always calculated the same

            * `u` is the cross product of the positive or negative of `axis`
              with a `reference vector`.
              ::
                  u = [+1|-1] axis.cross(ref_vec)
            * `v` is `u` rotated 90 degrees around `axis`.

        Whether the `axis` is positive or negative, and which reference
        vector is used, depends on the absolute values of the `x`, `y`, `z`
        components of the `axis` unit vector.

         #. If `x > y`, and `y > z`
             The reference vector is +Z
             ::
                 u = -1 axis.cross(+Z)
         #. If `y > z`, and `z >= x`
             The reference vector is +X.
             ::
                 u = -1 axis.cross(+X)
         #. If `y >= x`, and `x > z`
             The reference vector is +Z.
             ::
                 u = +1 axis.cross(+Z)
         #. If `x > z`, and `z >= y`
             The reference vector is +Y.
             ::
                 u = +1 axis.cross(+Y)
         #. If `z >= y`, and `y > x`
             The reference vector is +X.
             ::
                 u = +1 axis.cross(+X)
         #. otherwise
             The reference vector is +Y.
             ::
                 u = -1 axis.cross(+Y)
        """
        self.axis = Vector(axis).normalize()
        ref_vec = Vector(0.0, 1.0, 0.0)

        if ((abs(axis.x) > abs(axis.y)) and (abs(axis.y) > abs(axis.z))):
            ref_vec = Vector(0.0, 0., 1.0)
            self.u = axis.negative().cross(ref_vec)
            self.u.normalize()
            self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis)
            # projcase = "Case new"

        elif ((abs(axis.y) > abs(axis.z)) and (abs(axis.z) >= abs(axis.x))):
            ref_vec = Vector(1.0, 0.0, 0.0)
            self.u = axis.negative().cross(ref_vec)
            self.u.normalize()
            self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis)
            # projcase = "Y>Z, View Y"

        elif ((abs(axis.y) >= abs(axis.x)) and (abs(axis.x) > abs(axis.z))):
            ref_vec = Vector(0.0, 0., 1.0)
            self.u = axis.cross(ref_vec)
            self.u.normalize()
            self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis)
            # projcase = "ehem. XY, Case XY"

        elif ((abs(axis.x) > abs(axis.z)) and (abs(axis.z) >= abs(axis.y))):
            self.u = axis.cross(ref_vec)
            self.u.normalize()
            self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis)
            # projcase = "X>Z, View X"

        elif ((abs(axis.z) >= abs(axis.y)) and (abs(axis.y) > abs(axis.x))):
            ref_vec = Vector(1.0, 0., 0.0)
            self.u = axis.cross(ref_vec)
            self.u.normalize()
            self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis)
            # projcase = "Y>X, Case YZ"

        else:
            self.u = axis.negative().cross(ref_vec)
            self.u.normalize()
            self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis)
            # projcase = "else"

        # spat_vec = self.u.cross(self.v)
        # spat_res = spat_vec.dot(axis)
        # Console.PrintMessage(projcase + " spat Prod = " + str(spat_res) + "\n")

        offsetVector = Vector(axis)
        offsetVector.multiply(offset)
        self.position = point.add(offsetVector)

        return True

    def get_global_coords(self, point, as_vector=False):
        """Translate a point or vector from the local (WP) coordinate system to
        the global coordinate system.

        Parameters
        ----------
        point: Base.Vector
            Point.
        as_vector: bool, optional
            Defaults to `False`.
            If `True` treat point as a vector.

        Returns
        -------
        Base.Vector
        """
        pos = Vector() if as_vector else self.position
        mtx = FreeCAD.Matrix(self.u, self.v, self.axis, pos)
        return mtx.multVec(point)

    def get_local_coords(self, point, as_vector=False):
        """Translate a point or vector from the global coordinate system to
        the local (WP) coordinate system.

        Parameters
        ----------
        point: Base.Vector
            Point.
        as_vector: bool, optional
            Defaults to `False`.
            If `True` treat point as a vector.

        Returns
        -------
        Base.Vector
        """
        pos = Vector() if as_vector else self.position
        mtx = FreeCAD.Matrix(self.u, self.v, self.axis, pos)
        return mtx.inverse().multVec(point)

    def get_closest_axis(self, vec):
        """Return a string indicating the positive or negative WP axis closest
        to a vector.

        Parameters
        ----------
        vec: Base.Vector
            Vector.

        Returns
        -------
        str
            `"x"`, `"y"` or `"z"`.
        """
        xyz = list(self.get_local_coords(vec, as_vector=True))
        x, y, z = [abs(coord) for coord in xyz]
        if x >= y and x >= z:
            return "x"
        elif y >= x and y >= z:
            return "y"
        else:
            return "z"

    def get_placement(self):
        """Return a placement calculated from the WP."""
        return FreeCAD.Placement(self.position, FreeCAD.Rotation(self.u, self.v, self.axis, "ZYX"))

    def is_global(self):
        """Return `True` if the WP matches the global coordinate system exactly."""
        return self.u == Vector(1, 0, 0) \
            and self.v == Vector(0, 1, 0) \
            and self.axis == Vector(0, 0, 1) \
            and self.position == Vector()

    def is_ortho(self):
        """Return `True` if all WP axes are  parallel to a global axis."""
        rot = FreeCAD.Rotation(self.u, self.v, self.axis, "ZYX")
        ypr = [round(ang, 6) for ang in rot.getYawPitchRoll()]
        return all([ang%90 == 0 for ang in ypr])

    def project_point(self, point, direction=None, force_projection=True):
        """Project a point onto the WP and return the global coordinates of the
        projected point.

        Parameters
        ----------
        point: Base.Vector
            Point to project.
        direction: Base.Vector, optional
            Defaults to `None` in which case the WP `axis` is used.
            Direction of projection.
        force_projection: bool, optional
            Defaults to `True`.
            See DraftGeomUtils.project_point_on_plane

        Returns
        -------
        Base.Vector
        """
        return DraftGeomUtils.project_point_on_plane(point,
                                                     self.position,
                                                     self.axis,
                                                     direction,
                                                     force_projection)

    def set_to_top(self, offset=0):
        """Set the WP to the top position with an optional offset."""
        self.u = Vector(1, 0, 0)
        self.v = Vector(0, 1, 0)
        self.axis = Vector(0, 0, 1)
        self.position = self.axis * offset

    def set_to_front(self, offset=0):
        """Set the WP to the front position with an optional offset."""
        self.u = Vector(1, 0, 0)
        self.v = Vector(0, 0, 1)
        self.axis = Vector(0, -1, 0)
        self.position = self.axis * offset

    def set_to_side(self, offset=0):
        """Set the WP to the right side position with an optional offset."""
        self.u = Vector(0, 1, 0)
        self.v = Vector(0, 0, 1)
        self.axis = Vector(1, 0, 0)
        self.position = self.axis * offset

    def _axes_from_rotation(self, rot):
        """Return a tuple with the `u`, `v` and `axis` vectors from a Base.Rotation."""
        mtx = rot.toMatrix()
        return mtx.col(0), mtx.col(1), mtx.col(2)

    def _axes_from_view_rotation(self, rot):
        """Return a tuple with the `u`, `v` and `axis` vectors from a Base.Rotation
        derived from a view. The Yaw, Pitch and Roll angles are rounded if they are
        near multiples of 45 degrees.
        """
        ypr = [round(ang, 3) for ang in rot.getYawPitchRoll()]
        if all([ang%45 == 0 for ang in ypr]):
            rot.setEulerAngles("YawPitchRoll", *ypr)
        return self._axes_from_rotation(rot)

    def _get_prop_list(self):
        return ["u",
                "v",
                "axis",
                "position"]


class Plane(PlaneBase):
    """The old Plane class.

    Parameters
    ----------
    u: Base.Vector or WorkingPlane.Plane, optional
        Defaults to Vector(1, 0, 0).
        If a WP is provided:
            A copy of the WP is created, all other parameters are then ignored.
        If a vector is provided:
            Unit vector for the `u` attribute (+X axis).

    v: Base.Vector, optional
        Defaults to Vector(0, 1, 0).
        Unit vector for the `v` attribute (+Y axis).

    w: Base.Vector, optional
        Defaults to Vector(0, 0, 1).
        Unit vector for the `axis` attribute (+Z axis).

    pos: Base.Vector, optional
        Defaults to Vector(0, 0, 0).
        Vector for the `position` attribute (origin).

    weak: bool, optional
        Defaults to `True`.
        If `True` the WP is in "Auto" mode and will adapt to the current view.

    Note that the u, v and w vectors are not checked for validity.

    Other attributes
    ----------------
    stored: None/list
        Placeholder for a stored state.
    """

    def __init__(self,
                 u=Vector(1, 0, 0), v=Vector(0, 1, 0), w=Vector(0, 0, 1),
                 pos=Vector(0, 0, 0),
                 weak=True):

        if isinstance(u, Plane):
            self.match(u)
            return
        super().__init__(u, v, w, pos)
        self.weak = weak
        # a placeholder for a stored state
        self.stored = None

    def copy(self):
        """See PlaneBase.copy."""
        wp = Plane()
        self.match(source=self, target=wp)
        return wp

    def offsetToPoint(self, point, direction=None):
        """Return the signed distance from a point to the plane. The direction
        argument is ignored.

        The returned value is the negative of the local Z coordinate of the point.
        """
        return -DraftGeomUtils.distance_to_plane(point, self.position, self.axis)

    def projectPoint(self, point, direction=None, force_projection=True):
        """See PlaneBase.project_point."""
        return super().project_point(point, direction, force_projection)

    def alignToPointAndAxis(self, point, axis, offset=0, upvec=None):
        """See PlaneBase.align_to_point_and_axis."""
        if upvec is None:
            upvec = Vector(1, 0, 0)
        super().align_to_point_and_axis(point, axis, offset, upvec)
        self.weak = False
        return True

    def alignToPointAndAxis_SVG(self, point, axis, offset=0):
        """See PlaneBase.align_to_point_and_axis_svg."""
        super().align_to_point_and_axis_svg(point, axis, offset)
        self.weak = False
        return True

    def alignToCurve(self, shape, offset=0):
        """Align the WP to a curve. NOT IMPLEMENTED.

        Parameters
        ----------
        shape: Part.Shape
            Edge or Wire.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.

        Returns
        -------
        `False`
        """
        return False

    def alignToEdges(self, shapes, offset=0):
        """Align the WP to edges with an optional offset.

        The eges must define a plane.

        The endpoints of the first edge defines the WP `position` and `u` axis.

        Parameters
        ----------
        shapes: iterable
            Two edges.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.

        Returns
        -------
        `True`/`False`
            `True` if successful.
        """
        if super().align_to_edges_vertexes(shapes, offset) is False:
            return False
        self.weak = False
        return True

    def alignToFace(self, shape, offset=0, parent=None):
        """Align the WP to a face with an optional offset.

        The face must be planar.

        The center of gravity of the face defines the WP `position` and the
        normal of the face the WP `axis`. The WP `u` and `v` vectors are
        determined by the DraftGeomUtils.uv_vectors_from_face function.
        See there.

        Parameters
        ----------
        shape: Part.Face
            Face.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.
        parent: object
            Defaults to `None`.
            The ParentGeoFeatureGroup of the object the face belongs to.

        Returns
        -------
        `True`/`False`
            `True` if successful.
        """
        if shape.ShapeType == "Face" and shape.Surface.isPlanar():
            place = DraftGeomUtils.placement_from_face(shape)
            if parent:
                place = parent.getGlobalPlacement() * place
            super().align_to_placement(place, offset)
            self.weak = False
            return True
        else:
            return False

    def alignTo3Points(self, p1, p2, p3, offset=0):
        """Align the plane to a temporary face created from three points.

        Parameters
        ----------
        p1: Base.Vector
            First point.
        p2: Base.Vector
            Second point.
        p3: Base.Vector
            Third point.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`

        Returns
        -------
        `True`/`False`
            `True` if successful.
        """
        w = Part.makePolygon([p1, p2, p3, p1])
        f = Part.Face(w)
        return self.alignToFace(f, offset)

    def alignToSelection(self, offset=0):
        """Align the plane to a selection with an optional offset.

        The selection must define a plane.

        Parameter
        ---------
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`

        Returns
        -------
        `True`/`False`
            `True` if successful.
        """
        sel_ex = FreeCADGui.Selection.getSelectionEx()
        if not sel_ex:
            return False

        shapes = list()
        names = list()
        for obj in sel_ex:
            # check that the geometric property is a Part.Shape object
            geom_is_shape = False
            if isinstance(obj.Object, FreeCAD.GeoFeature):
                geom = obj.Object.getPropertyOfGeometry()
                if isinstance(geom, Part.Shape):
                    geom_is_shape = True
            if not geom_is_shape:
                _wrn(translate(
                    "draft",
                    "Object without Part.Shape geometry:'{}'".format(
                        obj.ObjectName)) + "\n")
                return False
            if geom.isNull():
                _wrn(translate(
                    "draft",
                    "Object with null Part.Shape geometry:'{}'".format(
                        obj.ObjectName)) + "\n")
                return False
            if obj.HasSubObjects:
                shapes.extend(obj.SubObjects)
                names.extend([obj.ObjectName + "." + n for n in obj.SubElementNames])
            else:
                shapes.append(geom)
                names.append(obj.ObjectName)

        normal = None
        for n in range(len(shapes)):
            if not DraftGeomUtils.is_planar(shapes[n]):
                _wrn(translate(
                   "draft", "'{}' object is not planar".format(names[n])) + "\n")
                return False
            if not normal:
                normal = DraftGeomUtils.get_normal(shapes[n])
                shape_ref = n

        # test if all shapes are coplanar
        if normal:
            for n in range(len(shapes)):
                if not DraftGeomUtils.are_coplanar(shapes[shape_ref], shapes[n]):
                    _wrn(translate(
                        "draft", "{} and {} aren't coplanar".format(
                        names[shape_ref],names[n])) + "\n")
                    return False
        else:
            # suppose all geometries are straight lines or points
            points = [vertex.Point for shape in shapes for vertex in shape.Vertexes]
            if len(points) >= 3:
                poly = Part.makePolygon(points)
                if not DraftGeomUtils.is_planar(poly):
                    _wrn(translate(
                        "draft", "All Shapes must be coplanar") + "\n")
                    return False
                normal = DraftGeomUtils.get_normal(poly)
            else:
                normal = None

        if not normal:
            _wrn(translate(
                "draft", "Selected Shapes must define a plane") + "\n")
            return False

        # set center of mass
        ctr_mass = Vector(0,0,0)
        ctr_pts = Vector(0,0,0)
        mass = 0
        for shape in shapes:
            if hasattr(shape, "CenterOfMass"):
                ctr_mass += shape.CenterOfMass*shape.Mass
                mass += shape.Mass
            else:
                ctr_pts += shape.Point
        if mass > 0:
            ctr_mass /= mass
        # all shapes are vertexes
        else:
            ctr_mass = ctr_pts/len(shapes)

        super().align_to_point_and_axis(ctr_mass, normal, offset)
        self.weak = False
        return True

    def setup(self, direction=None, point=None, upvec=None, force=False):
        """Set up the working plane if it exists but is undefined.

        If `direction` and `point` are present,
        it calls `align_to_point_and_axis(point, direction, 0, upvec)`.

        Otherwise, it gets the camera orientation to define
        a working plane that is perpendicular to the current view,
        centered at the origin, and with `v` pointing up on the screen.

        This method only works when the `weak` attribute is `True`.
        This method also sets `weak` to `True`.

        This method only works when `FreeCAD.GuiUp` is `True`,
        that is, when the graphical interface is loaded.
        Otherwise it fails silently.

        Parameters
        ----------
        direction: Base.Vector, optional
            It defaults to `None`. It is the new `axis` of the plane.
        point: Base.Vector, optional
            It defaults to `None`. It is the new `position` of the plane.
        upvec: Base.Vector, optional
            It defaults to `None`. It is the new `v` orientation of the plane.
        force: bool
            If True, it sets the plane even if the plane is not in weak mode
        """
        if self.weak or force:
            if direction is not None and point is not None:
                super().align_to_point_and_axis(point, direction, 0, upvec)
            elif FreeCAD.GuiUp:
                try:
                    view = gui_utils.get_3d_view()
                    if view is not None:
                        cam = view.getCameraNode()
                        rot = FreeCAD.Rotation(*cam.getField("orientation").getValue().getValue())
                        self.u, self.v, self.axis = self._axes_from_view_rotation(rot)
                        self.position = Vector()
                except Exception:
                    pass
            if force:
                self.weak = False
            else:
                self.weak = True

    def reset(self):
        """Reset the WP.

        Sets the `weak` attribute to `True`.
        """
        self.weak = True

    def setTop(self):
        """Set the WP to the top position and updates the GUI."""
        super().set_to_top()
        self.weak = False
        if FreeCAD.GuiUp:
            from draftutils.translate import translate
            if hasattr(FreeCADGui,"Snapper"):
                FreeCADGui.Snapper.setGrid()
            if hasattr(FreeCADGui,"draftToolBar"):
                FreeCADGui.draftToolBar.wplabel.setText(translate("draft", "Top"))

    def setFront(self):
        """Set the WP to the front position and updates the GUI."""
        super().set_to_front()
        self.weak = False
        if FreeCAD.GuiUp:
            from draftutils.translate import translate
            if hasattr(FreeCADGui,"Snapper"):
                FreeCADGui.Snapper.setGrid()
            if hasattr(FreeCADGui,"draftToolBar"):
                FreeCADGui.draftToolBar.wplabel.setText(translate("draft", "Front"))

    def setSide(self):
        """Set the WP to the left side position and updates the GUI.

        Note that set_to_side from the parent class sets the WP to the right side position.
        Which matches the Side option from Draft_SelectPlane.
        """
        self.u = Vector(0, -1, 0)
        self.v = Vector(0, 0, 1)
        self.axis = Vector(-1, 0, 0)
        self.position = Vector()
        self.weak = False
        if FreeCAD.GuiUp:
            from draftutils.translate import translate
            if hasattr(FreeCADGui,"Snapper"):
                FreeCADGui.Snapper.setGrid()
            if hasattr(FreeCADGui,"draftToolBar"):
                FreeCADGui.draftToolBar.wplabel.setText(translate("draft", "Side"))

    def getRotation(self):
        """Return a placement describing the WP orientation only."""
        return FreeCAD.Placement(Vector(), FreeCAD.Rotation(self.u, self.v, self.axis, "ZYX"))

    def getPlacement(self, rotated=False):
        """Return a placement calculated from the WP. The rotated argument is ignored."""
        return super().get_placement()

    def getNormal(self):
        """Return the normal vector (axis) of the WP."""
        return self.axis

    def setFromPlacement(self, place, rebase=False):
        """Align the WP to a placement.

        Parameters
        ----------
        place: Base.Placement
            Placement.
        rebase: bool, optional
            Defaults to `False`.
            If `False` the `position` of the WP is not changed.
        """
        if rebase:
            super().align_to_placement(place)
        else:
            super()._axes_from_rotation(place.Rotation)

    def inverse(self):
        """Invert the direction of the plane.

        It inverts the `u` and `axis` vectors.
        """
        self.u = self.u.negative()
        self.axis = self.axis.negative()

    def save(self):
        """Store the plane attributes.

        Store `u`, `v`, `axis`, `position` and `weak`
        in a list in `stored`.
        """
        self.stored = [self.u, self.v, self.axis, self.position, self.weak]

    def restore(self):
        """Restore the plane attributes that were saved.

        Restores the attributes `u`, `v`, `axis`, `position` and `weak`
        from `stored`, and set `stored` to `None`.
        """
        if self.stored is not None:
            self.u, self.v, self.axis, self.position, self.weak = self.stored
            self.stored = None

    def getLocalCoords(self, point):
        """Translate a point from the global coordinate system to
        the local (WP) coordinate system.
        """
        return super().get_local_coords(point)

    def getGlobalCoords(self, point):
        """Translate a point from the local (WP) coordinate system to
        the global coordinate system.
        """
        return super().get_global_coords(point)

    def getLocalRot(self, vec):
        """Translate a vector from the global coordinate system to
        the local (WP) coordinate system.
        """
        return super().get_local_coords(vec, as_vector=True)

    def getGlobalRot(self, vec):
        """Translate a vector from the local (WP) coordinate system to
        the global coordinate system.
        """
        return super().get_global_coords(vec, as_vector=True)

    def getClosestAxis(self, vec):
        """Return the closest WP axis to a vector.

        Parameters
        ----------
        vec: Base.Vector
            Vector.

        Returns
        -------
        str
            `'x'`, `'y'` or `'z'`.
        """
        return super().get_closest_axis(vec)

    def isGlobal(self):
        """Return `True` if the WP matches the global coordinate system."""
        return super().is_global()

    def isOrtho(self):
        """Return `True` if the WP axes are orthogonal to the global axes."""
        return super().is_ortho()

    def getDeviation(self):
        """Return the angle between the u axis and the horizontal plane.

        It defines a projection of `u` on the horizontal plane
        (without a Z component), and then measures the angle between
        this projection and `u`.

        It also considers the cross product of the projection
        and `u` to determine the sign of the angle.

        Returns
        -------
        float
            Angle between the `u` vector, and a projected vector
            on the global horizontal plane.

        See Also
        --------
        DraftVecUtils.angle
        """
        proj = Vector(self.u.x, self.u.y, 0)
        if self.u.getAngle(proj) == 0:
            return 0
        else:
            norm = proj.cross(self.u)
            return DraftVecUtils.angle(self.u, proj, norm)

    def getParameters(self):
        """Return a dictionary with the data which define the plane:
        `u`, `v`, `axis`, `position`, `weak`.

        Returns
        -------
        dict
            dictionary of the form:
            {"u":x, "v":v, "axis":axis, "position":position, "weak":weak}
        """
        return super().get_parameters()

    def setFromParameters(self, data):
        """Set the plane according to data.

        Parameters
        ----------
        data: dict
            dictionary of the form:
            {"u":x, "v":v, "axis":axis, "position":position, "weak":weak}
       """
        super().set_parameters(data)

    def _get_prop_list(self):
        return ["u",
                "v",
                "axis",
                "position",
                "weak"]

plane = Plane


class PlaneGui(PlaneBase):
    """The PlaneGui class.
    The class handles several GUI related aspects of the WP including a history.

    Parameters
    ----------
    u: Base.Vector or WorkingPlane.Plane, optional
        Defaults to Vector(1, 0, 0).
        If a WP is provided:
            A copy of the WP is created, all other parameters are then ignored.
        If a vector is provided:
            Unit vector for the `u` attribute (+X axis).

    v: Base.Vector, optional
        Defaults to Vector(0, 1, 0).
        Unit vector for the `v` attribute (+Y axis).

    w: Base.Vector, optional
        Defaults to Vector(0, 0, 1).
        Unit vector for the `axis` attribute (+Z axis).

    pos: Base.Vector, optional
        Defaults to Vector(0, 0, 0).
        Vector for the `position` attribute (origin).

    auto: bool, optional
        Defaults to `True`.
        If `True` the WP is in "Auto" mode and will adapt to the current view.

    icon: str, optional
        Defaults to ":/icons/view-axonometric.svg".
        Path to the icon for the draftToolBar.

    label: str, optional
        Defaults to "Auto".
        Label for the draftToolBar.

    tip: str, optional
        Defaults to "Current working plane: Auto".
        Tooltip for the draftToolBar.

    Note that the u, v and w vectors are not checked for validity.

    Other attributes
    ----------------
    _view: Gui::View3DInventor
        Reference to a 3D view.

    _stored: dict
        Dictionary for a temporary stored state.

    _history: dict
        Dictionary that holds up to 10 stored states.
    """

    def __init__(self,
                 u=Vector(1, 0, 0), v=Vector(0, 1, 0), w=Vector(0, 0, 1),
                 pos=Vector(0, 0, 0),
                 auto=True,
                 icon=":/icons/view-axonometric.svg",
                 label=QT_TRANSLATE_NOOP("draft", "Auto"),
                 tip=QT_TRANSLATE_NOOP("draft", "Current working plane:") + " " + QT_TRANSLATE_NOOP("draft", "Auto")):

        if isinstance(u, PlaneGui):
            self.match(u)
        else:
            super().__init__(u, v, w, pos)
            self.auto = auto
            self.icon = icon
            self.label = label
            self.tip = tip
        self._view = None
        self._stored = {}
        self._history = {}

    def copy(self):
        """Return a new plane that is a copy of the present object."""
        wp = PlaneGui()
        self.match(source=self, target=wp)
        return wp

    def _save(self):
        """Store the WP attributes."""
        self._stored = self.get_parameters()

    def _restore(self):
        """Restore the WP attributes that were saved."""
        if self._stored:
            self.set_parameters(self._stored)
            self._stored = {}
            self._update_all(_hist_add=False)

    def align_to_selection(self, offset=0, _hist_add=True):
        """Align the WP to a selection with an optional offset.

        The selection must define a plane.

        Parameter
        ---------
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`

        Returns
        -------
        `True`/`False`
            `True` if successful.
        """
        if not FreeCAD.GuiUp:
            return False

        sels = FreeCADGui.Selection.getSelectionEx("", 0)
        if not sels:
            return False

        objs = []
        for sel in sels:
            for sub in sel.SubElementNames if sel.SubElementNames else [""]:
                objs.append(Part.getShape(sel.Object, sub, needSubElement=True, retType=1))

        if len(objs) != 1:
            if all([obj[0].isNull() is False and obj[0].ShapeType in ["Edge", "Vertex"] for obj in objs]):
                ret = self.align_to_edges_vertexes([obj[0] for obj in objs], offset, _hist_add)
            else:
                ret = False

            if ret is False:
                _wrn(translate("draft", "Selected shapes do not define a plane"))
            return ret

        shape, mtx, obj = objs[0]
        place = FreeCAD.Placement(mtx)

        typ = utils.get_type(obj)
        if typ in ["App::Part", "PartDesign::Plane", "Axis", "SectionPlane"]:
            ret = self.align_to_obj_placement(obj, offset, place, _hist_add)
        elif typ == "WorkingPlaneProxy":
            ret = self.align_to_wp_proxy(obj, offset, place, _hist_add)
        elif typ == "BuildingPart":
            ret = self.align_to_wp_proxy(obj, offset, place * obj.Placement, _hist_add)
        elif shape.isNull():
            ret = self.align_to_obj_placement(obj, offset, place, _hist_add)
        elif shape.ShapeType == "Face":
            ret = self.align_to_face(shape, offset, _hist_add)
        elif shape.ShapeType == "Edge":
            ret = self.align_to_edge_or_wire(shape, offset, _hist_add)
        elif shape.Solids:
            ret = self.align_to_obj_placement(obj, offset, place, _hist_add)
        else:
            ret = self.align_to_edges_vertexes(shape.Vertexes, offset, _hist_add)

        if ret is False:
            _wrn(translate("draft", "Selected shapes do not define a plane"))
        return ret

    def _handle_custom(self, _hist_add):
        self.auto = False
        self.icon = ":/icons/Draft_SelectPlane.svg"
        self.label = self._get_label(translate("draft", "Custom"))
        self.tip = self._get_tip(translate("draft", "Custom"))
        self._update_all(_hist_add)

    def align_to_3_points(self, p1, p2, p3, offset=0, _hist_add=True):
        """See PlaneBase.align_to_3_points."""
        if super().align_to_3_points(p1, p2, p3, offset) is False:
            return False
        self._handle_custom(_hist_add)
        return True

    def align_to_edges_vertexes(self, shapes, offset=0, _hist_add=True):
        """See PlaneBase.align_to_edges_vertexes."""
        if super().align_to_edges_vertexes(shapes, offset) is False:
            return False
        self._handle_custom(_hist_add)
        return True

    def align_to_edge_or_wire(self, shape, offset=0, _hist_add=True):
        """See PlaneBase.align_to_edge_or_wire."""
        if super().align_to_edge_or_wire(shape, offset) is False:
            return False
        self._handle_custom(_hist_add)
        return True

    def align_to_face(self, shape, offset=0, _hist_add=True):
        """See PlaneBase.align_to_face."""
        if super().align_to_face(shape, offset) is False:
            return False
        self._handle_custom(_hist_add)
        return True

    def align_to_placement(self, place, offset=0, _hist_add=True):
        """See PlaneBase.align_to_placement."""
        super().align_to_placement(place, offset)
        self._handle_custom(_hist_add)
        return True

    def align_to_point_and_axis(self, point, axis, offset=0, upvec=Vector(1, 0, 0), _hist_add=True):
        """See PlaneBase.align_to_point_and_axis."""
        if super().align_to_point_and_axis(point, axis, offset, upvec) is False:
            return False
        self._handle_custom(_hist_add)
        return True

    def align_to_obj_placement(self, obj, offset=0, place=None, _hist_add=True):
        """Align the WP to an object placement with an optional offset.

        Parameters
        ----------
        obj: App::DocumentObject
            Object to derive the Placement (if place is `None`), the icon and
            the label from.
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.
        place: Base.Placement, optional
            Defaults to `None`.
            If `None` the Placement from obj is used.
            Argument to be used if obj is inside a container.

        Returns
        -------
        `True`/`False`
            `True` if successful.
        """
        if hasattr(obj, "Placement") is False:
            return False
        if place is None:
            place = obj.Placement
        super().align_to_placement(place, offset)
        self.auto = False
        if utils.get_type(obj) == "WorkingPlaneProxy":
            self.icon = ":/icons/Draft_PlaneProxy.svg"
        elif FreeCAD.GuiUp \
                and hasattr(obj, "ViewObject") \
                and hasattr(obj.ViewObject, "Proxy") \
                and hasattr(obj.ViewObject.Proxy, "getIcon"):
            self.icon = obj.ViewObject.Proxy.getIcon()
        else:
            self.icon = ":/icons/Std_Placement.svg"
        self.label = self._get_label(obj.Label)
        self.tip = self._get_tip(obj.Label)
        self._update_all(_hist_add)
        return True

    def align_to_wp_proxy(self, obj, offset=0, place=None, _hist_add=True):
        """Align the WP to a WPProxy with an optional offset.

        See align_to_obj_placement.

        Also handles several WPProxy related features.
        """
        if self.align_to_obj_placement(obj, offset, place, _hist_add) is False:
            return False

        if not FreeCAD.GuiUp:
            return True

        vobj = obj.ViewObject

        if hasattr(vobj, "AutoWorkingPlane") \
                and vobj.AutoWorkingPlane is True:
            self.auto = True

        if hasattr(vobj, "CutView") \
                and hasattr(vobj, "AutoCutView") \
                and vobj.AutoCutView is True:
            vobj.CutView = True

        if hasattr(vobj, "RestoreView") \
                and vobj.RestoreView is True \
                and hasattr(vobj, "ViewData") \
                and len(vobj.ViewData) >= 12:
            vdat = vobj.ViewData
            if self._view is not None:
                try:
                    if len(vdat) == 13 and vdat[12] == 1:
                        camtype = "Perspective"
                    else:
                        camtype = "Orthographic"
                    if self._view.getCameraType() != camtype:
                        self._view.setCameraType(camtype)

                    cam = self._view.getCameraNode()
                    cam.position.setValue([vdat[0], vdat[1], vdat[2]])
                    cam.orientation.setValue([vdat[3], vdat[4], vdat[5], vdat[6]])
                    cam.nearDistance.setValue(vdat[7])
                    cam.farDistance.setValue(vdat[8])
                    cam.aspectRatio.setValue(vdat[9])
                    cam.focalDistance.setValue(vdat[10])
                    if camtype == "Orthographic":
                        cam.height.setValue(vdat[11])
                    else:
                        cam.heightAngle.setValue(vdat[11])
                except Exception:
                    pass

        if hasattr(vobj, "RestoreState") \
                and vobj.RestoreState is True \
                and hasattr(vobj, "VisibilityMap") \
                and vobj.VisibilityMap:
            for name, vis in vobj.VisibilityMap.items():
                obj = FreeCADGui.ActiveDocument.getObject(name)
                if obj:
                    obj.Visibility = (vis == "True")

        return True

    def auto_align(self):
        """Align the WP to the current view if self.auto is True."""
        if self.auto and self._view is not None:
            try:
                cam = self._view.getCameraNode()
                rot = FreeCAD.Rotation(*cam.getField("orientation").getValue().getValue())
                self.u, self.v, self.axis = self._axes_from_view_rotation(rot)
                self.position = Vector()
            except Exception:
                pass

    def set_to_default(self):
        """Set the WP to the default from the preferences."""
        default_wp = params.get_param("defaultWP")
        if default_wp == 0:
            self.set_to_auto()
        elif default_wp == 1:
            self.set_to_top()
        elif default_wp == 2:
            self.set_to_front()
        elif default_wp == 3:
            self.set_to_side()

    def set_to_auto(self): # Similar to Plane.reset.
        """Set the WP to auto."""
        self.auto = True
        self.auto_align()
        self.icon = ":/icons/view-axonometric.svg"
        self.label = self._get_label(translate("draft", "Auto"))
        self.tip = self._get_tip(translate("draft", "Auto"))
        self._update_all()

    def set_to_top(self, offset=0, center_on_view=False):
        """Set the WP to the top position with an optional offset.

        Parameters
        ----------
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.
        center_on_view: bool, optional
            Defaults to `False`.
            If `True` the WP `position` is moved along the (offset) WP to the
            center of the view
        """
        super().set_to_top(offset)
        if center_on_view:
            self._center_on_view()
        self.auto = False
        self.icon = ":/icons/view-top.svg"
        self.label = self._get_label(translate("draft", "Top"))
        self.tip = self._get_tip(translate("draft", "Top"))
        self._update_all()

    def set_to_front(self, offset=0, center_on_view=False):
        """Set the WP to the front position with an optional offset.

        Parameters
        ----------
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.
        center_on_view: bool, optional
            Defaults to `False`.
            If `True` the WP `position` is moved along the (offset) WP to the
            center of the view
        """
        super().set_to_front(offset)
        if center_on_view:
            self._center_on_view()
        self.auto = False
        self.icon = ":/icons/view-front.svg"
        self.label = self._get_label(translate("draft", "Front"))
        self.tip = self._get_tip(translate("draft", "Front"))
        self._update_all()

    def set_to_side(self, offset=0, center_on_view=False):
        """Set the WP to the right side position with an optional offset.

        Parameters
        ----------
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.
        center_on_view: bool, optional
            Defaults to `False`.
            If `True` the WP `position` is moved along the (offset) WP to the
            center of the view
        """
        super().set_to_side(offset)
        if center_on_view:
            self._center_on_view()
        self.auto = False
        self.icon = ":/icons/view-right.svg"
        self.label = self._get_label(translate("draft", "Side"))
        self.tip = self._get_tip(translate("draft", "Side"))
        self._update_all()

    def set_to_view(self, offset=0, center_on_view=False):
        """Align the WP to the view with an optional offset.

        Parameters
        ----------
        offset: float, optional
            Defaults to zero.
            Offset along the WP `axis`.
        center_on_view: bool, optional
            Defaults to `False`.
            If `True` the WP `position` is moved along the (offset) WP to the
            center of the view
        """
        if self._view is not None:
            try:
                cam = self._view.getCameraNode()
                rot = FreeCAD.Rotation(*cam.getField("orientation").getValue().getValue())
                self.u, self.v, self.axis = self._axes_from_view_rotation(rot)
                self.position = self.axis * offset
                if center_on_view:
                    self._center_on_view()
                self.auto = False
                self.icon = ":/icons/Draft_SelectPlane.svg"
                self.label = self._get_label(translate("draft", "Custom"))
                self.tip = self._get_tip(translate("draft", "Custom"))
                self._update_all()
            except Exception:
                pass

    def set_to_position(self, pos):
        """Set the `position` of the WP."""
        self.position = pos
        label = self.label.rstrip("*")
        self.label = self._get_label(label)
        self.tip = self._get_tip(label)
        self._update_all()

    def center_on_view(self):
        """Move the WP `position` along the WP to the center of the view."""
        self._center_on_view()
        label = self.label.rstrip("*")
        self.label = self._get_label(label)
        self.tip = self._get_tip(label)
        self._update_all()

    def _center_on_view(self):
        if self._view is not None:
            try:
                cam = self._view.getCameraNode()
                pos = self.project_point(Vector(cam.position.getValue().getValue()),
                                         direction=self._view.getViewDirection(),
                                         force_projection=False)
                if pos is not None:
                    self.position = pos
            except Exception:
                pass

    def align_view(self):
        """Align the view to the WP."""
        if self._view is not None:
            try:
                default_cam_dist = abs(params.get_param_view("NewDocumentCameraScale"))
                cam = self._view.getCameraNode()
                cur_cam_dist = abs(self.get_local_coords(Vector(cam.position.getValue().getValue())).z)
                cam_dist = max(default_cam_dist, cur_cam_dist)
                cam.position.setValue(self.position + DraftVecUtils.scaleTo(self.axis, cam_dist))
                cam.orientation.setValue(self.get_placement().Rotation.Q)
            except Exception:
                pass

    def _previous(self):
        idx = self._history["idx"]
        if idx == 0:
            _wrn(translate("draft", "No previous working plane"))
            return
        idx -= 1
        self.set_parameters(self._history["data_list"][idx])
        self._history["idx"] = idx
        self._update_all(_hist_add=False)

    def _next(self):
        idx = self._history["idx"]
        if idx == len(self._history["data_list"]) - 1:
            _wrn(translate("draft", "No next working plane"))
            return
        idx += 1
        self.set_parameters(self._history["data_list"][idx])
        self._history["idx"] = idx
        self._update_all(_hist_add=False)

    def _has_previous(self):
        return bool(self._history) and self._history["idx"] != 0

    def _has_next(self):
        return bool(self._history) and self._history["idx"] != len(self._history["data_list"]) - 1

    def _get_prop_list(self):
        return ["u",
                "v",
                "axis",
                "position",
                "auto",
                "icon",
                "label",
                "tip"]

    def _get_label(self, label):
        if self.auto or self.position.isEqual(Vector(), 0):
            return label
        else:
            return label + "*"

    def _get_tip(self, label):
        tip = translate("draft", "Current working plane:")
        tip += " " + label
        if self.auto:
            return tip
        tip += "\n" + translate("draft", "Axes:")
        tip += "\n    X = "
        tip += self._format_vector(self.u)
        tip += "\n    Y = "
        tip += self._format_vector(self.v)
        tip += "\n    Z = "
        tip += self._format_vector(self.axis)
        tip += "\n" + translate("draft", "Position:")
        tip += "\n    X = "
        tip += self._format_coord(self.position.x)
        tip += "\n    Y = "
        tip += self._format_coord(self.position.y)
        tip += "\n    Z = "
        tip += self._format_coord(self.position.z)
        return tip

    def _format_coord(self, coord):
        return FreeCAD.Units.Quantity(coord, FreeCAD.Units.Length).UserString

    def _format_vector(self, vec):
        dec = params.get_param("Decimals", path="Units")
        return f"({vec.x:.{dec}f}  {vec.y:.{dec}f}  {vec.z:.{dec}f})"

    def _update_all(self, _hist_add=True):
        if _hist_add is True:
            self._update_history()
        self._update_old_plane()  # Must happen before _update_grid.
        self._update_grid()
        self._update_gui()

    def _update_history(self):
        data = self.get_parameters()
        if not self._history:
            self._history = {"idx": 0, "data_list": [data]}
            return
        idx = self._history["idx"]
        if data == self._history["data_list"][idx]:
            return
        if self.auto is True and self._history["data_list"][idx]["auto"] is True:
            return

        max_len = 10  # Max. length of data_list.
        self._history["data_list"] = self._history["data_list"][(idx - (max_len - 2)):(idx + 1)]
        self._history["data_list"].append(data)
        self._history["idx"] = len(self._history["data_list"]) - 1

    def _update_old_plane(self):
        """ Update the old DraftWorkingPlane for compatibility.
        The tracker and snapper code currently still depend on it.
        """
        if not hasattr(FreeCAD, "DraftWorkingPlane"):
            FreeCAD.DraftWorkingPlane = Plane()
        for prop in ["u", "v", "axis", "position"]:
            setattr(FreeCAD.DraftWorkingPlane,
                    prop,
                    self._copy_value(getattr(self, prop)))
        FreeCAD.DraftWorkingPlane.weak = self.auto

    def _update_grid(self):
        # Check for draftToolBar because the trackers (grid) depend on it for its colors.
        if FreeCAD.GuiUp \
                and hasattr(FreeCADGui, "draftToolBar") \
                and hasattr(FreeCADGui, "Snapper") \
                and self._view is not None:
            FreeCADGui.Snapper.setGrid()
            FreeCADGui.Snapper.restack()  # Required??

    def _update_gui(self):
        if FreeCAD.GuiUp \
                and hasattr(FreeCADGui, "draftToolBar") \
                and self._view is not None:
            from PySide import QtGui
            button = FreeCADGui.draftToolBar.wplabel
            button.setIcon(QtGui.QIcon(self.icon))
            button.setText(self.label)
            button.setToolTip(self.tip)


def get_working_plane(update=True):

    if not hasattr(FreeCAD, "draft_working_planes"):
        FreeCAD.draft_working_planes = [[], []]

    view = gui_utils.get_3d_view()

    if view is not None and view in FreeCAD.draft_working_planes[0]:
        i = FreeCAD.draft_working_planes[0].index(view)
        wp = FreeCAD.draft_working_planes[1][i]
        if update is False:
            wp._update_old_plane()  # Currently required for tracker and snapper code.
            return wp
        wp.auto_align()
        wp._update_all(_hist_add=False)
        return wp

    wp = PlaneGui()
    if FreeCAD.GuiUp:
        wp._view = view  # Update _view before call to set_to_default, set_to_auto requires a 3D view.
        wp.set_to_default()
        if view is not None:
            FreeCAD.draft_working_planes[0].append(view)
            FreeCAD.draft_working_planes[1].append(wp)

    return wp


# View observer code to update the Draft Tray:
if FreeCAD.GuiUp:
    from PySide import QtWidgets
    from draftutils.todo import ToDo

    def _update_gui():
        try:
            view = gui_utils.get_3d_view()
            if view is None:
                return
            if not hasattr(FreeCAD, "draft_working_planes"):
                FreeCAD.draft_working_planes = [[], []]
            if view in FreeCAD.draft_working_planes[0]:
                i = FreeCAD.draft_working_planes[0].index(view)
                wp = FreeCAD.draft_working_planes[1][i]
                wp._update_gui()
            else:
                get_working_plane()
        except Exception:
            pass

    def _view_observer_callback(sub_win):
        if sub_win is None:
            return
        view = gui_utils.get_3d_view()
        if view is None:
            return
        if not hasattr(FreeCADGui, "draftToolBar"):
            return
        tray = FreeCADGui.draftToolBar.tray
        if tray is None:
            return
        if FreeCADGui.draftToolBar.tray.isVisible() is False:
            return
        ToDo.delay(_update_gui, None)

    _view_observer_active = False

    def _view_observer_start():
        mw = FreeCADGui.getMainWindow()
        mdi = mw.findChild(QtWidgets.QMdiArea)
        global _view_observer_active
        if not _view_observer_active:
            mdi.subWindowActivated.connect(_view_observer_callback)
            _view_observer_active = True
            _view_observer_callback(mdi.activeSubWindow())  # Trigger initial update.

    def _view_observer_stop():
        mw = FreeCADGui.getMainWindow()
        mdi = mw.findChild(QtWidgets.QMdiArea)
        global _view_observer_active
        if _view_observer_active:
            mdi.subWindowActivated.disconnect(_view_observer_callback)
            _view_observer_active = False


# Compatibility function (v1.0, 2023):
def getPlacementFromPoints(points):
    """Return a placement from a list of 3 or 4 points. The 4th point is no longer used.

    Calls DraftGeomUtils.placement_from_points(). See there.
    """
    utils.use_instead("DraftGeomUtils.placement_from_points")
    return DraftGeomUtils.placement_from_points(*points[:3])


# Compatibility function (v1.0, 2023):
def getPlacementFromFace(face, rotated=False):
    """Return a placement from a face.

    Calls DraftGeomUtils.placement_from_face(). See there.
    """
    utils.use_instead("DraftGeomUtils.placement_from_face")
    return DraftGeomUtils.placement_from_face(face, rotated=rotated)
