# -*- coding: utf-8 -*-
# view loacal axis of selected datum plane macro
# Author: Avinash Pudale
# License: LGPL v 2.1

from __future__ import annotations

__Name__ = 'Datum-Plane Local Axis'
__Comment__ = 'Select a datum plane in the 3D view then run, this macro will shows local axes for all selected planes or delete all created local axes if nothing is selected'
__Author__ = 'Avinash Pudale'
__Date__ = '2023-08-11'
__Version__ = '0.3.0'
__License__ = 'LGPL-2.1'
__Web__ = 'https://forum.freecad.org/viewtopic.php?t=79562'
__Wiki__ = ''
__Icon__ = 'DatumPlaneLocalAxis.svg'
__Xpm__ = ''
__Help__ = 'The macro will add small X-, Y-, and Z-axis representations on the selected datum planes in the 3D view. The X-axis is represented in red, the Y-axis in green, and the Z-axis in blue. To clear the axis representations, simply run the macro again without selecting anything.'
__Status__ = 'Stable'
__Requires__ = 'FreeCAD >=0.20'
__Communication__ = 'https://github.com/FabLabBaramati/freecadDatumLoacalAxisMacro/issues'
__Files__ = 'DatumPlaneLocalAxis.svg'


import FreeCAD as app
import FreeCADGui as gui

import Draft

# Typing hints.
DO = app.DocumentObject
PL = DO  # A `PartDesign::Plane` or a `Part::Link` to a `PartDesign::Plane`.

# The 3 axes representing a plane will be inside a group that
# starts with this label.
GROUP_LABEL = 'Datum_Plane_Axis'


def strip_subelement(sub_fullpath: str) -> str:
    """Return sub_fullpath without the last sub-element.

    A sub-element is a face, edge or vertex.
    Parameters
    ----------
    - subobject_fullpath: SelectionObject.SubElementNames[i], where
        SelectionObject is obtained with gui.Selection.getSelectionEx('', 0)
        (i.e. not gui.Selection.getSelectionEx()).
        Examples:
        - 'Face6' if you select the top face of a cube solid made in Part.
        - 'Body.Box001.' if you select the tip of a Part->Body->"additive
            primitve" in PartDesign.
        - 'Body.Box001.Face6' if you select the top face of a Part->Body->
            "additive primitve" in PartDesign.

    """
    if (not sub_fullpath) or ('.' not in sub_fullpath):
        return ''
    return sub_fullpath.rsplit('.', maxsplit=1)[0]


def get_subobject_by_name(object_: app.DocumentObject,
                          subobject_name: str,
                          ) -> app.DocumentObject:
    """Return the appropriate object from object_.OutListRecursive."""
    for o in object_.OutListRecursive:
        if o.Name == subobject_name:
            return o


def get_subobjects_by_full_name(
        root_object: app.DocumentObject,
        subobject_fullpath: str,
        ) -> list[app.DocumentObject]:
    """Return the list of objects from root_object excluded to the named object.

    The last part of ``subobject_fullpath`` is then a specific vertex, edge or
    face and is ignored.
    So, subobject_fullpath has the form 'name0.name1.Edge001', for example; in
    this case, the returned objects are
    [object_named_name0, object_named_name1].

    Parameters
    ----------
    - root_object: SelectionObject.Object, where SelectionObject is obtained
        with gui.Selection.getSelectionEx('', 0)
        (i.e. not gui.Selection.getSelectionEx()).
    - subobject_fullpath: SelectionObject.SubElementNames[i].
        Examples:
        - 'Face6' if you select the top face of a cube solid made in Part.
        - 'Body.Box001.' if you select the tip of a Part->Body->"additive
            primitve" in PartDesign.
        - 'Body.Box001.Face6' if you select the top face of a Part->Body->
            "additive primitve" in PartDesign.

    """
    objects = []
    names = strip_subelement(subobject_fullpath).split('.')
    subobject = root_object
    for name in names:
        subobject = get_subobject_by_name(subobject, name)
        if subobject is None:
            # This should not append.
            return []
        objects.append(subobject)
    return objects


def get_global_placement_expression(
        root_object: app.DocumentObject,
        subobject_fullpath: str,
        ) -> str:
    """Return the global placement by recursively going through parents.

    Return the expression to compute the global placement of `root_object`.

    Parameters
    ----------
    - root_object: SelectionObject.Object, where SelectionObject is obtained
        with gui.Selection.getSelectionEx('', 0)
        (i.e. not gui.Selection.getSelectionEx()).
    - subobject_fullpath: SelectionObject.SubElementNames[i].
        Examples:
        - 'Face6' if you select the top face of a cube solid made in Part.
        - 'Body.Box001.' if you select the tip of a Part->Body->"additive
            primitve" in PartDesign.
        - 'Body.Box001.Face6' if you select the top face of a Part->Body->
            "additive primitve" in PartDesign.

    """
    objects = get_subobjects_by_full_name(root_object, subobject_fullpath)
    expr = f'{root_object.Name}.Placement'
    for o in objects:
        expr += f' * {o.Name}.Placement'
    return expr


def _add_local_axis_to_plane(
        plane_or_link: app.DocumentObject,
        placement_expression: str,
        ) -> None:
    """Add the local axis to the plane or link."""
    doc = plane_or_link.Document
    # Implementation note: returns self for non-links.
    plane = plane_or_link.getLinkedObject()

    # Create a group to contain the axis representations.
    group = doc.addObject('App::DocumentObjectGroup',
                          f'{GROUP_LABEL}_{plane_or_link.Name}')

    # Size of the line representing an axis.
    size = 0.95 * min(plane.Length, plane.Width) / 2.0

    labels_points_colors = (
        ('x', app.Vector(size, 0.0, 0.0), (1.0, 0.0, 0.0)),  # Red.
        ('y', app.Vector(0.0, size, 0.0), (0.0, 1.0, 0.0)),  # Green.
        ('z', app.Vector(0.0, 0.0, size), (0.0, 0.0, 1.0)),  # Blue.
        )

    for label, point, color in labels_points_colors:
        # Create Draft objects for the axis.
        axis = Draft.makeLine(app.Vector(0.0, 0.0, 0.0), point)
        axis.Label = label

        # Set the placements for the axis representations
        axis.setExpression('Placement', placement_expression)
        axis.setPropertyStatus('Placement', ['ReadOnly'])

        axis.ViewObject.DisplayMode = 'Wireframe'

        axis.ViewObject.LineColor = color
        axis.ViewObject.PointColor = color

        # Add the axis representations as child objects to the group
        group.addObject(axis)


def add_local_axis():
    doc = app.activeDocument()

    if not doc:
        return

    # Check if a datum plane is selected in the tree view.
    selection = gui.Selection.getSelectionEx('', 0)

    if not selection:
        # Delete all axis representations if no object is selected.
        objs_to_delete: list[DO] = []
        for obj in doc.Objects:
            if (obj.isDerivedFrom('App::DocumentObjectGroup')
                    and obj.Label.startswith(GROUP_LABEL)):
                for child_obj in obj.Group:
                    objs_to_delete.append(child_obj)
                objs_to_delete.append(obj)
        # Delete the groups and lines.
        for obj in objs_to_delete:
            doc.removeObject(obj.Name)
        doc.recompute()
        return

    # The list of selected planes (or links to a plane).
    planes_or_links: set[PL] = set()
    # For each plane, the expressions to place the axis representations.
    placement_expression: dict[PL] = {}
    for sel_obj in selection:
        obj = sel_obj.Object
        sub_fullpaths = sel_obj.SubElementNames
        if not sub_fullpaths:
            # Cannot be a datum plane because the plane itself must be
            # the last element of `sub_fullpaths`.
            continue
        sub_fullpath = sub_fullpaths[0]
        objects = get_subobjects_by_full_name(obj, sub_fullpath)
        if ((not objects)
                or (not hasattr(objects[-1], 'isDerivedFrom'))
                or (not hasattr(objects[-1], 'getLinkedObject'))):
            # Cannot be a datum plane.
            continue
        plane_or_link = objects[-1]
        # Implementation note: returns self for non-links.
        plane = plane_or_link.getLinkedObject()
        if not plane.isDerivedFrom('PartDesign::Plane'):
            continue
        planes_or_links.add(plane_or_link)
        placement_expression[plane_or_link] = get_global_placement_expression(
                obj, sub_fullpath)

    for plane_or_link in planes_or_links:
        _add_local_axis_to_plane(plane_or_link,
                                 placement_expression[plane_or_link])

    doc.recompute()


if __name__ == '__main__':
    add_local_axis()
