# ##### 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 3
#  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-80 compliant>

# This script was developed with financial support from the Foundation for
# Science and Technology of Portugal, under the grant SFRH/BD/66452/2009.


bl_info = {
    "name": "Acclaim Motion Capture Files (.asf, .amc)",
    "author": "Daniel Monteiro Basso <daniel@basso.inf.br>",
    "version": (2013, 1, 26, 1),
    "blender": (2, 65, 9),
    "location": "File > Import-Export",
    "description": "Imports Acclaim Skeleton and Motion Capture Files",
    "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/"
                "Scripts/Import-Export/Acclaim_Importer",
    "category": "Import-Export",
}


import re
import bpy
from mathutils import Vector, Matrix
from math import radians, degrees
from bpy.props import (
        StringProperty,
        BoolProperty,
        FloatProperty,
        IntProperty,
        )


class DataStructure:
    """
        Parse the Skeleton and Motion Files to an internal data structure.
    """
    doc = re.compile(r"(?ms):(\w+)\s+([^:]+)")
    block = re.compile(r"(?ms)begin\s+(.*?)\s+end")
    bonedata = re.compile(r"(?ms)(name|direction|length|axis|dof)\s+(.*?)\s*$"
                          "|limits(\s.*)")

    def __init__(self, file_path, scale=1.):
        self.scale = scale
        source = open(file_path, encoding="utf-8", errors="replace").read()
        sections = dict(DataStructure.doc.findall(source))
        if not sections:
            raise ValueError("Wrong file structure.")

        if 'units' in sections:
            units = dict(u.strip().split()
                for u in sections['units'].splitlines()
                if u.strip())
            if 'length' in units:
                self.scale /= float(units['length'])

        if 'bonedata' not in sections:
            raise ValueError("Bone data section not found.")
        bm = DataStructure.block.findall(sections['bonedata'])
        if not bm:
            raise ValueError("Bone data section malformed.")
        self.bones = {'root': {
            'dof': ['X', 'Y', 'Z'],
            'direction': Vector(),  # should be orientation of root sector
            'length': 1,
            'axis': Matrix(),
            'axis_inv': Matrix(),
            }}
        for b in bm:
            bd = dict((i[0] or 'limits', i[0] and i[1] or i[2])
                        for i in DataStructure.bonedata.findall(b))
            for k in bd:
                s = [t for t in re.split(r"[^a-zA-Z0-9-+.]", bd[k]) if t]
                if k == 'axis':
                    rot = Matrix()
                    for ang, basis in zip(s[:3], s[3].upper()):
                        rot = Matrix.Rotation(radians(float(ang)),
                                              4, basis) * rot
                    bd['axis'] = rot
                elif k == 'direction':
                    bd[k] = Vector([float(n) for n in s])
                elif k == 'length':
                    bd[k] = float(s[0]) * self.scale
                elif k == 'dof':
                    bd[k] = [a[1].upper() for a in s]  # only rotations
                elif k == 'limits':
                    bd[k] = s
            if 'axis' in bd:
                bd['axis_inv'] = bd['axis'].inverted()
            self.bones[bd['name']] = bd

        if 'hierarchy' not in sections:
            raise ValueError("Hierarchy section not found.")
        hm = DataStructure.block.search(sections['hierarchy'])
        if not hm:
            raise ValueError("Hierarchy section malformed.")
        self.hierarchy = {}
        for l in hm.group(1).splitlines():
            t = l.strip().split()
            self.hierarchy[t[0]] = t[1:]

    def scan_motion_capture(self, filename, skip=5):
        """
            Parse an Acclaim Motion Capture file and iterates over the data
        """
        amc = open(filename, encoding="utf-8", errors="replace")
        l = ' '
        while l and not l[0].isdigit():
            l = amc.readline().strip()
        while l:
            frame = int(l)
            bdefs = []
            while True:
                l = amc.readline().strip()
                if not l or l[0].isdigit():
                    break
                bdefs.append(l.split())
            if (frame - 1) % skip != 0:
                continue
            self.pose_def = {}
            for b in bdefs:
                vs = [float(v) for v in b[1:]]
                if b[0] == 'root':
                    loc = Vector(vs[:3]) * self.scale
                    vs = vs[3:]
                rot = Matrix()
                if 'dof' not in self.bones[b[0]]:
                    # If 'dof' isn't defined it probably means the AMC comes
                    # from a different origin than the ASF, such as the
                    # AMC exporter in this package. Assume XYZ order.
                    self.bones[b[0]]['dof'] = ['X', 'Y', 'Z']
                for dof, ang in zip(self.bones[b[0]]['dof'], vs):
                    rot = Matrix.Rotation(radians(ang), 4, dof) * rot
                self.pose_def[b[0]] = rot
            pose = self.calculate_pose(Matrix.Translation(loc))
            yield(frame / skip + 1, pose)
        amc.close()

    def calculate_pose(self, parent, bone='root'):
        """
            Calculate each bone transform iteratively
        """
        bd = self.bones[bone]
        tail = Matrix.Translation(bd['direction'] * bd['length'])
        if bone in self.pose_def:
            tail = bd['axis'] * self.pose_def[bone] * bd['axis_inv'] * tail
        world = parent * tail
        local = tail
        yield(bone, world, local)
        if bone in self.hierarchy:
            for child in self.hierarchy[bone]:
                for b, w, l in self.calculate_pose(world, child):
                    yield(b, w, l)


class StructureBuilder(DataStructure):
    def __init__(self, file_path, name="Skel", scale=1.):
        """
            Setup instance data and load the skeleton
        """
        self.file_path = file_path
        self.name = name
        self.user_def_scale = scale
        DataStructure.__init__(self, file_path, scale)

    def create_armature(self):
        """
            Create the armature and leave it in edit mode
        """
        bpy.context.view_layer.objects.active = None
        bpy.ops.object.add(type='ARMATURE', enter_editmode=True)
        self.object = bpy.context.view_layer.objects.active
        self.armature = self.object.data
        self.object.name = self.name
        self.armature.name = self.name
        self.armature.display_type = 'STICK'
        self.object['source_file_path'] = self.file_path
        self.object['source_scale'] = self.user_def_scale
        self.object['MhxArmature'] = 'Daz'

    def load_armature(self, obj):
        """
            Assign the armature object to be used for loading motion
        """
        self.object = obj

    def build_structure(self, use_limits=False):
        """
            Create the root bone and start the recursion, exit edit mode
        """
        self.use_limits = use_limits
        bpy.ops.armature.bone_primitive_add(name='root')
        root_dir = Vector((0, 0.1 * self.scale, 0))
        bpy.ops.transform.translate(value=root_dir + Vector((.0, .0, -1.0)))
        self.recursive_add_bones()
        bpy.ops.armature.select_all(action='DESELECT')
        bpy.ops.object.mode_set(mode='OBJECT')

    def recursive_add_bones(self, parent_name='root'):
        """
            Traverse the hierarchy creating bones and constraints
        """
        if  parent_name not in self.hierarchy:
            return
        for name in self.hierarchy[parent_name]:
            self.add_bone(name, parent_name)
            if self.use_limits:
                self.add_limit_constraint(name)
            self.recursive_add_bones(name)

    def add_bone(self, name, parent_name):
        """
            Extrude a bone from the specified parent, and configure it
        """
        bone_def = self.bones[name]
        bpy.ops.armature.select_all(action='DESELECT')
        # select tail of parent bone
        self.armature.edit_bones[parent_name].select_tail = True
        # extrude and name the new bone
        bpy.ops.armature.extrude()
        self.armature.edit_bones[-1].name = name
        # translate the tail of the new bone
        tail = bone_def['direction'] * bone_def['length']
        bpy.ops.transform.translate(value=tail)
        # align the bone to the rotation axis
        axis = bone_def['axis'].to_3x3()
        vec = axis * Vector((.0, .0, -1.0))
        self.armature.edit_bones[-1].align_roll(vector=vec)

    def add_limit_constraint(self, name):
        """
            Create the limit rotation constraint of the specified bone
        """
        bpy.ops.object.mode_set(mode='POSE')
        bone_def = self.bones[name]
        dof = bone_def['dof'] if 'dof' in bone_def else ''
        pb = self.object.pose.bones[name]
        self.armature.bones.active = self.armature.bones[name]
        bpy.ops.pose.constraint_add(type='LIMIT_ROTATION')
        constr = pb.constraints[-1]
        constr.owner_space = 'LOCAL'
        constr.use_limit_x = True
        constr.use_limit_y = True
        constr.use_limit_z = True
        if dof:
            limits = (radians(float(v)) for v in bone_def['limits'])
            if 'X' in dof:
                constr.min_x = next(limits)
                constr.max_x = next(limits)
            if 'Y' in dof:
                constr.max_z = -next(limits)
                constr.min_z = -next(limits)
            if 'Z' in dof:
                constr.min_y = next(limits)
                constr.max_y = next(limits)
        bpy.ops.object.mode_set(mode='EDIT')

    def load_motion_capture(self, filename, frame_skip=5, use_frame_no=False):
        """
            Create the keyframes for a motion capture file
        """
        bpy.context.active_object.animation_data_clear()
        bpy.ops.object.mode_set(mode='POSE')
        bpy.ops.pose.select_all(action='SELECT')
        bpy.ops.pose.rot_clear()
        bpy.ops.pose.loc_clear()
        self.rest = {}
        for b in self.object.pose.bones:
            self.rest[b.name] = (b,
                                 b.matrix.to_3x3(),
                                 b.matrix.to_3x3().inverted(),
                                 )
        self.fno = 1  # default Blender scene start frame
        self.use_frame_no = use_frame_no
        self.motion = iter(self.scan_motion_capture(filename, frame_skip))

    def apply_next_frame(self):
        try:
            frame, bones = next(self.motion)
        except StopIteration:
            return False
        regframe = frame if self.use_frame_no else self.fno
        self.fno += 1
        for name, w, l in bones:
            b, P, Pi = self.rest[name]
            if name == 'root':
                b.location = w.to_translation()
                b.keyframe_insert('location', -1, regframe, name)
            T = Pi * l.to_3x3() * P
            b.rotation_quaternion = T.to_quaternion()
            b.keyframe_insert('rotation_quaternion', -1, regframe, name)
        return True


class AsfImporter(bpy.types.Operator):
    #
    "Load an Acclaim Skeleton File"
    #
    bl_idname = "import_anim.asf"
    bl_label = "Import ASF"

    filepath: StringProperty(
            subtype='FILE_PATH',
            )
    armature_name: StringProperty(
            name="Armature Name", maxlen=63,
            default="Skeleton",
            description="Name of the new object",
            )
    use_limits: BoolProperty(
            name="Use Limits", default=False,
            description="Create bone constraints for limits",
            )
    scale: FloatProperty(
            name="Scale",
            default=1.0,
            description="Scale the armature by this value",
            min=0.0001, max=1000000.0,
            soft_min=0.001, soft_max=100.0,
            )
    from_inches: BoolProperty(
            name="Convert from inches to metric",
            default=False, description="Scale by 2.54/100",
            )
    use_rot_x: BoolProperty(
            name="Rotate X 90 degrees",
            default=False,
            description="Correct orientation",
            )
    use_rot_z: BoolProperty(
            name="Rotate Z 90 degrees",
            default=False,
            description="Correct orientation",
            )

    filter_glob: StringProperty(default="*.asf", options={'HIDDEN'})

    def execute(self, context):
        uscale = (0.0254 if self.from_inches else 1.0)
        sb = StructureBuilder(self.filepath,
                              self.armature_name,
                              self.scale * uscale,
                              )
        sb.create_armature()
        sb.build_structure(self.use_limits)
        if self.use_rot_x:
            bpy.ops.transform.rotate(value=radians(90.0), axis=(1, 0, 0))
        if self.use_rot_z:
            bpy.ops.transform.rotate(value=radians(90.0), axis=(0, 0, 1))
        return {'FINISHED'}

    def invoke(self, context, event):
        wm = context.window_manager
        wm.fileselect_add(self)
        return {'RUNNING_MODAL'}


class AmcAnimator(bpy.types.Operator):
    """
        Load an Acclaim Motion Capture
    """
    bl_idname = "import_anim.amc_animate"
    bl_label = "Animate AMC"

    sb = None
    timer = None

    def modal(self, context, event):
        if event.type == 'ESC':
            self.cancel(context)
            return {'CANCELLED'}
        if event.type == 'TIMER':
            if not self.sb.apply_next_frame():
                self.cancel(context)
                return {'FINISHED'}
        return {'PASS_THROUGH'}

    def execute(self, context):
        self.timer = context.window_manager.\
            event_timer_add(0.001, context.window)
        context.window_manager.modal_handler_add(self)
        return {'RUNNING_MODAL'}

    def cancel(self, context):
        bpy.context.scene.frame_set(bpy.context.scene.frame_current)
        context.window_manager.event_timer_remove(self.timer)
        bpy.ops.object.mode_set(mode='OBJECT')


class AmcImporter(bpy.types.Operator):
    #
    "Load an Acclaim Motion Capture"
    #
    bl_idname = "import_anim.amc"
    bl_label = "Import AMC"

    filepath: StringProperty(
            subtype='FILE_PATH',
            )
    frame_skip: IntProperty(
            name="Fps divisor",
            default=4,
            # usually the sample rate is 120, so the default 4 gives you 30fps
            description="Frame supersampling factor",
            min=1,
            )
    use_frame_no: BoolProperty(
            name="Use frame numbers",
            default=False,
            description="Offset start of animation according to the source",
            )

    filter_glob: StringProperty(default="*.amc", options={'HIDDEN'})

    @classmethod
    def poll(cls, context):
        ob = context.active_object
        return (ob and ob.type == 'ARMATURE' and 'source_file_path' in ob)

    def execute(self, context):
        ob = context.active_object
        sb = StructureBuilder(
            ob['source_file_path'],
            ob.name,
            ob['source_scale'])
        sb.load_armature(ob)
        sb.load_motion_capture(self.filepath,
                               self.frame_skip,
                               self.use_frame_no)
        AmcAnimator.sb = sb
        bpy.ops.import_anim.amc_animate()
        return {'FINISHED'}

    def invoke(self, context, event):
        ob = context.active_object
        import os
        if not os.path.exists(ob['source_file_path']):
            self.report({'ERROR'},
                "Original Armature source file not found... was it moved?")
            return {'CANCELLED'}
        wm = context.window_manager
        wm.fileselect_add(self)
        return {'RUNNING_MODAL'}


class AmcExporter(bpy.types.Operator):
    #
    "Save an animation in Acclaim format"
    #
    bl_idname = "export_anim.amc"
    bl_label = "Export AMC"

    filepath: StringProperty(
            subtype='FILE_PATH'
            )
    use_scale: BoolProperty(
            name="Use original armature scale",
            default=True,
            description="Scale movement to original scale if available",
            )

    filter_glob: StringProperty(default="*.amc", options={'HIDDEN'})

    @classmethod
    def poll(cls, context):
        ob = context.active_object
        return (ob and ob.type == 'ARMATURE' and 'source_file_path' in ob)

    def execute(self, context):
        ob = context.active_object
        scn = context.scene
        out = open(self.filepath, "w")
        out.write(":FULLY-SPECIFIED\n:DEGREES\n")
        ds = DataStructure(ob['source_file_path'], ob['source_scale'])
        scale = ds.scale if self.use_scale else 1
        for frame in range(scn.frame_start, scn.frame_end + 1):
            out.write("{}\n".format(frame))
            scn.frame_set(frame)
            for bone in ob.pose.bones:
                out.write("{} ".format(bone.name))
                if bone.name == "root":
                    loc = bone.location / scale
                    out.write(" ".join(str(v) for v in loc) + " ")
                    rot = bone.matrix.to_euler()
                else:
                    A = ds.bones[bone.name]['axis'].to_3x3()
                    R = ob.data.bones[bone.name].matrix_local.to_3x3()
                    AiR = A.transposed() * R
                    AiR_i = AiR.inverted()
                    rot = (AiR * bone.matrix_basis.to_3x3() * AiR_i).to_euler()
                out.write(" ".join(str(degrees(v)) for v in rot) + "\n")
        return {'FINISHED'}

    def invoke(self, context, event):
        wm = context.window_manager
        wm.fileselect_add(self)
        return {'RUNNING_MODAL'}


def menu_func_s(self, context):
    self.layout.operator(AsfImporter.bl_idname,
                         text="Acclaim Skeleton File (.asf)")


def menu_func_mi(self, context):
    self.layout.operator(AmcImporter.bl_idname,
                         text="Acclaim Motion Capture (.amc)")


def menu_func_me(self, context):
    self.layout.operator(AmcExporter.bl_idname,
                         text="Acclaim Motion Capture (.amc)")


def register():
    bpy.utils.register_module(__name__)
    bpy.types.TOPBAR_MT_file_import.append(menu_func_s)
    bpy.types.TOPBAR_MT_file_import.append(menu_func_mi)
    bpy.types.TOPBAR_MT_file_export.append(menu_func_me)


def unregister():
    bpy.utils.unregister_module(__name__)
    bpy.types.TOPBAR_MT_file_import.remove(menu_func_s)
    bpy.types.TOPBAR_MT_file_import.remove(menu_func_mi)
    bpy.types.TOPBAR_MT_file_export.remove(menu_func_me)


if __name__ == "__main__":
    register()
