#  Copyright (C) 2012 Bill Currie <bill@taniwha.org>
#  Date: 2012/2/20

# ##### BEGIN GPL LICENSE BLOCK #####
#
#  This program is free software; you can redistribute it and/or
#  modify it under the terms of the GNU General Public License
#  as published by the Free Software Foundation; either version 2
#  of the License, or (at your option) any later version.
#
#  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 General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software Foundation,
#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####

# <pep8 compliant>

import bpy
import bmesh
from bpy.types import Operator
from bpy.props import (
        FloatProperty,
        IntProperty,
        BoolProperty,
        )
from mathutils import (
        Vector,
        Matrix,
        Quaternion,
        )
from math import (
        pi, cos,
        sin,
        )

cossin = []

# Initialize the cossin table based on the number of segments.
#
#   @param n  The number of segments into which the circle will be
#             divided.
#   @return   None


def build_cossin(n):
    global cossin
    cossin = []
    for i in range(n):
        a = 2 * pi * i / n
        cossin.append((cos(a), sin(a)))


def select_up(axis):
    # if axis.length != 0 and (abs(axis[0] / axis.length) < 1e-5 and abs(axis[1] / axis.length) < 1e-5):
    if (abs(axis[0] / axis.length) < 1e-5 and abs(axis[1] / axis.length) < 1e-5):
        up = Vector((-1, 0, 0))
    else:
        up = Vector((0, 0, 1))
    return up

# Make a single strut in non-manifold mode.
#
#   The strut will be a "cylinder" with @a n sides. The vertices of the
#   cylinder will be @a od / 2 from the center of the cylinder. Optionally,
#   extra loops will be placed (@a od - @a id) / 2 from either end. The
#   strut will be either a simple, open-ended single-surface "cylinder", or a
#   double walled "pipe" with the outer wall vertices @a od / 2 from the center
#   and the inner wall vertices @a id / 2 from the center. The two walls will
#   be joined together at the ends with a face ring such that the entire strut
#   is a manifold object. All faces of the strut will be quads.
#
#   @param v1       Vertex representing one end of the strut's center-line.
#   @param v2       Vertex representing the other end of the strut's
#                   center-line.
#   @param id       The diameter of the inner wall of a solid strut. Used for
#                   calculating the position of the extra loops irrespective
#                   of the solidity of the strut.
#   @param od       The diameter of the outer wall of a solid strut, or the
#                   diameter of a non-solid strut.
#   @param solid    If true, the strut will be made solid such that it has an
#                   inner wall (diameter @a id), an outer wall (diameter
#                   @a od), and face rings at either end of the strut such
#                   the strut is a manifold object. If false, the strut is
#                   a simple, open-ended "cylinder".
#   @param loops    If true, edge loops will be placed at either end of the
#                   strut, (@a od - @a id) / 2 from the end of the strut. The
#                   loops make subsurfed solid struts work nicely.
#   @return         A tuple containing a list of vertices and a list of faces.
#                   The face vertex indices are accurate only for the list of
#                   vertices for the created strut.


def make_strut(v1, v2, ind, od, n, solid, loops):
    v1 = Vector(v1)
    v2 = Vector(v2)
    axis = v2 - v1
    pos = [(0, od / 2)]
    if loops:
        pos += [((od - ind) / 2, od / 2),
                (axis.length - (od - ind) / 2, od / 2)]
    pos += [(axis.length, od / 2)]
    if solid:
        pos += [(axis.length, ind / 2)]
        if loops:
            pos += [(axis.length - (od - ind) / 2, ind / 2),
                    ((od - ind) / 2, ind / 2)]
        pos += [(0, ind / 2)]
    vps = len(pos)
    fps = vps
    if not solid:
        fps -= 1
    fw = axis.copy()
    fw.normalize()
    up = select_up(axis)
    lf = up.cross(fw)
    lf.normalize()
    up = fw.cross(lf)
    mat = Matrix((fw, lf, up))
    mat.transpose()
    verts = [None] * n * vps
    faces = [None] * n * fps
    for i in range(n):
        base = (i - 1) * vps
        x = cossin[i][0]
        y = cossin[i][1]
        for j in range(vps):
            p = Vector((pos[j][0], pos[j][1] * x, pos[j][1] * y))
            p = mat * p
            verts[i * vps + j] = p + v1
        if i:
            for j in range(fps):
                f = (i - 1) * fps + j
                faces[f] = [base + j, base + vps + j,
                            base + vps + (j + 1) % vps, base + (j + 1) % vps]
    base = len(verts) - vps
    i = n
    for j in range(fps):
        f = (i - 1) * fps + j
        faces[f] = [base + j, j, (j + 1) % vps, base + (j + 1) % vps]

    return verts, faces


# Project a point along a vector onto a plane.
#
#   Really, just find the intersection of the line represented by @a point
#   and @a dir with the plane represented by @a norm and @a p. However, if
#   the point is on or in front of the plane, or the line is parallel to
#   the plane, the original point will be returned.
#
#   @param point    The point to be projected onto the plane.
#   @param dir      The vector along which the point will be projected.
#   @param norm     The normal of the plane onto which the point will be
#                   projected.
#   @param p        A point through which the plane passes.
#   @return         A vector representing the projected point, or the
#                   original point.

def project_point(point, dir, norm, p):
    d = (point - p).dot(norm)
    if d >= 0:
        # the point is already on or in front of the plane
        return point
    v = dir.dot(norm)
    if v * v < 1e-8:
        # the plane is unreachable
        return point
    return point - dir * d / v


# Make a simple strut for debugging.
#
#   The strut is just a single quad representing the Z axis of the edge.
#
#   @param mesh     The base mesh. Used for finding the edge vertices.
#   @param edge_num The number of the current edge. For the face vertex
#                   indices.
#   @param edge     The edge for which the strut will be built.
#   @param od       Twice the width of the strut.
#   @return         A tuple containing a list of vertices and a list of faces.
#                   The face vertex indices are pre-adjusted by the edge
#                   number.
#   @fixme          The face vertex indices should be accurate for the local
#                   vertices (consistency)

def make_debug_strut(mesh, edge_num, edge, od):
    v = [mesh.verts[edge.verts[0].index].co,
         mesh.verts[edge.verts[1].index].co,
         None, None]
    v[2] = v[1] + edge.z * od / 2
    v[3] = v[0] + edge.z * od / 2
    f = [[edge_num * 4 + 0, edge_num * 4 + 1,
          edge_num * 4 + 2, edge_num * 4 + 3]]
    return v, f


# Make a cylinder with ends clipped to the end-planes of the edge.
#
#   The strut is just a single quad representing the Z axis of the edge.
#
#   @param mesh     The base mesh. Used for finding the edge vertices.
#   @param edge_num The number of the current edge. For the face vertex
#                   indices.
#   @param edge     The edge for which the strut will be built.
#   @param od       The diameter of the strut.
#   @return         A tuple containing a list of vertices and a list of faces.
#                   The face vertex indices are pre-adjusted by the edge
#                   number.
#   @fixme          The face vertex indices should be accurate for the local
#                   vertices (consistency)

def make_clipped_cylinder(mesh, edge_num, edge, od):
    n = len(cossin)
    cyl = [None] * n
    v0 = mesh.verts[edge.verts[0].index].co
    c0 = v0 + od * edge.y
    v1 = mesh.verts[edge.verts[1].index].co
    c1 = v1 - od * edge.y
    for i in range(n):
        x = cossin[i][0]
        y = cossin[i][1]
        r = (edge.z * x - edge.x * y) * od / 2
        cyl[i] = [c0 + r, c1 + r]
        for p in edge.verts[0].planes:
            cyl[i][0] = project_point(cyl[i][0], edge.y, p, v0)
        for p in edge.verts[1].planes:
            cyl[i][1] = project_point(cyl[i][1], -edge.y, p, v1)
    v = [None] * n * 2
    f = [None] * n
    base = edge_num * n * 2
    for i in range(n):
        v[i * 2 + 0] = cyl[i][1]
        v[i * 2 + 1] = cyl[i][0]
        f[i] = [None] * 4
        f[i][0] = base + i * 2 + 0
        f[i][1] = base + i * 2 + 1
        f[i][2] = base + (i * 2 + 3) % (n * 2)
        f[i][3] = base + (i * 2 + 2) % (n * 2)
    return v, f


# Represent a vertex in the base mesh, with additional information.
#
#   These vertices are @b not shared between edges.
#
#   @var index  The index of the vert in the base mesh
#   @var edge   The edge to which this vertex is attached.
#   @var edges  A tuple of indicess of edges attached to this vert, not
#               including the edge to which this vertex is attached.
#   @var planes List of vectors representing the normals of the planes that
#               bisect the angle between this vert's edge and each other
#               adjacant edge.

class SVert:
    # Create a vertex holding additional information about the bmesh vertex.
    #   @param bmvert   The bmesh vertex for which additional information is
    #                   to be stored.
    #   @param bmedge   The edge to which this vertex is attached.

    def __init__(self, bmvert, bmedge, edge):
        self.index = bmvert.index
        self.edge = edge
        edges = bmvert.link_edges[:]
        edges.remove(bmedge)
        self.edges = tuple(map(lambda e: e.index, edges))
        self.planes = []

    def calc_planes(self, edges):
        for ed in self.edges:
            self.planes.append(calc_plane_normal(self.edge, edges[ed]))


# Represent an edge in the base mesh, with additional information.
#
#   Edges do not share vertices so that the edge is always on the front (back?
#   must verify) side of all the planes attached to its vertices. If the
#   vertices were shared, the edge could be on either side of the planes, and
#   there would be planes attached to the vertex that are irrelevant to the
#   edge.
#
#   @var index      The index of the edge in the base mesh.
#   @var bmedge     Cached reference to this edge's bmedge
#   @var verts      A tuple of 2 SVert vertices, one for each end of the
#                   edge. The vertices are @b not shared between edges.
#                   However, if two edges are connected via a vertex in the
#                   bmesh, their corresponding SVert vertices will have the
#                   the same index value.
#   @var x          The x axis of the edges local frame of reference.
#                   Initially invalid.
#   @var y          The y axis of the edges local frame of reference.
#                   Initialized such that the edge runs from verts[0] to
#                   verts[1] along the negative y axis.
#   @var z          The z axis of the edges local frame of reference.
#                   Initially invalid.


class SEdge:

    def __init__(self, bmesh, bmedge):

        self.index = bmedge.index
        self.bmedge = bmedge
        bmesh.verts.ensure_lookup_table()
        self.verts = (SVert(bmedge.verts[0], bmedge, self),
                      SVert(bmedge.verts[1], bmedge, self))
        self.y = (bmesh.verts[self.verts[0].index].co -
                  bmesh.verts[self.verts[1].index].co)
        self.y.normalize()
        self.x = self.z = None

    def set_frame(self, up):
        self.x = self.y.cross(up)
        self.x.normalize()
        self.z = self.x.cross(self.y)

    def calc_frame(self, base_edge):
        baxis = base_edge.y
        if (self.verts[0].index == base_edge.verts[0].index or
              self.verts[1].index == base_edge.verts[1].index):
            axis = -self.y
        elif (self.verts[0].index == base_edge.verts[1].index or
                self.verts[1].index == base_edge.verts[0].index):
            axis = self.y
        else:
            raise ValueError("edges not connected")
        if baxis.dot(axis) in (-1, 1):
            # aligned axis have their up/z aligned
            up = base_edge.z
        else:
            # Get the unit vector dividing the angle (theta) between baxis and
            # axis in two equal parts
            h = (baxis + axis)
            h.normalize()
            # (cos(theta/2), sin(theta/2) * n) where n is the unit vector of the
            # axis rotating baxis onto axis
            q = Quaternion([baxis.dot(h)] + list(baxis.cross(h)))
            # rotate the base edge's up around the rotation axis (blender
            # quaternion shortcut:)
            up = q * base_edge.z
        self.set_frame(up)

    def calc_vert_planes(self, edges):
        for v in self.verts:
            v.calc_planes(edges)

    def bisect_faces(self):
        n1 = self.bmedge.link_faces[0].normal
        if len(self.bmedge.link_faces) > 1:
            n2 = self.bmedge.link_faces[1].normal
            return (n1 + n2).normalized()
        return n1

    def calc_simple_frame(self):
        return self.y.cross(select_up(self.y)).normalized()

    def find_edge_frame(self, sedges):
        if self.bmedge.link_faces:
            return self.bisect_faces()
        if self.verts[0].edges or self.verts[1].edges:
            edges = list(self.verts[0].edges + self.verts[1].edges)
            for i in range(len(edges)):
                edges[i] = sedges[edges[i]]
            while edges and edges[-1].y.cross(self.y).length < 1e-3:
                edges.pop()
            if not edges:
                return self.calc_simple_frame()
            n1 = edges[-1].y.cross(self.y).normalized()
            edges.pop()
            while edges and edges[-1].y.cross(self.y).cross(n1).length < 1e-3:
                edges.pop()
            if not edges:
                return n1
            n2 = edges[-1].y.cross(self.y).normalized()
            return (n1 + n2).normalized()
        return self.calc_simple_frame()


def calc_plane_normal(edge1, edge2):
    if edge1.verts[0].index == edge2.verts[0].index:
        axis1 = -edge1.y
        axis2 = edge2.y
    elif edge1.verts[1].index == edge2.verts[1].index:
        axis1 = edge1.y
        axis2 = -edge2.y
    elif edge1.verts[0].index == edge2.verts[1].index:
        axis1 = -edge1.y
        axis2 = -edge2.y
    elif edge1.verts[1].index == edge2.verts[0].index:
        axis1 = edge1.y
        axis2 = edge2.y
    else:
        raise ValueError("edges not connected")
    # Both axis1 and axis2 are unit vectors, so this will produce a vector
    # bisects the two, so long as they are not 180 degrees apart (in which
    # there are infinite solutions).
    return (axis1 + axis2).normalized()


def build_edge_frames(edges):
    edge_set = set(edges)
    while edge_set:
        edge_queue = [edge_set.pop()]
        edge_queue[0].set_frame(edge_queue[0].find_edge_frame(edges))
        while edge_queue:
            current_edge = edge_queue.pop()
            for i in (0, 1):
                for e in current_edge.verts[i].edges:
                    edge = edges[e]
                    if edge.x is not None:  # edge already processed
                        continue
                    edge_set.remove(edge)
                    edge_queue.append(edge)
                    edge.calc_frame(current_edge)


def make_manifold_struts(truss_obj, od, segments):
    bpy.context.view_layer.objects.active = truss_obj
    bpy.ops.object.editmode_toggle()
    truss_mesh = bmesh.from_edit_mesh(truss_obj.data).copy()
    bpy.ops.object.editmode_toggle()
    edges = [None] * len(truss_mesh.edges)
    for i, e in enumerate(truss_mesh.edges):
        edges[i] = SEdge(truss_mesh, e)
    build_edge_frames(edges)
    verts = []
    faces = []
    for e, edge in enumerate(edges):
        # v, f = make_debug_strut(truss_mesh, e, edge, od)
        edge.calc_vert_planes(edges)
        v, f = make_clipped_cylinder(truss_mesh, e, edge, od)
        verts += v
        faces += f
    return verts, faces


def make_simple_struts(truss_mesh, ind, od, segments, solid, loops):
    vps = 2
    if solid:
        vps *= 2
    if loops:
        vps *= 2
    fps = vps
    if not solid:
        fps -= 1

    verts = [None] * len(truss_mesh.edges) * segments * vps
    faces = [None] * len(truss_mesh.edges) * segments * fps
    vbase = 0
    fbase = 0

    for e in truss_mesh.edges:
        v1 = truss_mesh.vertices[e.vertices[0]]
        v2 = truss_mesh.vertices[e.vertices[1]]
        v, f = make_strut(v1.co, v2.co, ind, od, segments, solid, loops)
        for fv in f:
            for i in range(len(fv)):
                fv[i] += vbase
        for i in range(len(v)):
            verts[vbase + i] = v[i]
        for i in range(len(f)):
            faces[fbase + i] = f[i]
        # if not base % 12800:
        #    print (base * 100 / len(verts))
        vbase += vps * segments
        fbase += fps * segments

    return verts, faces


def create_struts(self, context, ind, od, segments, solid, loops, manifold):
    build_cossin(segments)

    for truss_obj in bpy.context.scene.objects:
        if not truss_obj.select_get():
            continue
        truss_obj.select_set(False)
        truss_mesh = truss_obj.to_mesh(context.scene, True, 'PREVIEW')
        if not truss_mesh.edges:
            continue
        if manifold:
            verts, faces = make_manifold_struts(truss_obj, od, segments)
        else:
            verts, faces = make_simple_struts(truss_mesh, ind, od, segments,
                                              solid, loops)
        mesh = bpy.data.meshes.new("Struts")
        mesh.from_pydata(verts, [], faces)
        obj = bpy.data.objects.new("Struts", mesh)
        bpy.context.collection.objects.link(obj)
        obj.select_set(True)
        obj.location = truss_obj.location
        bpy.context.view_layer.objects.active = obj
        mesh.update()


class Struts(Operator):
    bl_idname = "mesh.generate_struts"
    bl_label = "Struts"
    bl_description = ("Add one or more struts meshes based on selected truss meshes \n"
                      "Note: can get very high poly\n"
                      "Needs an existing Active Mesh Object")
    bl_options = {'REGISTER', 'UNDO'}

    ind: FloatProperty(
            name="Inside Diameter",
            description="Diameter of inner surface",
            min=0.0, soft_min=0.0,
            max=100, soft_max=100,
            default=0.04
            )
    od: FloatProperty(
            name="Outside Diameter",
            description="Diameter of outer surface",
            min=0.001, soft_min=0.001,
            max=100, soft_max=100,
            default=0.05
            )
    manifold: BoolProperty(
            name="Manifold",
            description="Connect struts to form a single solid",
            default=False
            )
    solid: BoolProperty(
            name="Solid",
            description="Create inner surface",
            default=False
            )
    loops: BoolProperty(
            name="Loops",
            description="Create sub-surf friendly loops",
            default=False
            )
    segments: IntProperty(
            name="Segments",
            description="Number of segments around strut",
            min=3, soft_min=3,
            max=64, soft_max=64,
            default=12
            )

    def draw(self, context):
        layout = self.layout

        col = layout.column(align=True)
        col.prop(self, "ind")
        col.prop(self, "od")
        col.prop(self, "segments")
        col.separator()

        col.prop(self, "manifold")
        col.prop(self, "solid")
        col.prop(self, "loops")

    @classmethod
    def poll(cls, context):
        obj = context.active_object
        return obj is not None and obj.type == "MESH"

    def execute(self, context):
        store_undo = bpy.context.preferences.edit.use_global_undo
        bpy.context.preferences.edit.use_global_undo = False
        keywords = self.as_keywords()

        try:
            create_struts(self, context, **keywords)
            bpy.context.preferences.edit.use_global_undo = store_undo

            return {"FINISHED"}

        except Exception as e:
            bpy.context.preferences.edit.use_global_undo = store_undo
            self.report({"WARNING"},
                        "Make Struts could not be performed. Operation Cancelled")
            print("\n[mesh.generate_struts]\n{}".format(e))
            return {"CANCELLED"}


def register():
    bpy.utils.register_module(__name__)


def unregister():
    bpy.utils.unregister_module(__name__)


if __name__ == "__main__":
    register()
