# SPDX-License-Identifier: GPL-2.0-or-later

"""
bl_info = {
    "name": "Torus Knots",
    "author": "Marius Giurgi (DolphinDream), testscreenings",
    "version": (0, 3),
    "blender": (2, 80, 0),
    "location": "View3D > Add > Curve",
    "description": "Adds many types of (torus) knots",
    "warning": "",
    "doc_url": "{BLENDER_MANUAL_URL}/addons/add_curve/extra_objects.html",
    "category": "Add Curve",
}
"""

import bpy
from bpy.props import (
        BoolProperty,
        EnumProperty,
        FloatProperty,
        IntProperty
        )
from math import (
        sin, cos,
        pi, sqrt
        )
from mathutils import (
        Vector,
        Matrix,
        )
from bpy_extras.object_utils import (
        AddObjectHelper,
        object_data_add
        )
from random import random
from bpy.types import Operator

# Globals:
DEBUG = False


# greatest common denominator
def gcd(a, b):
    if b == 0:
        return a
    else:
        return gcd(b, a % b)


# #######################################################################
# ###################### Knot Definitions ###############################
# #######################################################################
def Torus_Knot(self, linkIndex=0):
    p = self.torus_p  # revolution count (around the torus center)
    q = self.torus_q  # spin count (around the torus tube)

    N = self.torus_res  # curve resolution (number of control points)

# use plus options only when they are enabled
    if self.options_plus:
        u = self.torus_u  # p multiplier
        v = self.torus_v  # q multiplier
        h = self.torus_h  # height (scale along Z)
        s = self.torus_s  # torus scale (radii scale factor)
    else:  # don't use plus settings
        u = 1
        v = 1
        h = 1
        s = 1

    R = self.torus_R * s  # major radius (scaled)
    r = self.torus_r * s  # minor radius (scaled)

    # number of decoupled links when (p,q) are NOT co-primes
    links = gcd(p, q)  # = 1 when (p,q) are co-primes

    # parametrized angle increment (cached outside of the loop for performance)
    # NOTE: the total angle is divided by number of decoupled links to ensure
    #       the curve does not overlap with itself when (p,q) are not co-primes
    da = 2 * pi / links / (N - 1)

    # link phase : each decoupled link is phased equally around the torus center
    # NOTE: linkIndex value is in [0, links-1]
    linkPhase = 2 * pi / q * linkIndex  # = 0 when there is just ONE link

    # user defined phasing
    if self.options_plus:
        rPhase = self.torus_rP  # user defined revolution phase
        sPhase = self.torus_sP  # user defined spin phase
    else:  # don't use plus settings
        rPhase = 0
        sPhase = 0

    rPhase += linkPhase  # total revolution phase of the current link

    if DEBUG:
        print("")
        print("Link: %i of %i" % (linkIndex, links))
        print("gcd = %i" % links)
        print("p = %i" % p)
        print("q = %i" % q)
        print("link phase = %.2f deg" % (linkPhase * 180 / pi))
        print("link phase = %.2f rad" % linkPhase)

    # flip directions ? NOTE: flipping both is equivalent to no flip
    if self.flip_p:
        p *= -1
    if self.flip_q:
        q *= -1

    # create the 3D point array for the current link
    newPoints = []
    for n in range(N - 1):
        # t = 2 * pi / links * n/(N-1) with: da = 2*pi/links/(N-1) => t = n * da
        t = n * da
        theta = p * t * u + rPhase  # revolution angle
        phi = q * t * v + sPhase  # spin angle

        x = (R + r * cos(phi)) * cos(theta)
        y = (R + r * cos(phi)) * sin(theta)
        z = r * sin(phi) * h

    # append 3D point
    # NOTE : the array is adjusted later as needed to 4D for POLY and NURBS
        newPoints.append([x, y, z])

    return newPoints


# ------------------------------------------------------------------------------
# Calculate the align matrix for the new object (based on user preferences)

def align_matrix(self, context):
    if self.absolute_location:
        loc = Matrix.Translation(Vector((0, 0, 0)))
    else:
        loc = Matrix.Translation(context.scene.cursor.location)

# user defined location & translation
    userLoc = Matrix.Translation(self.location)
    userRot = self.rotation.to_matrix().to_4x4()

    obj_align = context.preferences.edit.object_align
    if (context.space_data.type == 'VIEW_3D' and obj_align == 'VIEW'):
        rot = context.space_data.region_3d.view_matrix.to_3x3().inverted().to_4x4()
    else:
        rot = Matrix()

    align_matrix = userLoc @ loc @ rot @ userRot
    return align_matrix


# ------------------------------------------------------------------------------
# Set curve BEZIER handles to auto

def setBezierHandles(obj, mode='AUTO'):
    scene = bpy.context.scene
    if obj.type != 'CURVE':
        return
    #scene.objects.active = obj
    #bpy.ops.object.mode_set(mode='EDIT', toggle=True)
    #bpy.ops.curve.select_all(action='SELECT')
    #bpy.ops.curve.handle_type_set(type=mode)
    #bpy.ops.object.mode_set(mode='OBJECT', toggle=True)


# ------------------------------------------------------------------------------
# Convert array of vert coordinates to points according to spline type

def vertsToPoints(Verts, splineType):
    # main vars
    vertArray = []

    # array for BEZIER spline output (V3)
    if splineType == 'BEZIER':
        for v in Verts:
            vertArray += v

    # array for non-BEZIER output (V4)
    else:
        for v in Verts:
            vertArray += v
            if splineType == 'NURBS':
                vertArray.append(1)  # for NURBS w=1
            else:  # for POLY w=0
                vertArray.append(0)

    return vertArray


# ------------------------------------------------------------------------------
# Create the Torus Knot curve and object and add it to the scene

def create_torus_knot(self, context):
    # pick a name based on (p,q) parameters
    aName = "Torus Knot %i x %i" % (self.torus_p, self.torus_q)

    # create curve
    curve_data = bpy.data.curves.new(name=aName, type='CURVE')

    # setup materials to be used for the TK links
    if self.use_colors:
        addLinkColors(self, curve_data)

    # create torus knot link(s)
    if self.multiple_links:
        links = gcd(self.torus_p, self.torus_q)
    else:
        links = 1

    for l in range(links):
        # get vertices for the current link
        verts = Torus_Knot(self, l)

        # output splineType 'POLY' 'NURBS' or 'BEZIER'
        splineType = self.outputType

        # turn verts into proper array (based on spline type)
        vertArray = vertsToPoints(verts, splineType)

        # create spline from vertArray (based on spline type)
        spline = curve_data.splines.new(type=splineType)
        if splineType == 'BEZIER':
            spline.bezier_points.add(int(len(vertArray) * 1.0 / 3 - 1))
            spline.bezier_points.foreach_set('co', vertArray)
            for point in spline.bezier_points:
                point.handle_right_type = self.handleType
                point.handle_left_type = self.handleType
        else:
            spline.points.add(int(len(vertArray) * 1.0 / 4 - 1))
            spline.points.foreach_set('co', vertArray)
            spline.use_endpoint_u = True

        # set curve options
        spline.use_cyclic_u = True
        spline.order_u = 4

        # set a color per link
        if self.use_colors:
            spline.material_index = l

    curve_data.dimensions = '3D'
    curve_data.resolution_u = self.segment_res

    # create surface ?
    if self.geo_surface:
        curve_data.fill_mode = 'FULL'
        curve_data.bevel_depth = self.geo_bDepth
        curve_data.bevel_resolution = self.geo_bRes
        curve_data.extrude = self.geo_extrude
        curve_data.offset = self.geo_offset

    # set object in the scene
    new_obj = object_data_add(context, curve_data)  # place in active scene
    bpy.ops.object.select_all(action='DESELECT')
    new_obj.select_set(True)  # set as selected
    bpy.context.view_layer.objects.active = new_obj
    new_obj.matrix_world = self.align_matrix  # apply matrix
    bpy.context.view_layer.update()

    return


# ------------------------------------------------------------------------------
# Create materials to be assigned to each TK link

def addLinkColors(self, curveData):
    # some predefined colors for the torus knot links
    colors = []
    if self.colorSet == "1":  # RGBish
        colors += [[0.0, 0.0, 1.0]]
        colors += [[0.0, 1.0, 0.0]]
        colors += [[1.0, 0.0, 0.0]]
        colors += [[1.0, 1.0, 0.0]]
        colors += [[0.0, 1.0, 1.0]]
        colors += [[1.0, 0.0, 1.0]]
        colors += [[1.0, 0.5, 0.0]]
        colors += [[0.0, 1.0, 0.5]]
        colors += [[0.5, 0.0, 1.0]]
    else:  # RainBow
        colors += [[0.0, 0.0, 1.0]]
        colors += [[0.0, 0.5, 1.0]]
        colors += [[0.0, 1.0, 1.0]]
        colors += [[0.0, 1.0, 0.5]]
        colors += [[0.0, 1.0, 0.0]]
        colors += [[0.5, 1.0, 0.0]]
        colors += [[1.0, 1.0, 0.0]]
        colors += [[1.0, 0.5, 0.0]]
        colors += [[1.0, 0.0, 0.0]]

    me = curveData
    links = gcd(self.torus_p, self.torus_q)

    for i in range(links):
        matName = "TorusKnot-Link-%i" % i
        matListNames = bpy.data.materials.keys()
        # create the material
        if matName not in matListNames:
            if DEBUG:
                print("Creating new material : %s" % matName)
            mat = bpy.data.materials.new(matName)
        else:
            if DEBUG:
                print("Material %s already exists" % matName)
            mat = bpy.data.materials[matName]

        # set material color
        if self.options_plus and self.random_colors:
            mat.diffuse_color = (random(), random(), random(), 1.0)
        else:
            cID = i % (len(colors))  # cycle through predefined colors
            mat.diffuse_color = (*colors[cID], 1.0)

        if self.options_plus:
            mat.diffuse_color = (mat.diffuse_color[0] * self.saturation, mat.diffuse_color[1] * self.saturation, mat.diffuse_color[2] * self.saturation, 1.0)
        else:
            mat.diffuse_color = (mat.diffuse_color[0] * 0.75, mat.diffuse_color[1] * 0.75, mat.diffuse_color[2] * 0.75, 1.0)

        me.materials.append(mat)


# ------------------------------------------------------------------------------
# Main Torus Knot class

class torus_knot_plus(Operator, AddObjectHelper):
    bl_idname = "curve.torus_knot_plus"
    bl_label = "Torus Knot +"
    bl_options = {'REGISTER', 'UNDO', 'PRESET'}
    bl_description = "Adds many types of tours knots"
    bl_context = "object"

    def mode_update_callback(self, context):
        # keep the equivalent radii sets (R,r)/(eR,iR) in sync
        if self.mode == 'EXT_INT':
            self.torus_eR = self.torus_R + self.torus_r
            self.torus_iR = self.torus_R - self.torus_r

    # align_matrix for the invoke
    align_matrix = None

    # GENERAL options
    options_plus : BoolProperty(
            name="Extra Options",
            default=False,
            description="Show more options (the plus part)",
            )
    absolute_location : BoolProperty(
            name="Absolute Location",
            default=False,
            description="Set absolute location instead of relative to 3D cursor",
            )
    # COLOR options
    use_colors : BoolProperty(
            name="Use Colors",
            default=False,
            description="Show torus links in colors",
            )
    colorSet : EnumProperty(
            name="Color Set",
            items=(('1', "RGBish", "RGBsish ordered colors"),
                    ('2', "Rainbow", "Rainbow ordered colors")),
            )
    random_colors : BoolProperty(
            name="Randomize Colors",
            default=False,
            description="Randomize link colors",
            )
    saturation : FloatProperty(
            name="Saturation",
            default=0.75,
            min=0.0, max=1.0,
            description="Color saturation",
            )
    # SURFACE Options
    geo_surface : BoolProperty(
            name="Surface",
            default=True,
            description="Create surface",
            )
    geo_bDepth : FloatProperty(
            name="Bevel Depth",
            default=0.04,
            min=0, soft_min=0,
            description="Bevel Depth",
            )
    geo_bRes : IntProperty(
            name="Bevel Resolution",
            default=2,
            min=0, soft_min=0,
            max=5, soft_max=5,
            description="Bevel Resolution"
            )
    geo_extrude : FloatProperty(
            name="Extrude",
            default=0.0,
            min=0, soft_min=0,
            description="Amount of curve extrusion"
            )
    geo_offset : FloatProperty(
            name="Offset",
            default=0.0,
            min=0, soft_min=0,
            description="Offset the surface relative to the curve"
            )
    # TORUS KNOT Options
    torus_p : IntProperty(
            name="p",
            default=2,
            min=1, soft_min=1,
            description="Number of Revolutions around the torus hole before closing the knot"
            )
    torus_q : IntProperty(
            name="q",
            default=3,
            min=1, soft_min=1,
            description="Number of Spins through the torus hole before closing the knot"
            )
    flip_p : BoolProperty(
            name="Flip p",
            default=False,
            description="Flip Revolution direction"
            )
    flip_q : BoolProperty(
            name="Flip q",
            default=False,
            description="Flip Spin direction"
            )
    multiple_links : BoolProperty(
            name="Multiple Links",
            default=True,
            description="Generate all links or just one link when q and q are not co-primes"
            )
    torus_u : IntProperty(
            name="Rev. Multiplier",
            default=1,
            min=1, soft_min=1,
            description="Revolutions Multiplier"
            )
    torus_v : IntProperty(
            name="Spin Multiplier",
            default=1,
            min=1, soft_min=1,
            description="Spin multiplier"
            )
    torus_rP : FloatProperty(
            name="Revolution Phase",
            default=0.0,
            min=0.0, soft_min=0.0,
            description="Phase revolutions by this radian amount"
            )
    torus_sP : FloatProperty(
            name="Spin Phase",
            default=0.0,
            min=0.0, soft_min=0.0,
            description="Phase spins by this radian amount"
            )
    # TORUS DIMENSIONS options
    mode : EnumProperty(
            name="Torus Dimensions",
            items=(("MAJOR_MINOR", "Major/Minor",
                    "Use the Major/Minor radii for torus dimensions."),
                    ("EXT_INT", "Exterior/Interior",
                    "Use the Exterior/Interior radii for torus dimensions.")),
            update=mode_update_callback,
            )
    torus_R : FloatProperty(
            name="Major Radius",
            min=0.00, max=100.0,
            default=1.0,
            subtype='DISTANCE',
            unit='LENGTH',
            description="Radius from the torus origin to the center of the cross section"
            )
    torus_r : FloatProperty(
            name="Minor Radius",
            min=0.00, max=100.0,
            default=.25,
            subtype='DISTANCE',
            unit='LENGTH',
            description="Radius of the torus' cross section"
            )
    torus_iR : FloatProperty(
            name="Interior Radius",
            min=0.00, max=100.0,
            default=.75,
            subtype='DISTANCE',
            unit='LENGTH',
            description="Interior radius of the torus (closest to the torus center)"
            )
    torus_eR : FloatProperty(
            name="Exterior Radius",
            min=0.00, max=100.0,
            default=1.25,
            subtype='DISTANCE',
            unit='LENGTH',
            description="Exterior radius of the torus (farthest from the torus center)"
            )
    torus_s : FloatProperty(
            name="Scale",
            min=0.01, max=100.0,
            default=1.00,
            description="Scale factor to multiply the radii"
            )
    torus_h : FloatProperty(
            name="Height",
            default=1.0,
            min=0.0, max=100.0,
            description="Scale along the local Z axis"
            )
    # CURVE options
    torus_res : IntProperty(
            name="Curve Resolution",
            default=100,
            min=3, soft_min=3,
            description="Number of control vertices in the curve"
            )
    segment_res : IntProperty(
            name="Segment Resolution",
            default=12,
            min=1, soft_min=1,
            description="Curve subdivisions per segment"
            )
    SplineTypes = [
            ('POLY', "Poly", "Poly type"),
            ('NURBS', "Nurbs", "Nurbs type"),
            ('BEZIER', "Bezier", "Bezier type")]
    outputType : EnumProperty(
            name="Output splines",
            default='BEZIER',
            description="Type of splines to output",
            items=SplineTypes,
            )
    bezierHandles = [
            ('VECTOR', "Vector", "Bezier Handles type - Vector"),
            ('AUTO', "Auto", "Bezier Handles type - Automatic"),
            ]
    handleType : EnumProperty(
            name="Handle type",
            default='AUTO',
            items=bezierHandles,
            description="Bezier handle type",
            )
    adaptive_resolution : BoolProperty(
            name="Adaptive Resolution",
            default=False,
            description="Auto adjust curve resolution based on TK length",
            )
    edit_mode : BoolProperty(
            name="Show in edit mode",
            default=True,
            description="Show in edit mode"
            )

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

        # extra parameters toggle
        layout.prop(self, "options_plus")

        # TORUS KNOT Parameters
        col = layout.column()
        col.label(text="Torus Knot Parameters:")

        box = layout.box()
        split = box.split(factor=0.85, align=True)
        split.prop(self, "torus_p", text="Revolutions")
        split.prop(self, "flip_p", toggle=True, text="",
                   icon='ARROW_LEFTRIGHT')

        split = box.split(factor=0.85, align=True)
        split.prop(self, "torus_q", text="Spins")
        split.prop(self, "flip_q", toggle=True, text="",
                   icon='ARROW_LEFTRIGHT')

        links = gcd(self.torus_p, self.torus_q)
        info = "Multiple Links"

        if links > 1:
            info += "  ( " + str(links) + " )"
        box.prop(self, 'multiple_links', text=info)

        if self.options_plus:
            box = box.box()
            col = box.column(align=True)
            col.prop(self, "torus_u")
            col.prop(self, "torus_v")

            col = box.column(align=True)
            col.prop(self, "torus_rP")
            col.prop(self, "torus_sP")

        # TORUS DIMENSIONS options
        col = layout.column(align=True)
        col.label(text="Torus Dimensions:")
        box = layout.box()
        col = box.column(align=True)
        col.row().prop(self, "mode", expand=True)

        if self.mode == "MAJOR_MINOR":
            col = box.column(align=True)
            col.prop(self, "torus_R")
            col.prop(self, "torus_r")
        else:  # EXTERIOR-INTERIOR
            col = box.column(align=True)
            col.prop(self, "torus_eR")
            col.prop(self, "torus_iR")

        if self.options_plus:
            box = box.box()
            col = box.column(align=True)
            col.prop(self, "torus_s")
            col.prop(self, "torus_h")

        # CURVE options
        col = layout.column(align=True)
        col.label(text="Curve Options:")
        box = layout.box()

        col = box.column()
        col.label(text="Output Curve Type:")
        col.row().prop(self, "outputType", expand=True)

        depends = box.column()
        depends.prop(self, "torus_res")
        # deactivate the "curve resolution" if "adaptive resolution" is enabled
        depends.enabled = not self.adaptive_resolution

        box.prop(self, "adaptive_resolution")
        box.prop(self, "segment_res")

        # SURFACE options
        col = layout.column()
        col.label(text="Geometry Options:")
        box = layout.box()
        box.prop(self, "geo_surface")
        if self.geo_surface:
            col = box.column(align=True)
            col.prop(self, "geo_bDepth")
            col.prop(self, "geo_bRes")

            col = box.column(align=True)
            col.prop(self, "geo_extrude")
            col.prop(self, "geo_offset")

        # COLOR options
        col = layout.column()
        col.label(text="Color Options:")
        box = layout.box()
        box.prop(self, "use_colors")
        if self.use_colors and self.options_plus:
            box = box.box()
            box.prop(self, "colorSet")
            box.prop(self, "random_colors")
            box.prop(self, "saturation")

        col = layout.column()
        col.row().prop(self, "edit_mode", expand=True)

        # TRANSFORM options
        col = layout.column()
        col.label(text="Transform Options:")
        box = col.box()
        box.prop(self, "location")
        box.prop(self, "absolute_location")
        box.prop(self, "rotation")

    @classmethod
    def poll(cls, context):
        return context.scene is not None

    def execute(self, context):
        # turn off 'Enter Edit Mode'
        use_enter_edit_mode = bpy.context.preferences.edit.use_enter_edit_mode
        bpy.context.preferences.edit.use_enter_edit_mode = False

        if self.mode == 'EXT_INT':
            # adjust the equivalent radii pair : (R,r) <=> (eR,iR)
            self.torus_R = (self.torus_eR + self.torus_iR) * 0.5
            self.torus_r = (self.torus_eR - self.torus_iR) * 0.5

        if self.adaptive_resolution:
            # adjust curve resolution automatically based on (p,q,R,r) values
            p = self.torus_p
            q = self.torus_q
            R = self.torus_R
            r = self.torus_r
            links = gcd(p, q)

            # get an approximate length of the whole TK curve
            # upper bound approximation
            maxTKLen = 2 * pi * sqrt(p * p * (R + r) * (R + r) + q * q * r * r)
            # lower bound approximation
            minTKLen = 2 * pi * sqrt(p * p * (R - r) * (R - r) + q * q * r * r)
            avgTKLen = (minTKLen + maxTKLen) / 2  # average approximation

            if DEBUG:
                print("Approximate average TK length = %.2f" % avgTKLen)

            # x N factor = control points per unit length
            self.torus_res = max(3, int(avgTKLen / links) * 8)

        # update align matrix
        self.align_matrix = align_matrix(self, context)

        # create the curve
        create_torus_knot(self, context)

        if use_enter_edit_mode:
            bpy.ops.object.mode_set(mode = 'EDIT')

        # restore pre operator state
        bpy.context.preferences.edit.use_enter_edit_mode = use_enter_edit_mode

        if self.edit_mode:
            bpy.ops.object.mode_set(mode = 'EDIT')
        else:
            bpy.ops.object.mode_set(mode = 'OBJECT')

        return {'FINISHED'}

    def invoke(self, context, event):
        self.execute(context)

        return {'FINISHED'}

# Register
classes = [
    torus_knot_plus
]

def register():
    from bpy.utils import register_class
    for cls in classes:
        register_class(cls)

def unregister():
    from bpy.utils import unregister_class
    for cls in reversed(classes):
        unregister_class(cls)

if __name__ == "__main__":
    register()
