├── .gitignore ├── gui ├── gui.py ├── prop.py ├── panel.py └── operator.py ├── ops ├── common.py ├── ifp_importer.py ├── armature_constructor.py ├── action_retargeter.py └── ifp_exporter.py ├── __init__.py └── gtaLib └── ifp.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ -------------------------------------------------------------------------------- /gui/gui.py: -------------------------------------------------------------------------------- 1 | from .operator import * 2 | from .panel import * 3 | from .prop import * 4 | -------------------------------------------------------------------------------- /gui/prop.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from bpy.props import ( 4 | BoolProperty, 5 | PointerProperty, 6 | ) 7 | 8 | 9 | class IFP_ActionProps(bpy.types.PropertyGroup): 10 | 11 | use_export: BoolProperty(name="Use Export", default=True) 12 | target_armature: PointerProperty(name="Target Armature", type=bpy.types.Object) 13 | 14 | def register(): 15 | bpy.types.Action.ifp = bpy.props.PointerProperty(type=IFP_ActionProps) 16 | -------------------------------------------------------------------------------- /ops/common.py: -------------------------------------------------------------------------------- 1 | from mathutils import Matrix 2 | 3 | 4 | def set_keyframe(curves, frame, values): 5 | for i, c in enumerate(curves): 6 | c.keyframe_points.add(1) 7 | c.keyframe_points[-1].co = frame, values[i] 8 | c.keyframe_points[-1].interpolation = 'LINEAR' 9 | 10 | 11 | def translation_matrix(v): 12 | return Matrix.Translation(v) 13 | 14 | 15 | def scale_matrix(v): 16 | mat = Matrix.Identity(4) 17 | mat[0][0], mat[1][1], mat[2][2] = v[0], v[1], v[2] 18 | return mat 19 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from .gui import gui 4 | 5 | 6 | bl_info = { 7 | "name": "GTA Animation", 8 | "author": "Psycrow", 9 | "version": (0, 1, 0), 10 | "blender": (2, 81, 0), 11 | "location": "File > Import-Export", 12 | "description": "Import / Export GTA Animation (.ifp)", 13 | "warning": "", 14 | "wiki_url": "", 15 | "support": 'COMMUNITY', 16 | "category": "Import-Export" 17 | } 18 | 19 | classes = ( 20 | gui.SCENE_OT_ifp_construct_armature, 21 | gui.OBJECT_OT_ifp_retarget_action, 22 | gui.OBJECT_OT_ifp_untarget_action, 23 | gui.VIEW3D_PT_IFP_Tools, 24 | gui.IFP_ActionProps, 25 | gui.ImportGtaIfp, 26 | gui.ExportGtaIfp, 27 | gui.ImportReport, 28 | ) 29 | 30 | 31 | def register(): 32 | for cls in classes: 33 | bpy.utils.register_class(cls) 34 | 35 | bpy.types.TOPBAR_MT_file_import.append(gui.menu_func_import) 36 | bpy.types.TOPBAR_MT_file_export.append(gui.menu_func_export) 37 | 38 | 39 | def unregister(): 40 | bpy.types.TOPBAR_MT_file_import.remove(gui.menu_func_import) 41 | bpy.types.TOPBAR_MT_file_export.remove(gui.menu_func_export) 42 | 43 | for cls in classes: 44 | bpy.utils.unregister_class(cls) 45 | 46 | 47 | if __name__ == "__main__": 48 | register() 49 | -------------------------------------------------------------------------------- /gui/panel.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from .operator import ( 4 | SCENE_OT_ifp_construct_armature, 5 | OBJECT_OT_ifp_retarget_action, 6 | OBJECT_OT_ifp_untarget_action, 7 | ) 8 | 9 | 10 | class VIEW3D_PT_IFP_Tools(bpy.types.Panel): 11 | bl_idname = "VIEW3D_PT_ifp_tools" 12 | bl_label = "GTA IFP" 13 | 14 | bl_space_type = "VIEW_3D" 15 | bl_region_type = "UI" 16 | bl_category = "GTA IFP" 17 | 18 | def draw(self, context): 19 | layout = self.layout 20 | layout.operator(SCENE_OT_ifp_construct_armature.bl_idname, icon="ARMATURE_DATA") 21 | layout.operator(OBJECT_OT_ifp_retarget_action.bl_idname, icon="ACTION") 22 | layout.operator(OBJECT_OT_ifp_untarget_action.bl_idname, icon="REMOVE") 23 | 24 | arm_obj = context.object 25 | if arm_obj and type(arm_obj.data) != bpy.types.Armature: 26 | arm_obj = None 27 | 28 | act = None 29 | if arm_obj and arm_obj.animation_data: 30 | act = arm_obj.animation_data.action 31 | 32 | box = layout.box() 33 | box.label(text=f"Active Armature: {arm_obj.name if arm_obj else None}") 34 | box.label(text=f"Active Action: {act.name if act else None}") 35 | 36 | if act: 37 | action_target_arm = act.ifp.target_armature 38 | box.label(text=f"Target Armature: {action_target_arm.name if action_target_arm else None}") 39 | -------------------------------------------------------------------------------- /ops/ifp_importer.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from .common import set_keyframe 4 | from ..gtaLib.ifp import Animation 5 | 6 | 7 | def create_action(anim:Animation, fps:float): 8 | act = bpy.data.actions.new(anim.name) 9 | 10 | if bpy.app.version < (4, 4, 0): 11 | group = act.groups.new(name='ifp') 12 | fcurves = act.fcurves 13 | 14 | else: 15 | slot = act.slots.new(id_type='OBJECT', name='IFP') 16 | layer = act.layers.new('Layer') 17 | strip = layer.strips.new(type='KEYFRAME') 18 | channelbag = strip.channelbag(slot, ensure=True) 19 | 20 | group = channelbag.groups.new('ifp') 21 | fcurves = channelbag.fcurves 22 | 23 | group.mute = group.lock = True 24 | 25 | for b in anim.bones: 26 | bone_name = b.name 27 | bone_id = b.bone_id if b.use_bone_id else None 28 | 29 | data_path_prefix = f"ifp//{bone_name}//{bone_id}//" 30 | 31 | has_location = b.keyframe_type[2] == 'T' 32 | has_scale = b.keyframe_type[3] == 'S' 33 | 34 | cr = [fcurves.new(data_path=data_path_prefix + 'R', index=i) for i in range(4)] 35 | for c in cr: 36 | c.mute = c.lock = True 37 | c.group = group 38 | 39 | if has_location: 40 | cl = [fcurves.new(data_path=data_path_prefix + 'T', index=i) for i in range(3)] 41 | for c in cl: 42 | c.mute = c.lock = True 43 | c.group = group 44 | 45 | if has_scale: 46 | cs = [fcurves.new(data_path=data_path_prefix + 'S', index=i) for i in range(3)] 47 | for c in cs: 48 | c.mute = c.lock = True 49 | c.group = group 50 | 51 | for kf in b.keyframes: 52 | time = kf.time * fps 53 | 54 | if has_location: 55 | set_keyframe(cl, time, kf.pos) 56 | 57 | if has_scale: 58 | set_keyframe(cs, time, kf.scl) 59 | 60 | set_keyframe(cr, time, kf.rot) 61 | 62 | return act 63 | -------------------------------------------------------------------------------- /ops/armature_constructor.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from mathutils import Matrix, Vector 4 | 5 | 6 | def clear_extension(string): 7 | k = string.rfind('.') 8 | return string if k < 0 else string[:k] 9 | 10 | 11 | class ArmatureConstructor: 12 | 13 | arm_obj = None 14 | bones_map = {} 15 | 16 | @staticmethod 17 | def construct_bone(obj, root_bone=None): 18 | self = ArmatureConstructor 19 | 20 | if obj.type == 'MESH' and not obj.dff.is_frame: 21 | self.bones_map[obj] = root_bone.name 22 | return 23 | 24 | bone_name = clear_extension(obj.name) 25 | mat = self.arm_obj.matrix_world.inverted() @ obj.matrix_world 26 | 27 | bone = self.arm_obj.data.edit_bones.new(bone_name) 28 | bone.head = mat.translation 29 | bone.tail = mat @ Vector((0, 0.05, 0)) 30 | bone.parent = root_bone 31 | bone.use_connect = False 32 | bone['bone_id'] = -1 33 | 34 | self.bones_map[obj] = bone.name 35 | 36 | for ch_obj in obj.children: 37 | self.construct_bone(ch_obj, bone) 38 | 39 | 40 | @staticmethod 41 | def construct_armature(context, root_obj): 42 | self = ArmatureConstructor 43 | 44 | arm_name = root_obj.name 45 | root_obj.name += "_csobj" 46 | 47 | arm_data = bpy.data.armatures.new(arm_name) 48 | arm_obj = bpy.data.objects.new(arm_name, arm_data) 49 | arm_obj.matrix_world = root_obj.matrix_world 50 | 51 | self.arm_obj = arm_obj 52 | self.bones_map = {} 53 | 54 | context.collection.objects.link(arm_obj) 55 | context.view_layer.objects.active = arm_obj 56 | bpy.ops.object.mode_set(mode='EDIT') 57 | 58 | for obj in root_obj.children: 59 | self.construct_bone(obj) 60 | 61 | bpy.ops.object.mode_set(mode='OBJECT') 62 | 63 | for obj, bone_name in self.bones_map.items(): 64 | if obj.type == 'EMPTY': 65 | bpy.data.objects.remove(obj) 66 | continue 67 | 68 | obj.parent = arm_obj 69 | obj.parent_type = 'BONE' 70 | obj.parent_bone = bone_name 71 | obj.matrix_local = Matrix() 72 | obj.matrix_parent_inverse = arm_obj.matrix_world.inverted() @ Matrix.Translation((0, -0.05, 0)) 73 | obj.dff.is_frame = False 74 | 75 | collections = [] 76 | for col in bpy.data.collections: 77 | if root_obj.name in col.objects: 78 | col.objects.unlink(root_obj) 79 | collections.append(col) 80 | 81 | if collections: 82 | context.collection.objects.unlink(arm_obj) 83 | for col in collections: 84 | col.objects.link(arm_obj) 85 | 86 | bpy.data.objects.remove(root_obj) 87 | 88 | # TODO: Armature deconstrictor 89 | -------------------------------------------------------------------------------- /ops/action_retargeter.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from bpy_extras import anim_utils 4 | from collections import defaultdict 5 | from mathutils import Matrix, Quaternion 6 | 7 | from .common import set_keyframe, translation_matrix, scale_matrix 8 | 9 | 10 | POSEDATA_PREFIX = 'pose.bones["%s"].' 11 | 12 | 13 | def find_bone_by_id(arm_obj, bone_id): 14 | for bone in arm_obj.data.bones: 15 | if bone.get('bone_id') == bone_id: 16 | return bone 17 | 18 | 19 | def local_to_basis_matrix(local_matrix, global_matrix, parent_matrix): 20 | return global_matrix.inverted() @ (parent_matrix @ local_matrix) 21 | 22 | 23 | def get_ifp_channelbag(act): 24 | slot = act.slots.get('OBIFP') 25 | if slot: 26 | return anim_utils.action_get_channelbag_for_slot(act, slot) 27 | 28 | 29 | def untarget_action(act): 30 | act.ifp.target_armature = None 31 | 32 | if bpy.app.version < (4, 4, 0): 33 | groups = act.groups 34 | fcurves = act.fcurves 35 | 36 | else: 37 | channelbag = get_ifp_channelbag(act) 38 | if not channelbag: 39 | return 40 | 41 | groups = channelbag.groups 42 | fcurves = channelbag.fcurves 43 | 44 | ifp_group = groups.get('ifp') 45 | if not ifp_group: 46 | return 47 | 48 | # Clear fcurves 49 | for c in list(fcurves): 50 | if c.group != ifp_group: 51 | fcurves.remove(c) 52 | 53 | for group in list(groups): 54 | if group != ifp_group: 55 | groups.remove(group) 56 | 57 | 58 | def retarget_action(act, arm_obj): 59 | untarget_action(act) 60 | 61 | act.ifp.target_armature = arm_obj 62 | 63 | missing_bones = set() 64 | 65 | if bpy.app.version < (4, 4, 0): 66 | groups = act.groups 67 | fcurves = act.fcurves 68 | 69 | else: 70 | channelbag = get_ifp_channelbag(act) 71 | if not channelbag: 72 | return missing_bones 73 | 74 | groups = channelbag.groups 75 | fcurves = channelbag.fcurves 76 | 77 | ifp_group = groups.get('ifp') 78 | if not ifp_group: 79 | return missing_bones 80 | 81 | act_bones = {} 82 | for c in fcurves: 83 | _, bone_name, bone_id, movement = c.data_path.split('//') 84 | use_bone_id = bone_id != 'None' 85 | bone_id = int(bone_id) if use_bone_id else None 86 | 87 | bone_data = act_bones.get(bone_name) 88 | if not bone_data: 89 | bone_data = ( 90 | bone_id, 91 | defaultdict(lambda: [None, None, None, None]), 92 | defaultdict(lambda: [None, None, None]), 93 | defaultdict(lambda: [None, None, None]) 94 | ) 95 | 96 | chan = bone_data[{'R':1, 'T':2, 'S':3}[movement]] 97 | for kp in c.keyframe_points: 98 | k, v = kp.co 99 | chan[k][c.array_index] = v 100 | 101 | act_bones[bone_name] = bone_data 102 | 103 | for bone_name, bone_data in act_bones.items(): 104 | bone_id, rots, locs, scls = bone_data 105 | 106 | bone = None 107 | if bone_id is not None and bone_id != -1: 108 | bone = find_bone_by_id(arm_obj, bone_id) 109 | if not bone: 110 | bone = arm_obj.data.bones.get(bone_name) 111 | 112 | if not bone: 113 | missing_bones.add(bone_name) 114 | continue 115 | 116 | group = groups.new(name=bone_name) 117 | bone_name = bone.name 118 | pose_bone = arm_obj.pose.bones[bone_name] 119 | pose_bone.rotation_mode = 'QUATERNION' 120 | pose_bone.location = (0, 0, 0) 121 | pose_bone.rotation_quaternion = (1, 0, 0, 0) 122 | pose_bone.scale = (1, 1, 1) 123 | 124 | rest_mat = bone.matrix_local 125 | if bone.parent: 126 | parent_mat = bone.parent.matrix_local 127 | local_rot = (parent_mat.inverted_safe() @ rest_mat).to_quaternion() 128 | else: 129 | parent_mat = Matrix.Identity(4) 130 | local_rot = rest_mat.to_quaternion() 131 | 132 | cr = [fcurves.new(data_path=(POSEDATA_PREFIX % bone_name) + 'rotation_quaternion', index=i) for i in range(4)] 133 | for c in cr: 134 | c.group = group 135 | 136 | if locs: 137 | cl = [fcurves.new(data_path=(POSEDATA_PREFIX % bone_name) + 'location', index=i) for i in range(3)] 138 | for c in cl: 139 | c.group = group 140 | 141 | if scls: 142 | cs = [fcurves.new(data_path=(POSEDATA_PREFIX % bone_name) + 'scale', index=i) for i in range(3)] 143 | for c in cs: 144 | c.group = group 145 | 146 | prev_rot = None 147 | for time in sorted(rots.keys()): 148 | rot = local_rot.rotation_difference(Quaternion(rots[time])) 149 | if prev_rot: 150 | alt_rot = rot.copy() 151 | alt_rot.negate() 152 | if rot.rotation_difference(prev_rot).angle > alt_rot.rotation_difference(prev_rot).angle: 153 | rot = alt_rot 154 | prev_rot = rot 155 | set_keyframe(cr, time, rot) 156 | 157 | for time in sorted(locs.keys()): 158 | mat = translation_matrix(locs[time]) 159 | mat_basis = local_to_basis_matrix(mat, rest_mat, parent_mat) 160 | loc = mat_basis.to_translation() 161 | set_keyframe(cl, time, loc) 162 | 163 | for time in sorted(scls.keys()): 164 | mat = scale_matrix(scls[time]) 165 | mat_basis = local_to_basis_matrix(mat, rest_mat, parent_mat) 166 | scl = mat_basis.to_scale() 167 | set_keyframe(cs, time, scl) 168 | 169 | return missing_bones 170 | -------------------------------------------------------------------------------- /ops/ifp_exporter.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from bpy_extras import anim_utils 4 | from dataclasses import dataclass 5 | from mathutils import Euler, Matrix, Quaternion, Vector 6 | from typing import Dict, List 7 | 8 | from .common import translation_matrix, scale_matrix 9 | from ..gtaLib.ifp import Keyframe 10 | 11 | @dataclass 12 | class Transformation: 13 | location: Vector 14 | rotation_quaternion: Quaternion 15 | rotation_euler: Euler 16 | scale: Vector 17 | 18 | 19 | @dataclass 20 | class PoseData: 21 | bone_id: int 22 | bone: bpy.types.PoseBone 23 | transfomations: Dict[int, Transformation] 24 | type: List[str] 25 | overridden: bool 26 | 27 | 28 | def basis_to_local_matrix(basis_matrix, global_matrix, parent_matrix): 29 | return parent_matrix.inverted() @ global_matrix @ basis_matrix 30 | 31 | 32 | def get_action_channelbag(act, obj=None): 33 | obj_active_slot = obj.animation_data.action_slot if obj else None 34 | 35 | slot = act.slots.get('OBifp', obj_active_slot) 36 | if not slot: 37 | if len(act.slots) == 0: 38 | return 39 | slot = act.slots[0] 40 | 41 | return anim_utils.action_get_channelbag_for_slot(act, slot) 42 | 43 | 44 | def get_pose_data(arm_obj, act) -> Dict[str, PoseData]: 45 | pose_data: Dict[str, PoseData] = {} 46 | 47 | if bpy.app.version < (4, 4, 0): 48 | groups = act.groups 49 | fcurves = act.fcurves 50 | 51 | else: 52 | channelbag = get_action_channelbag(act, arm_obj) 53 | if not channelbag: 54 | return pose_data 55 | 56 | groups = channelbag.groups 57 | fcurves = channelbag.fcurves 58 | 59 | ifp_group = groups.get('ifp') 60 | 61 | taged_bones_map = {} 62 | 63 | # Collect active keyframes 64 | if arm_obj: 65 | for curve in fcurves: 66 | if curve.group == ifp_group: 67 | continue 68 | 69 | if 'pose.bones' not in curve.data_path: 70 | continue 71 | 72 | bone_name = curve.data_path.split('"')[1] 73 | bone = arm_obj.data.bones.get(bone_name) 74 | 75 | bone_id = bone.get('bone_id') 76 | if bone_id is None: 77 | continue 78 | 79 | bone_key = bone_id if bone_id != -1 else bone_name 80 | 81 | pd = taged_bones_map.get(bone_key) 82 | if pd is None: 83 | pd = PoseData( 84 | bone_id=bone_id, 85 | bone=bone, 86 | transfomations={}, 87 | type=['K', 'R', '0', '0'], 88 | overridden=False, 89 | ) 90 | 91 | for kp in curve.keyframe_points: 92 | time = int(kp.co[0]) 93 | if time not in pd.transfomations: 94 | pd.transfomations[time] = Transformation( 95 | location=Vector(), 96 | rotation_quaternion=Quaternion(), 97 | rotation_euler=Euler(), 98 | scale=Vector()) 99 | 100 | if curve.data_path == f'pose.bones["{bone_name}"].location': 101 | pd.transfomations[time].location[curve.array_index] = kp.co[1] 102 | pd.type[2] = 'T' 103 | 104 | elif curve.data_path == f'pose.bones["{bone_name}"].rotation_quaternion': 105 | pd.transfomations[time].rotation_quaternion[curve.array_index] = kp.co[1] 106 | 107 | elif curve.data_path == f'pose.bones["{bone_name}"].rotation_euler': 108 | pd.transfomations[time].rotation_euler[curve.array_index] = kp.co[1] 109 | 110 | elif curve.data_path == f'pose.bones["{bone_name}"].scale': 111 | pd.transfomations[time].scale[curve.array_index] = kp.co[1] 112 | pd.type[3] = 'S' 113 | 114 | taged_bones_map[bone_key] = pd 115 | 116 | # Merge with IFP stored keyframes 117 | for curve in fcurves: 118 | if curve.group != ifp_group: 119 | continue 120 | 121 | _, bone_name, bone_id, movement = curve.data_path.split('//') 122 | use_bone_id = bone_id != 'None' 123 | bone_id = int(bone_id) if use_bone_id else -1 124 | bone_key = bone_id if bone_id != -1 else bone_name 125 | 126 | pd = taged_bones_map.get(bone_key) 127 | if pd is not None: 128 | pd.overridden = True 129 | pose_data[bone_name] = pd 130 | continue 131 | 132 | pd = pose_data.get(bone_name) 133 | if pd is None: 134 | pd = PoseData( 135 | bone_id=bone_id, 136 | bone=None, 137 | transfomations={}, 138 | type=['K', 'R', '0', '0'], 139 | overridden=True, 140 | ) 141 | 142 | for kp in curve.keyframe_points: 143 | time = int(kp.co[0]) 144 | if time not in pd.transfomations: 145 | pd.transfomations[time] = Transformation( 146 | location=Vector(), 147 | rotation_quaternion=Quaternion(), 148 | rotation_euler=Euler(), 149 | scale=Vector()) 150 | 151 | if movement == 'L': 152 | pd.transfomations[time].location[curve.array_index] = kp.co[1] 153 | pd.type[2] = 'T' 154 | 155 | elif movement == 'R': 156 | pd.transfomations[time].rotation_quaternion[curve.array_index] = kp.co[1] 157 | 158 | elif movement == 'S': 159 | pd.transfomations[time].scale[curve.array_index] = kp.co[1] 160 | pd.type[3] = 'S' 161 | 162 | pose_data[bone_name] = pd 163 | 164 | # Merge with remaining active keyframes 165 | for pd in taged_bones_map.values(): 166 | if not pd.overridden: 167 | pose_data[pd.bone.name] = pd 168 | 169 | return pose_data 170 | 171 | 172 | def create_ifp_animations(context, ifp_cls, actions, fps): 173 | anim_cls = ifp_cls.get_animation_class() 174 | bone_cls = anim_cls.get_bone_class() 175 | animations = [] 176 | 177 | for act in actions: 178 | arm_obj = act.ifp.target_armature 179 | 180 | if bpy.app.version < (4, 4, 0): 181 | groups = act.groups 182 | 183 | else: 184 | channelbag = get_action_channelbag(act, arm_obj) 185 | groups = channelbag.groups if channelbag else [] 186 | 187 | # If there is no IFP data, use an active armature 188 | if not arm_obj and 'ifp' not in groups: 189 | arm_obj = context.object 190 | if arm_obj and type(arm_obj.data) != bpy.types.Armature: 191 | arm_obj = None 192 | 193 | anim = anim_cls(act.name, []) 194 | pose_data = get_pose_data(arm_obj, act) 195 | 196 | for bone_name, data in pose_data.items(): 197 | bone = data.bone 198 | if bone: 199 | rest_mat = bone.matrix_local 200 | if bone.parent: 201 | parent_mat = bone.parent.matrix_local 202 | local_rot = (parent_mat.inverted_safe() @ rest_mat).to_quaternion() 203 | else: 204 | parent_mat = Matrix.Identity(4) 205 | local_rot = rest_mat.to_quaternion() 206 | 207 | keyframes = [] 208 | for time, tr in data.transfomations.items(): 209 | kf_pos = tr.location 210 | if bone and arm_obj.pose.bones[bone.name].rotation_mode == 'QUATERNION': 211 | kf_rot = tr.rotation_quaternion 212 | else: 213 | kf_rot = tr.rotation_euler.to_quaternion() 214 | kf_scl = tr.scale 215 | 216 | if bone: 217 | basis_mat = translation_matrix(kf_pos) @ scale_matrix(kf_scl) 218 | local_mat = basis_to_local_matrix(basis_mat, rest_mat, parent_mat) 219 | 220 | kf_pos = local_mat.to_translation() 221 | kf_rot = local_rot.inverted().rotation_difference(kf_rot) 222 | kf_scl = local_mat.to_scale() 223 | 224 | kf = Keyframe(time / fps, kf_pos, kf_rot, kf_scl) 225 | keyframes.append(kf) 226 | 227 | anim.bones.append(bone_cls(bone_name, ''.join(data.type), True, data.bone_id, 0, 0, keyframes)) 228 | 229 | animations.append(anim) 230 | 231 | return animations 232 | -------------------------------------------------------------------------------- /gui/operator.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from bpy.props import ( 4 | BoolProperty, 5 | EnumProperty, 6 | FloatProperty, 7 | IntProperty, 8 | StringProperty, 9 | ) 10 | from bpy_extras.io_utils import ( 11 | ImportHelper, 12 | ExportHelper, 13 | ) 14 | 15 | from ..ops.armature_constructor import ArmatureConstructor 16 | from ..ops.action_retargeter import retarget_action, untarget_action 17 | from ..ops.ifp_importer import create_action 18 | from ..ops.ifp_exporter import create_ifp_animations 19 | from ..gtaLib.ifp import Ifp, ANIM_CLASSES 20 | 21 | 22 | class SCENE_OT_ifp_construct_armature(bpy.types.Operator): 23 | bl_idname = "scene.ifp_construct_armature" 24 | bl_description = "Construct an armature from a hierarchy of objects" 25 | bl_label = "Construct Armature" 26 | 27 | def execute(self, context): 28 | root_objects = [] 29 | for obj in context.selected_objects: 30 | if obj.parent and obj.parent in context.selected_objects: 31 | continue 32 | 33 | if obj.type == 'EMPTY' and obj.children: 34 | root_objects.append(obj) 35 | 36 | for root_obj in root_objects: 37 | ArmatureConstructor.construct_armature(context, root_obj) 38 | 39 | return {'FINISHED'} 40 | 41 | 42 | class OBJECT_OT_ifp_retarget_action(bpy.types.Operator): 43 | bl_idname = "object.ifp_retarget_action" 44 | bl_description = "Adjust the active action to the selected armature" 45 | bl_label = "Retarget Action" 46 | 47 | @classmethod 48 | def poll(cls, context): 49 | arm_obj = context.object 50 | 51 | if not arm_obj or type(arm_obj.data) != bpy.types.Armature: 52 | return False 53 | 54 | if not arm_obj.animation_data: 55 | return False 56 | 57 | act = arm_obj.animation_data.action 58 | if not act: 59 | return False 60 | 61 | return True 62 | 63 | def execute(self, context): 64 | arm_obj = context.object 65 | act = arm_obj.animation_data.action 66 | 67 | missing_bones = retarget_action(act, arm_obj) 68 | 69 | if missing_bones: 70 | bpy.ops.message.ifp_import_report('INVOKE_DEFAULT', 71 | missing_bones_message='\n'.join(missing_bones), 72 | created_actions=0) 73 | 74 | return {'FINISHED'} 75 | 76 | 77 | class OBJECT_OT_ifp_untarget_action(bpy.types.Operator): 78 | bl_idname = "object.ifp_untarget_action" 79 | bl_description = "Clear the active action from the targeted armature" 80 | bl_label = "Untarget Action" 81 | 82 | @classmethod 83 | def poll(cls, context): 84 | arm_obj = context.object 85 | 86 | if not arm_obj or type(arm_obj.data) != bpy.types.Armature: 87 | return False 88 | 89 | if not arm_obj.animation_data: 90 | return False 91 | 92 | act = arm_obj.animation_data.action 93 | if not act: 94 | return False 95 | 96 | return True 97 | 98 | def execute(self, context): 99 | arm_obj = context.object 100 | act = arm_obj.animation_data.action 101 | 102 | untarget_action(act) 103 | return {'FINISHED'} 104 | 105 | 106 | class ImportReport(bpy.types.Operator): 107 | bl_idname = "message.ifp_import_report" 108 | bl_label = "IFP Import Report" 109 | 110 | missing_bones_message: StringProperty(default='') 111 | created_actions: IntProperty(default=0) 112 | 113 | def execute(self, context): 114 | if self.created_actions > 0: 115 | self.report({'INFO'}, f'Created {self.created_actions} IFP actions') 116 | if self.missing_bones_message: 117 | self.report({'WARNING'}, 'Missing bones:\n' + self.missing_bones_message) 118 | return {'FINISHED'} 119 | 120 | def invoke(self, context, event): 121 | return context.window_manager.invoke_props_dialog(self, width=240) 122 | 123 | def draw(self, context): 124 | layout = self.layout 125 | if self.created_actions > 0: 126 | layout.label(text=f'Created {self.created_actions} IFP actions', icon='INFO') 127 | 128 | if self.missing_bones_message: 129 | layout.label(text='Missing bones:') 130 | box = layout.box() 131 | for text in self.missing_bones_message.split('\n'): 132 | if text: 133 | box.label(text=text, icon='BONE_DATA') 134 | 135 | 136 | class ImportGtaIfp(bpy.types.Operator, ImportHelper): 137 | bl_idname = "import_scene.gta_ifp" 138 | bl_label = "Import GTA Animation" 139 | bl_options = {'PRESET', 'UNDO'} 140 | 141 | filter_glob: StringProperty(default="*.ifp", options={'HIDDEN'}) 142 | filename_ext = ".ifp" 143 | 144 | fps: FloatProperty( 145 | name="FPS", 146 | description="Value by which the keyframe time is multiplied (GTA 3/VC)", 147 | default=30.0, 148 | ) 149 | 150 | use_armature: BoolProperty( 151 | name="Use Active Armature", 152 | description="Adjust all actions to the active armature", 153 | default=True, 154 | ) 155 | 156 | def execute(self, context): 157 | fps = self.fps 158 | use_armature = self.use_armature 159 | arm_obj = None 160 | 161 | if use_armature: 162 | arm_obj = context.view_layer.objects.active 163 | if arm_obj and type(arm_obj.data) != bpy.types.Armature: 164 | arm_obj = None 165 | 166 | ifp = Ifp.load(self.filepath) 167 | if not ifp.data: 168 | return {'CANCELLED'} 169 | 170 | if ifp.version == 'ANP3': 171 | fps = 1.0 172 | 173 | missing_bones = set() 174 | actions_count = 0 175 | 176 | for anim in ifp.data.animations: 177 | act = create_action(anim, fps) 178 | act.name = anim.name 179 | actions_count += 1 180 | 181 | if arm_obj: 182 | mb = retarget_action(act, arm_obj) 183 | missing_bones.update(mb) 184 | 185 | animation_data = arm_obj.animation_data 186 | if not animation_data: 187 | animation_data = arm_obj.animation_data_create() 188 | animation_data.action = act 189 | 190 | if bpy.app.version >= (4, 4, 0): 191 | animation_data.action_slot = act.slots[-1] 192 | 193 | bpy.ops.message.ifp_import_report('INVOKE_DEFAULT', 194 | missing_bones_message='\n'.join(missing_bones), 195 | created_actions=actions_count) 196 | 197 | return {'FINISHED'} 198 | 199 | 200 | class ExportGtaIfp(bpy.types.Operator, ExportHelper): 201 | bl_idname = "export_scene.gta_ifp" 202 | bl_label = "Export GTA Animation" 203 | bl_options = {'PRESET'} 204 | 205 | filter_glob: StringProperty(default="*.ifp", options={'HIDDEN'}) 206 | filename_ext = ".ifp" 207 | 208 | ifp_version: EnumProperty( 209 | name='Version', 210 | description='IFP version', 211 | items={ 212 | ('ANP3', 'GTA SA', 'IFP version for GTA San Andreas'), 213 | ('ANPK', 'GTA 3/VC', 'IFP version for GTA 3 and GTA Vice City')}, 214 | default='ANP3', 215 | ) 216 | 217 | ifp_name: StringProperty( 218 | name="Name", 219 | description="IFP name", 220 | default='Model', 221 | maxlen=23, 222 | ) 223 | 224 | fps: FloatProperty( 225 | name="FPS", 226 | description="Value by which the keyframe time is divided (GTA 3/VC)", 227 | default=30.0, 228 | ) 229 | 230 | def draw(self, context): 231 | layout = self.layout 232 | 233 | layout.prop(self, "ifp_version") 234 | layout.prop(self, "ifp_name") 235 | layout.prop(self, "fps") 236 | 237 | box = layout.box() 238 | box.label(text="Actions to Export:") 239 | 240 | if bpy.data.actions: 241 | for act in bpy.data.actions: 242 | row = box.row() 243 | row.prop(act.ifp, "use_export", text="") 244 | row.prop(act, "name", text="") 245 | else: 246 | box.label(text="No actions found", icon='INFO') 247 | 248 | def execute(self, context): 249 | name = self.ifp_name 250 | version = self.ifp_version 251 | fps = self.fps 252 | 253 | ifp_cls = ANIM_CLASSES[version] 254 | if version == 'ANP3': 255 | fps = 1.0 256 | 257 | actions = [act for act in bpy.data.actions if act.ifp.use_export] 258 | 259 | animations = create_ifp_animations(context, ifp_cls, actions, fps) 260 | data = ifp_cls(name, animations) 261 | ifp = Ifp(version, data) 262 | ifp.save(self.filepath) 263 | 264 | return {'FINISHED'} 265 | 266 | 267 | def menu_func_import(self, context): 268 | self.layout.operator(ImportGtaIfp.bl_idname, 269 | text="GTA Animation (.ifp)") 270 | 271 | 272 | def menu_func_export(self, context): 273 | self.layout.operator(ExportGtaIfp.bl_idname, 274 | text="GTA Animation (.ifp)") 275 | -------------------------------------------------------------------------------- /gtaLib/ifp.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | from dataclasses import dataclass 4 | from mathutils import Quaternion, Vector 5 | from os import SEEK_CUR 6 | from typing import List 7 | 8 | 9 | def read_int16(fd, num=1, en='<'): 10 | res = struct.unpack('%s%dh' % (en, num), fd.read(2 * num)) 11 | return res if num > 1 else res[0] 12 | 13 | 14 | def read_int32(fd, num=1, en='<'): 15 | res = struct.unpack('%s%di' % (en, num), fd.read(4 * num)) 16 | return res if num > 1 else res[0] 17 | 18 | 19 | def read_uint32(fd, num=1, en='<'): 20 | res = struct.unpack('%s%dI' % (en, num), fd.read(4 * num)) 21 | return res if num > 1 else res[0] 22 | 23 | 24 | def read_float32(fd, num=1, en='<'): 25 | res = struct.unpack('%s%df' % (en, num), fd.read(4 * num)) 26 | return res if num > 1 else res[0] 27 | 28 | 29 | def read_str(fd, max_len): 30 | n, res = 0, '' 31 | while n < max_len: 32 | b = fd.read(1) 33 | n += 1 34 | if b == b'\x00': 35 | break 36 | res += b.decode() 37 | 38 | fd.seek(max_len - n, SEEK_CUR) 39 | return res 40 | 41 | 42 | def write_val(fd, vals, t, en='<'): 43 | data = vals if hasattr(vals, '__len__') else (vals, ) 44 | data = struct.pack('%s%d%s' % (en, len(data), t), *data) 45 | fd.write(data) 46 | 47 | 48 | def write_uint16(fd, vals, en='<'): 49 | write_val(fd, vals, 'h', en) 50 | 51 | 52 | def write_int32(fd, vals, en='<'): 53 | write_val(fd, vals, 'i', en) 54 | 55 | 56 | def write_uint32(fd, vals, en='<'): 57 | write_val(fd, vals, 'I', en) 58 | 59 | 60 | def write_float32(fd, vals, en='<'): 61 | write_val(fd, vals, 'f', en) 62 | 63 | 64 | def write_str(fd, val, max_len): 65 | fd.write(val.encode()) 66 | fd.write(b'\x00' * (max_len - len(val))) 67 | 68 | 69 | @dataclass 70 | class Keyframe: 71 | time: float 72 | pos: Vector 73 | rot: Quaternion 74 | scl: Vector 75 | 76 | 77 | @dataclass 78 | class Bone: 79 | name: str 80 | keyframe_type: str 81 | use_bone_id: bool 82 | bone_id: int 83 | sibling_x: int 84 | sibling_y: int 85 | keyframes: List[Keyframe] 86 | 87 | 88 | @dataclass 89 | class Animation: 90 | name: str 91 | bones: List[Bone] 92 | 93 | 94 | @dataclass 95 | class IfpData: 96 | name: str 97 | animations: List[Animation] 98 | 99 | 100 | class Anp3Bone(Bone): 101 | def get_keyframes_size(self): 102 | s = 16 if self.keyframe_type[2] == 'T' else 10 103 | return len(self.keyframes) * s 104 | 105 | def get_size(self): 106 | return 36 + self.get_keyframes_size() 107 | 108 | @classmethod 109 | def read(cls, fd): 110 | name = read_str(fd, 24) 111 | keyframe_type, keyframes_num = read_uint32(fd, 2) 112 | keyframe_type = 'KRT0' if keyframe_type == 4 else 'KR00' 113 | 114 | bone_id = read_int32(fd) 115 | 116 | keyframes = [] 117 | for _ in range(keyframes_num): 118 | qx, qy, qz, qw, time = read_int16(fd, 5) 119 | px, py, pz = read_int16(fd, 3) if keyframe_type[2] == 'T' else (0, 0, 0) 120 | kf = Keyframe( 121 | time, 122 | Vector((px/1024.0, py/1024.0, pz/1024.0)), 123 | Quaternion((qw/4096.0, qx/4096.0, qy/4096.0, qz/4096.0)), 124 | Vector((1, 1, 1)) 125 | ) 126 | keyframes.append(kf) 127 | 128 | return cls(name, keyframe_type, True, bone_id, 0, 0, keyframes) 129 | 130 | def write(self, fd): 131 | keyframe_type = 4 if self.keyframe_type[2] == 'T' else 3 132 | 133 | write_str(fd, self.name, 24) 134 | write_uint32(fd, (keyframe_type, len(self.keyframes))) 135 | write_int32(fd, self.bone_id) 136 | 137 | for kf in self.keyframes: 138 | qx = int(kf.rot.x*4096.0) 139 | qy = int(kf.rot.y*4096.0) 140 | qz = int(kf.rot.z*4096.0) 141 | qw = int(kf.rot.w*4096.0) 142 | write_uint16(fd, (qx, qy, qz, qw, int(kf.time))) 143 | 144 | if keyframe_type == 4: 145 | px = int(kf.pos.x*1024.0) 146 | py = int(kf.pos.y*1024.0) 147 | pz = int(kf.pos.z*1024.0) 148 | write_uint16(fd, (px, py, pz)) 149 | 150 | 151 | class Anp3Animation(Animation): 152 | @staticmethod 153 | def get_bone_class(): 154 | return Anp3Bone 155 | 156 | def get_size(self): 157 | return 36 + sum(b.get_size() for b in self.bones) 158 | 159 | @classmethod 160 | def read(cls, fd): 161 | name = read_str(fd, 24) 162 | bones_num, keyframes_size, unk = read_uint32(fd, 3) 163 | bones = [Anp3Bone.read(fd) for _ in range(bones_num)] 164 | return cls(name, bones) 165 | 166 | def write(self, fd): 167 | keyframes_size = sum(b.get_keyframes_size() for b in self.bones) 168 | 169 | write_str(fd, self.name, 24) 170 | write_uint32(fd, (len(self.bones), keyframes_size, 1)) 171 | for b in self.bones: 172 | b.write(fd) 173 | 174 | 175 | class Anp3(IfpData): 176 | @staticmethod 177 | def get_animation_class(): 178 | return Anp3Animation 179 | 180 | @classmethod 181 | def read(cls, fd): 182 | size = read_uint32(fd) 183 | name = read_str(fd, 24) 184 | animations_num = read_uint32(fd) 185 | animations = [cls.get_animation_class().read(fd) for _ in range(animations_num)] 186 | return cls(name, animations) 187 | 188 | def write(self, fd): 189 | size = 28 + sum(a.get_size() for a in self.animations) 190 | write_uint32(fd, size) 191 | write_str(fd, self.name, 24) 192 | write_uint32(fd, len(self.animations)) 193 | for a in self.animations: 194 | a.write(fd) 195 | 196 | 197 | class AnpkBone(Bone): 198 | def get_keyframes_size(self): 199 | s = 20 200 | if self.keyframe_type[2] == 'T': 201 | s += 12 202 | if self.keyframe_type[3] == 'S': 203 | s += 12 204 | return len(self.keyframes) * s 205 | 206 | def get_size(self): 207 | if self.use_bone_id: 208 | anim_len = 44 209 | else: 210 | anim_len = 48 211 | return self.get_keyframes_size() + anim_len + 24 212 | 213 | @classmethod 214 | def read(cls, fd): 215 | fd.seek(4, SEEK_CUR) # CPAN 216 | bone_len = read_uint32(fd) 217 | fd.seek(4, SEEK_CUR) # ANIM 218 | anim_len = read_uint32(fd) 219 | name = read_str(fd, 28) 220 | keyframes_num = read_uint32(fd) 221 | fd.seek(8, SEEK_CUR) # unk 222 | 223 | if anim_len == 44: 224 | bone_id = read_int32(fd) 225 | sibling_x, sibling_y = 0, 0 226 | use_bone_id = True 227 | else: 228 | bone_id = -1 229 | sibling_x, sibling_y = read_int32(fd, 2) 230 | use_bone_id = False 231 | 232 | if keyframes_num: 233 | keyframe_type = read_str(fd, 4) 234 | keyframes_len = read_uint32(fd) 235 | 236 | keyframes = [] 237 | for _ in range(keyframes_num): 238 | qx, qy, qz, qw = read_float32(fd, 4) 239 | px, py, pz = read_float32(fd, 3) if keyframe_type[2] == 'T' else (0, 0, 0) 240 | sx, sy, sz = read_float32(fd, 3) if keyframe_type[3] == 'S' else (1, 1, 1) 241 | time = read_float32(fd) 242 | 243 | rot = Quaternion((qw, qx, qy, qz)) 244 | rot.conjugate() 245 | 246 | kf = Keyframe( 247 | time, 248 | Vector((px, py, pz)), 249 | rot, 250 | Vector((sx, sy, sz)), 251 | ) 252 | keyframes.append(kf) 253 | else: 254 | keyframe_type = 'K000' 255 | keyframes = [] 256 | 257 | return cls(name, keyframe_type, use_bone_id, bone_id, sibling_x, sibling_y, keyframes) 258 | 259 | def write(self, fd): 260 | keyframes_num = len(self.keyframes) 261 | if self.use_bone_id: 262 | anim_len = 44 263 | else: 264 | anim_len = 48 265 | 266 | keyframes_len = self.get_keyframes_size() 267 | bone_len = keyframes_len + anim_len + 16 268 | 269 | write_str(fd, 'CPAN', 4) 270 | write_uint32(fd, bone_len) 271 | write_str(fd, 'ANIM', 4) 272 | write_uint32(fd, anim_len) 273 | write_str(fd, self.name, 28) 274 | write_uint32(fd, (keyframes_num, 0, keyframes_num - 1)) 275 | 276 | if self.use_bone_id: 277 | write_int32(fd, self.bone_id) 278 | else: 279 | write_int32(fd, (self.sibling_x, self.sibling_y)) 280 | 281 | write_str(fd, self.keyframe_type, 4) 282 | write_uint32(fd, keyframes_len) 283 | 284 | for kf in self.keyframes: 285 | rot = kf.rot.copy() 286 | rot.conjugate() 287 | write_float32(fd, (rot.x, rot.y, rot.z, rot.w)) 288 | 289 | if self.keyframe_type[2] == 'T': 290 | write_float32(fd, kf.pos) 291 | 292 | if self.keyframe_type[3] == 'S': 293 | write_float32(fd, kf.scl) 294 | 295 | write_float32(fd, kf.time) 296 | 297 | 298 | class AnpkAnimation(Animation): 299 | def get_bone_class(): 300 | return AnpkBone 301 | 302 | def get_size(self): 303 | name_len = len(self.name) + 1 304 | name_align_len = (4 - name_len % 4) % 4 305 | return 32 + name_len + name_align_len + sum(b.get_size() for b in self.bones) 306 | 307 | @classmethod 308 | def read(cls, fd): 309 | fd.seek(4, SEEK_CUR) # NAME 310 | name_len = read_uint32(fd) 311 | name = read_str(fd, name_len) 312 | fd.seek((4 - name_len % 4) % 4, SEEK_CUR) 313 | fd.seek(4, SEEK_CUR) # DGAN 314 | animation_size = read_uint32(fd) 315 | fd.seek(4, SEEK_CUR) # INFO 316 | unk_size, bones_num = read_uint32(fd, 2) 317 | fd.seek(unk_size - 4, SEEK_CUR) 318 | bones = [AnpkBone.read(fd) for _ in range(bones_num)] 319 | return cls(name, bones) 320 | 321 | def write(self, fd): 322 | name_len = len(self.name) + 1 323 | animation_size = 16 + sum(b.get_size() for b in self.bones) 324 | 325 | write_str(fd, 'NAME', 4) 326 | write_uint32(fd, name_len) 327 | write_str(fd, self.name, name_len + (4 - name_len % 4) % 4) 328 | write_str(fd, 'DGAN', 4) 329 | write_uint32(fd, animation_size) 330 | write_str(fd, 'INFO', 4) 331 | write_uint32(fd, (8, len(self.bones), 0)) 332 | for b in self.bones: 333 | b.write(fd) 334 | 335 | 336 | class Anpk(IfpData): 337 | @staticmethod 338 | def get_animation_class(): 339 | return AnpkAnimation 340 | 341 | @classmethod 342 | def read(cls, fd): 343 | size = read_uint32(fd) 344 | fd.seek(4, SEEK_CUR) # INFO 345 | info_len, animations_num = read_uint32(fd, 2) 346 | name = read_str(fd, info_len - 4) 347 | fd.seek((4 - info_len % 4) % 4, SEEK_CUR) 348 | 349 | animations = [cls.get_animation_class().read(fd) for _ in range(animations_num)] 350 | return cls(name, animations) 351 | 352 | def write(self, fd): 353 | name_len = len(self.name) + 1 354 | info_len = name_len + 4 355 | name_align_len = (4 - name_len % 4) % 4 356 | size = 12 + name_len + name_align_len + sum(a.get_size() for a in self.animations) 357 | 358 | write_uint32(fd, size) 359 | write_str(fd, 'INFO', 4) 360 | write_uint32(fd, (info_len, len(self.animations))) 361 | write_str(fd, self.name, name_len + name_align_len) 362 | for a in self.animations: 363 | a.write(fd) 364 | 365 | 366 | ANIM_CLASSES = { 367 | 'ANP3': Anp3, 368 | 'ANPK': Anpk, 369 | } 370 | 371 | 372 | @dataclass 373 | class Ifp: 374 | version: str 375 | data: object 376 | 377 | @classmethod 378 | def read(cls, fd): 379 | version = read_str(fd, 4) 380 | 381 | anim_cls = ANIM_CLASSES.get(version) 382 | if not anim_cls: 383 | raise Exception('Unknown IFP version') 384 | 385 | data = anim_cls.read(fd) 386 | return cls(version, data) 387 | 388 | def write(self, fd): 389 | write_str(fd, self.version, 4) 390 | self.data.write(fd) 391 | fd.write(b'\x00' * (2048 - (fd.tell() % 2048))) 392 | 393 | @classmethod 394 | def load(cls, filepath): 395 | with open(filepath, 'rb') as fd: 396 | return cls.read(fd) 397 | 398 | def save(self, filepath): 399 | with open(filepath, 'wb') as fd: 400 | return self.write(fd) 401 | --------------------------------------------------------------------------------