From 0d06236babdc8c9d9f18f6079d18b9d207f291c2 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 24 Jan 2022 14:14:35 -0800 Subject: [PATCH 01/22] Added the ability to export actions with the original sequence names that they were imported from. This will be very helpful in resolving naming conflicts when working with actions that share the same name on export (e.g. players and weapons often share the same animation name (e.g., `prone_reload_mg42` but have to exist in the same file). Still kind of broken though because it allows duplicate names which will possibly break downstream programs. --- io_scene_psk_psa/psa/builder.py | 9 ++++++++- io_scene_psk_psa/psa/exporter.py | 21 ++++++++++++++++++++- io_scene_psk_psa/psa/importer.py | 8 +++++--- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 825676a..cddc250 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -7,6 +7,7 @@ class PsaBuilderOptions(object): self.actions = [] self.bone_filter_mode = 'ALL' self.bone_group_indices = [] + self.should_use_original_sequence_names = False class PsaBuilder(object): @@ -106,7 +107,13 @@ class PsaBuilder(object): frame_min, frame_max = [int(x) for x in action.frame_range] sequence = Psa.Sequence() - sequence.name = bytes(action.name, encoding='utf-8') + + if options.should_use_original_sequence_names and 'original_sequence_name' in action: + sequence_name = action['original_sequence_name'] + else: + sequence_name = action.name + + sequence.name = bytes(sequence_name, encoding='windows-1252') sequence.frame_count = frame_max - frame_min + 1 sequence.frame_start_index = frame_start_index sequence.fps = context.scene.render.fps diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 6349d2e..d53cfb2 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -45,6 +45,19 @@ class PsaExportActionListItem(PropertyGroup): return self.action.name +def update_action_names(context): + property_group = context.scene.psa_export + for item in property_group.action_list: + action = item.action + if property_group.should_use_original_sequence_names and 'original_sequence_name' in action: + item.action_name = action['original_sequence_name'] + else: + item.action_name = action.name + + +def should_use_original_sequence_names_updated(property, context): + update_action_names(context) + class PsaExportPropertyGroup(PropertyGroup): action_list: CollectionProperty(type=PsaExportActionListItem) action_list_index: IntProperty(default=0) @@ -58,6 +71,7 @@ class PsaExportPropertyGroup(PropertyGroup): ) bone_group_list: CollectionProperty(type=BoneGroupListItem) bone_group_list_index: IntProperty(default=0) + should_use_original_sequence_names: BoolProperty(default=False, description='If the action was imported from the PSA Import panel, the original name of the action will be used instead of the action name assigned in Blender', update=should_use_original_sequence_names_updated) def is_bone_filter_mode_item_available(context, identifier): @@ -97,6 +111,8 @@ class PsaExportOperator(Operator, ExportHelper): row.operator('psa_export.actions_select_all', text='All') row.operator('psa_export.actions_deselect_all', text='None') + layout.prop(property_group, 'should_use_original_sequence_names', text='Original Sequence Names') + # BONES box = layout.box() box.label(text='Bones', icon='BONE_DATA') @@ -115,7 +131,6 @@ class PsaExportOperator(Operator, ExportHelper): rows = max(3, min(len(property_group.bone_group_list), 10)) row.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, 'bone_group_list_index', rows=rows) - def is_action_for_armature(self, action): if len(action.fcurves) == 0: return False @@ -148,9 +163,12 @@ class PsaExportOperator(Operator, ExportHelper): item = property_group.action_list.add() item.action = action item.action_name = action.name + if self.is_action_for_armature(action): item.is_selected = True + update_action_names(context) + if len(property_group.action_list) == 0: # If there are no actions at all, we have nothing to export, so just cancel the operation. self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions to export.') @@ -175,6 +193,7 @@ class PsaExportOperator(Operator, ExportHelper): options.actions = actions options.bone_filter_mode = property_group.bone_filter_mode options.bone_group_indices = [x.index for x in property_group.bone_group_list if x.is_selected] + options.should_use_original_sequence_names = property_group.should_use_original_sequence_names builder = PsaBuilder() try: psa = builder.build(context, options) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 29febd7..fa177c0 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -116,7 +116,8 @@ class PsaImporter(object): actions = [] for sequence in sequences: # Add the action. - action_name = options.action_name_prefix + sequence.name.decode('windows-1252') + sequence_name = sequence.name.decode('windows-1252') + action_name = options.action_name_prefix + sequence_name action = bpy.data.actions.new(name=action_name) action.use_fake_user = options.should_use_fake_user @@ -136,8 +137,6 @@ class PsaImporter(object): action.fcurves.new(location_data_path, index=2), # Lz ] - sequence_name = sequence.name.decode('windows-1252') - # Read the sequence data matrix from the PSA. sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name) keyframe_write_matrix = np.ones(sequence_data_matrix.shape, dtype=np.int8) @@ -183,6 +182,9 @@ class PsaImporter(object): if should_write: fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'}) + # Store the original sequence name for use when exporting this same action using the PSA exporter. + action['original_sequence_name'] = sequence_name + actions.append(action) # If the user specifies, store the new animations as strips on a non-contributing NLA stack. From c6729416637ff9301541537b9e66d651a8204f78 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 24 Jan 2022 21:50:34 -0800 Subject: [PATCH 02/22] PSA Import screen now has more robust functionality now (but still aint done!) --- io_scene_psk_psa/__init__.py | 12 +- io_scene_psk_psa/helpers.py | 7 +- io_scene_psk_psa/psa/builder.py | 5 +- io_scene_psk_psa/psa/exporter.py | 59 ++++--- io_scene_psk_psa/psa/importer.py | 255 +++++++++++++++++++++++++------ io_scene_psk_psa/psk/exporter.py | 4 +- io_scene_psk_psa/psk/importer.py | 6 +- io_scene_psk_psa/types.py | 6 +- 8 files changed, 267 insertions(+), 87 deletions(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 1a98c9b..36110a0 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -13,6 +13,7 @@ bl_info = { if 'bpy' in locals(): import importlib + importlib.reload(psx_data) importlib.reload(psx_helpers) importlib.reload(psx_types) @@ -42,15 +43,14 @@ else: from .psa import reader as psa_reader from .psa import importer as psa_importer - import bpy from bpy.props import PointerProperty -classes = psx_types.__classes__ + \ - psk_importer.__classes__ + \ - psk_exporter.__classes__ + \ - psa_exporter.__classes__ + \ - psa_importer.__classes__ +classes = (psx_types.classes + + psk_importer.classes + + psk_exporter.classes + + psa_exporter.classes + + psa_importer.classes) def psk_export_menu_func(self, context): diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index 34b74cb..1217dca 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -17,8 +17,11 @@ def populate_bone_group_list(armature_object, bone_group_list): item.is_selected = True -def add_bone_groups_to_layout(layout): - pass +def get_psa_sequence_name(action, should_use_original_sequence_name): + if should_use_original_sequence_name and 'original_sequence_name' in action: + return action['original_sequence_name'] + else: + return action.name def get_export_bone_indices_for_bone_groups(armature_object, bone_group_indices: List[int]) -> List[int]: diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index cddc250..87549fb 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -108,10 +108,7 @@ class PsaBuilder(object): sequence = Psa.Sequence() - if options.should_use_original_sequence_names and 'original_sequence_name' in action: - sequence_name = action['original_sequence_name'] - else: - sequence_name = action.name + sequence_name = get_psa_sequence_name(action, options.should_use_original_sequence_names) sequence.name = bytes(sequence_name, encoding='windows-1252') sequence.frame_count = frame_max - frame_min + 1 diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index d53cfb2..1dd7c6e 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -1,5 +1,5 @@ import bpy -from bpy.types import Operator, PropertyGroup, Action, UIList, BoneGroup +from bpy.types import Operator, PropertyGroup, Action, UIList, BoneGroup, Panel from bpy.props import CollectionProperty, IntProperty, PointerProperty, StringProperty, BoolProperty, EnumProperty from bpy_extras.io_utils import ExportHelper from typing import Type @@ -7,6 +7,7 @@ from .builder import PsaBuilder, PsaBuilderOptions from .data import * from ..types import BoneGroupListItem from ..helpers import * +from collections import Counter import re @@ -49,15 +50,13 @@ def update_action_names(context): property_group = context.scene.psa_export for item in property_group.action_list: action = item.action - if property_group.should_use_original_sequence_names and 'original_sequence_name' in action: - item.action_name = action['original_sequence_name'] - else: - item.action_name = action.name + item.action_name = get_psa_sequence_name(action, property_group.should_use_original_sequence_names) def should_use_original_sequence_names_updated(property, context): update_action_names(context) + class PsaExportPropertyGroup(PropertyGroup): action_list: CollectionProperty(type=PsaExportActionListItem) action_list_index: IntProperty(default=0) @@ -71,11 +70,11 @@ class PsaExportPropertyGroup(PropertyGroup): ) bone_group_list: CollectionProperty(type=BoneGroupListItem) bone_group_list_index: IntProperty(default=0) - should_use_original_sequence_names: BoolProperty(default=False, description='If the action was imported from the PSA Import panel, the original name of the action will be used instead of the action name assigned in Blender', update=should_use_original_sequence_names_updated) + should_use_original_sequence_names: BoolProperty(default=False, name='Original Names', description='If the action was imported from the PSA Import panel, the original name of the sequence will be used instead of the Blender action name', update=should_use_original_sequence_names_updated) def is_bone_filter_mode_item_available(context, identifier): - if identifier == "BONE_GROUPS": + if identifier == 'BONE_GROUPS': obj = context.active_object if not obj.pose or not obj.pose.bone_groups: return False @@ -83,7 +82,7 @@ def is_bone_filter_mode_item_available(context, identifier): class PsaExportOperator(Operator, ExportHelper): - bl_idname = 'export.psa' + bl_idname = 'psa_export.operator' bl_label = 'Export' __doc__ = 'Export actions to PSA' filename_ext = '.psa' @@ -102,19 +101,34 @@ class PsaExportOperator(Operator, ExportHelper): property_group = context.scene.psa_export # ACTIONS - box = layout.box() - box.label(text='Actions', icon='ACTION') - row = box.row() - row.template_list('PSA_UL_ExportActionList', 'asd', property_group, 'action_list', property_group, 'action_list_index', rows=10) - row = box.row(align=True) + layout.label(text='Actions', icon='ACTION') + row = layout.row(align=True) row.label(text='Select') row.operator('psa_export.actions_select_all', text='All') row.operator('psa_export.actions_deselect_all', text='None') + row = layout.row() + rows = max(3, min(len(property_group.action_list), 10)) + row.template_list('PSA_UL_ExportActionList', '', property_group, 'action_list', property_group, + 'action_list_index', rows=rows) - layout.prop(property_group, 'should_use_original_sequence_names', text='Original Sequence Names') + col = layout.column(heading="Options") + col.use_property_split = True + col.use_property_decorate = False + col.prop(property_group, 'should_use_original_sequence_names') + + # Determine if there is going to be a naming conflict and display an error, if so. + selected_actions = [x for x in property_group.action_list if x.is_selected] + action_names = [x.action_name for x in selected_actions] + action_name_counts = Counter(action_names) + for action_name, count in action_name_counts.items(): + if count > 1: + layout.label(text=f'Duplicate action: {action_name}', icon='ERROR') + break + + layout.separator() # BONES - box = layout.box() + box = layout.row() box.label(text='Bones', icon='BONE_DATA') bone_filter_mode_items = property_group.bl_rna.properties['bone_filter_mode'].enum_items_static row = box.row(align=True) @@ -126,10 +140,9 @@ class PsaExportOperator(Operator, ExportHelper): item_layout.enabled = is_bone_filter_mode_item_available(context, identifier) if property_group.bone_filter_mode == 'BONE_GROUPS': - box = layout.box() - row = box.row() rows = max(3, min(len(property_group.bone_group_list), 10)) - row.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, 'bone_group_list_index', rows=rows) + layout.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, + 'bone_group_list_index', rows=rows) def is_action_for_armature(self, action): if len(action.fcurves) == 0: @@ -160,12 +173,12 @@ class PsaExportOperator(Operator, ExportHelper): # Populate actions list. property_group.action_list.clear() for action in bpy.data.actions: + if not self.is_action_for_armature(action): + continue item = property_group.action_list.add() item.action = action item.action_name = action.name - - if self.is_action_for_armature(action): - item.is_selected = True + item.is_selected = True update_action_names(context) @@ -264,11 +277,11 @@ class PsaExportDeselectAll(bpy.types.Operator): return {'FINISHED'} -__classes__ = [ +classes = ( PsaExportActionListItem, PsaExportPropertyGroup, PsaExportOperator, PSA_UL_ExportActionList, PsaExportSelectAll, PsaExportDeselectAll, -] +) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index fa177c0..793721b 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -5,7 +5,7 @@ from mathutils import Vector, Quaternion, Matrix from .data import Psa from typing import List, AnyStr, Optional from bpy.types import Operator, Action, UIList, PropertyGroup, Panel, Armature, FileSelectParams -from bpy_extras.io_utils import ExportHelper, ImportHelper +from bpy_extras.io_utils import ImportHelper from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty from .reader import PsaReader @@ -16,6 +16,7 @@ class PsaImportOptions(object): self.should_use_fake_user = False self.should_stash = False self.sequence_names = [] + self.should_use_action_name_prefix = False self.action_name_prefix = '' @@ -216,40 +217,57 @@ class PsaImportActionListItem(PropertyGroup): return self.action_name -def on_psa_file_path_updated(property, context): +def load_psa_file(context): property_group = context.scene.psa_import - property_group.action_list.clear() + property_group.sequence_list.clear() property_group.psa_bones.clear() + property_group.action_list.clear() + property_group.psa_error = '' try: # Read the file and populate the action list. p = os.path.abspath(property_group.psa_file_path) psa_reader = PsaReader(p) for sequence in psa_reader.sequences.values(): - item = property_group.action_list.add() + item = property_group.sequence_list.add() item.action_name = sequence.name.decode('windows-1252') item.frame_count = sequence.frame_count - item.is_selected = True for psa_bone in psa_reader.bones: item = property_group.psa_bones.add() - item.bone_name = psa_bone.name - except IOError as e: - # TODO: set an error somewhere so the user knows the PSA could not be read. - pass + item.bone_name = psa_bone.name.decode('windows-1252') + except Exception as e: + property_group.psa_error = str(e) -class PsaImportPropertyGroup(bpy.types.PropertyGroup): +def on_psa_file_path_updated(property, context): + load_psa_file(context) + + +class PsaBonePropertyGroup(PropertyGroup): + bone_name: StringProperty() + + +class PsaDataPropertyGroup(PropertyGroup): + bone_count: IntProperty(default=0) + bones: CollectionProperty(type=PsaBonePropertyGroup) + sequence_count: IntProperty(default=0) + + +class PsaImportPropertyGroup(PropertyGroup): psa_file_path: StringProperty(default='', update=on_psa_file_path_updated, name='PSA File Path') + psa_error: StringProperty(default='') psa_bones: CollectionProperty(type=PsaImportPsaBoneItem) + sequence_list: CollectionProperty(type=PsaImportActionListItem) + sequence_list_index: IntProperty(name='', default=0) action_list: CollectionProperty(type=PsaImportActionListItem) action_list_index: IntProperty(name='', default=0) - action_filter_name: StringProperty(default='') should_clean_keys: BoolProperty(default=True, name='Clean Keyframes', description='Exclude unnecessary keyframes from being written to the actions.') should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is saved even it has no users.') should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track') - action_name_prefix: StringProperty(default='', name='Action Name Prefix') + should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name') + action_name_prefix: StringProperty(default='', name='Prefix') -class PSA_UL_ImportActionList(UIList): +class PSA_UL_SequenceList(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): row = layout.row(align=True) @@ -282,7 +300,34 @@ class PSA_UL_ImportActionList(UIList): return flt_flags, flt_neworder -class PsaImportSelectAll(bpy.types.Operator): +class PSA_UL_ImportSequenceList(PSA_UL_SequenceList, UIList): + pass + + +class PSA_UL_ImportActionList(PSA_UL_SequenceList, UIList): + pass + + +class PsaImportSequencesSelectAll(bpy.types.Operator): + bl_idname = 'psa_import.sequences_select_all' + bl_label = 'All' + bl_description = 'Select all sequences' + + @classmethod + def poll(cls, context): + property_group = context.scene.psa_import + sequence_list = property_group.sequence_list + has_unselected_actions = any(map(lambda action: not action.is_selected, sequence_list)) + return len(sequence_list) > 0 and has_unselected_actions + + def execute(self, context): + property_group = context.scene.psa_import + for action in property_group.sequence_list: + action.is_selected = True + return {'FINISHED'} + + +class PsaImportActionsSelectAll(bpy.types.Operator): bl_idname = 'psa_import.actions_select_all' bl_label = 'All' bl_description = 'Select all actions' @@ -301,7 +346,26 @@ class PsaImportSelectAll(bpy.types.Operator): return {'FINISHED'} -class PsaImportDeselectAll(bpy.types.Operator): +class PsaImportSequencesDeselectAll(bpy.types.Operator): + bl_idname = 'psa_import.sequences_deselect_all' + bl_label = 'None' + bl_description = 'Deselect all sequences' + + @classmethod + def poll(cls, context): + property_group = context.scene.psa_import + sequence_list = property_group.sequence_list + has_selected_sequences = any(map(lambda action: action.is_selected, sequence_list)) + return len(sequence_list) > 0 and has_selected_sequences + + def execute(self, context): + property_group = context.scene.psa_import + for action in property_group.sequence_list: + action.is_selected = False + return {'FINISHED'} + + +class PsaImportActionsDeselectAll(bpy.types.Operator): bl_idname = 'psa_import.actions_deselect_all' bl_label = 'None' bl_description = 'Deselect all actions' @@ -320,6 +384,31 @@ class PsaImportDeselectAll(bpy.types.Operator): return {'FINISHED'} +class PSA_PT_ImportPanel_Advanced(Panel): + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_label = 'Advanced' + bl_options = {'DEFAULT_CLOSED'} + bl_parent_id = 'PSA_PT_ImportPanel' + + def draw(self, context): + layout = self.layout + property_group = context.scene.psa_import + + col = layout.column(heading="Options") + col.use_property_split = True + col.use_property_decorate = False + col.prop(property_group, 'should_clean_keys') + col.separator() + col.prop(property_group, 'should_use_fake_user') + col.prop(property_group, 'should_stash') + col.separator() + col.prop(property_group, 'should_use_action_name_prefix') + + if property_group.should_use_action_name_prefix: + col.prop(property_group, 'action_name_prefix') + + class PSA_PT_ImportPanel(Panel): bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' @@ -336,33 +425,58 @@ class PSA_PT_ImportPanel(Panel): layout = self.layout property_group = context.scene.psa_import - row = layout.row() + row = layout.row(align=True) + row.operator('psa_import.select_file', text='', icon='FILEBROWSER') row.prop(property_group, 'psa_file_path', text='') - row.enabled = False + row.operator('psa_import.file_reload', text='', icon='FILE_REFRESH') - row = layout.row() - row.operator('psa_import.select_file', text='Select PSA File', icon='FILEBROWSER') + if property_group.psa_error != '': + row = layout.row() + row.label(text='File could not be read', icon='ERROR') - if len(property_group.action_list) > 0: - box = layout.box() - box.label(text=f'Actions ({len(property_group.action_list)})', icon='ACTION') - row = box.row() - rows = max(3, min(len(property_group.action_list), 10)) - row.template_list('PSA_UL_ImportActionList', '', property_group, 'action_list', property_group, 'action_list_index', rows=rows) - row = box.row(align=True) - row.label(text='Select') - row.operator('psa_import.actions_select_all', text='All') - row.operator('psa_import.actions_deselect_all', text='None') + box = layout.box() - col = layout.column(heading="Options") - col.use_property_split = True - col.use_property_decorate = False - col.prop(property_group, 'should_clean_keys') - col.prop(property_group, 'should_use_fake_user') - col.prop(property_group, 'should_stash') - col.prop(property_group, 'action_name_prefix') + box.label(text=f'Sequences', icon='ARMATURE_DATA') - layout.operator('psa_import.import', text=f'Import') + # select + rows = max(3, min(len(property_group.sequence_list) + len(property_group.action_list), 10)) + + row = box.row() + col = row.column() + + row2 = col.row(align=True) + row2.label(text='Select') + row2.operator('psa_import.sequences_select_all', text='All') + row2.operator('psa_import.sequences_deselect_all', text='None') + + col = col.row() + col.template_list('PSA_UL_ImportSequenceList', '', property_group, 'sequence_list', property_group, + 'sequence_list_index', rows=rows) + + col = row.column(align=True) + col.operator('psa_import.push_to_actions', icon='TRIA_RIGHT', text='') + col.operator('psa_import.pop_from_actions', icon='TRIA_LEFT', text='') + + col = row.column() + row2 = col.row(align=True) + row2.label(text='Select') + row2.operator('psa_import.actions_select_all', text='All') + row2.operator('psa_import.actions_deselect_all', text='None') + col.template_list('PSA_UL_ImportActionList', '', property_group, 'action_list', property_group, + 'action_list_index', rows=rows) + col.separator() + col.operator('psa_import.import', text=f'Import') + + +class PsaImportFileReload(Operator): + bl_idname = 'psa_import.file_reload' + bl_label = 'Refresh' + bl_options = {'REGISTER'} + bl_description = 'Refresh the PSA file' + + def execute(self, context): + load_psa_file(context) + return {"FINISHED"} class PsaImportSelectFile(Operator): @@ -392,13 +506,12 @@ class PsaImportOperator(Operator): property_group = context.scene.psa_import active_object = context.view_layer.objects.active action_list = property_group.action_list - has_selected_actions = any(map(lambda action: action.is_selected, action_list)) - return has_selected_actions and active_object is not None and active_object.type == 'ARMATURE' + return len(action_list) and active_object is not None and active_object.type == 'ARMATURE' def execute(self, context): property_group = context.scene.psa_import psa_reader = PsaReader(property_group.psa_file_path) - sequence_names = [x.action_name for x in property_group.action_list if x.is_selected] + sequence_names = [x.action_name for x in property_group.action_list] options = PsaImportOptions() options.sequence_names = sequence_names options.should_clean_keys = property_group.should_clean_keys @@ -410,6 +523,52 @@ class PsaImportOperator(Operator): return {'FINISHED'} +class PsaImportPushToActions(Operator): + bl_idname = 'psa_import.push_to_actions' + bl_label = 'Push to Actions' + + @classmethod + def poll(cls, context): + property_group = context.scene.psa_import + has_sequences_selected = any(map(lambda x: x.is_selected, property_group.sequence_list)) + return has_sequences_selected + + def execute(self, context): + property_group = context.scene.psa_import + indices_to_remove = [] + for sequence_index, item in enumerate(property_group.sequence_list): + if item.is_selected: + indices_to_remove.append(sequence_index) + action = property_group.action_list.add() + action.action_name = item.action_name + for index in reversed(indices_to_remove): + property_group.sequence_list.remove(index) + return {'FINISHED'} + + +class PsaImportPopFromActions(Operator): + bl_idname = 'psa_import.pop_from_actions' + bl_label = 'Pop From Actions' + + @classmethod + def poll(cls, context): + property_group = context.scene.psa_import + has_actions_selected = any(map(lambda x: x.is_selected, property_group.action_list)) + return has_actions_selected + + def execute(self, context): + property_group = context.scene.psa_import + indices_to_remove = [] + for action_index, item in enumerate(property_group.action_list): + if item.is_selected: + indices_to_remove.append(action_index) + sequence = property_group.sequence_list.add() + sequence.action_name = item.action_name + for index in reversed(indices_to_remove): + property_group.action_list.remove(index) + return {'FINISHED'} + + class PsaImportFileSelectOperator(Operator, ImportHelper): bl_idname = 'psa_import.file_select' bl_label = 'File Select' @@ -432,15 +591,23 @@ class PsaImportFileSelectOperator(Operator, ImportHelper): return {'FINISHED'} -__classes__ = [ +classes = ( PsaImportPsaBoneItem, PsaImportActionListItem, PsaImportPropertyGroup, + PSA_UL_SequenceList, + PSA_UL_ImportSequenceList, PSA_UL_ImportActionList, - PsaImportSelectAll, - PsaImportDeselectAll, + PsaImportSequencesSelectAll, + PsaImportSequencesDeselectAll, + PsaImportActionsSelectAll, + PsaImportActionsDeselectAll, + PsaImportFileReload, PSA_PT_ImportPanel, + PSA_PT_ImportPanel_Advanced, PsaImportOperator, PsaImportFileSelectOperator, PsaImportSelectFile, -] + PsaImportPushToActions, + PsaImportPopFromActions, +) diff --git a/io_scene_psk_psa/psk/exporter.py b/io_scene_psk_psa/psk/exporter.py index 860c20f..ee0b188 100644 --- a/io_scene_psk_psa/psk/exporter.py +++ b/io_scene_psk_psa/psk/exporter.py @@ -148,7 +148,7 @@ class PskExportPropertyGroup(PropertyGroup): bone_group_list_index: IntProperty(default=0) -__classes__ = [ +classes = ( PskExportOperator, PskExportPropertyGroup -] \ No newline at end of file +) \ No newline at end of file diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 81283f8..4b593bd 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -184,6 +184,6 @@ class PskImportOperator(Operator, ImportHelper): return {'FINISHED'} -__classes__ = [ - PskImportOperator -] +classes = ( + PskImportOperator, +) diff --git a/io_scene_psk_psa/types.py b/io_scene_psk_psa/types.py index 47e6dcd..89b6b26 100644 --- a/io_scene_psk_psa/types.py +++ b/io_scene_psk_psa/types.py @@ -19,7 +19,7 @@ class BoneGroupListItem(PropertyGroup): return self.name -__classes__ = [ +classes = ( BoneGroupListItem, - PSX_UL_BoneGroupList -] + PSX_UL_BoneGroupList, +) From fbf3ec599b2e3a946679ec6faad48ffbccd4096e Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 25 Jan 2022 15:36:48 -0800 Subject: [PATCH 03/22] Reformatting for brevity and reducing redundancy --- io_scene_psk_psa/psa/exporter.py | 67 ++++++----- io_scene_psk_psa/psa/importer.py | 183 +++++++++++++++---------------- io_scene_psk_psa/psk/exporter.py | 20 ++-- 3 files changed, 134 insertions(+), 136 deletions(-) diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 1dd7c6e..6632111 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -47,10 +47,10 @@ class PsaExportActionListItem(PropertyGroup): def update_action_names(context): - property_group = context.scene.psa_export - for item in property_group.action_list: + pg = context.scene.psa_export + for item in pg.action_list: action = item.action - item.action_name = get_psa_sequence_name(action, property_group.should_use_original_sequence_names) + item.action_name = get_psa_sequence_name(action, pg.should_use_original_sequence_names) def should_use_original_sequence_names_updated(property, context): @@ -98,26 +98,25 @@ class PsaExportOperator(Operator, ExportHelper): def draw(self, context): layout = self.layout - property_group = context.scene.psa_export + pg = context.scene.psa_export # ACTIONS layout.label(text='Actions', icon='ACTION') row = layout.row(align=True) row.label(text='Select') - row.operator('psa_export.actions_select_all', text='All') - row.operator('psa_export.actions_deselect_all', text='None') + row.operator(PsaExportSelectAll.bl_idname, text='All') + row.operator(PsaExportDeselectAll.bl_idname, text='None') row = layout.row() - rows = max(3, min(len(property_group.action_list), 10)) - row.template_list('PSA_UL_ExportActionList', '', property_group, 'action_list', property_group, - 'action_list_index', rows=rows) + rows = max(3, min(len(pg.action_list), 10)) + row.template_list('PSA_UL_ExportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) col = layout.column(heading="Options") col.use_property_split = True col.use_property_decorate = False - col.prop(property_group, 'should_use_original_sequence_names') + col.prop(pg, 'should_use_original_sequence_names') # Determine if there is going to be a naming conflict and display an error, if so. - selected_actions = [x for x in property_group.action_list if x.is_selected] + selected_actions = [x for x in pg.action_list if x.is_selected] action_names = [x.action_name for x in selected_actions] action_name_counts = Counter(action_names) for action_name, count in action_name_counts.items(): @@ -130,18 +129,18 @@ class PsaExportOperator(Operator, ExportHelper): # BONES box = layout.row() box.label(text='Bones', icon='BONE_DATA') - bone_filter_mode_items = property_group.bl_rna.properties['bone_filter_mode'].enum_items_static + bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static row = box.row(align=True) for item in bone_filter_mode_items: identifier = item.identifier item_layout = row.row(align=True) - item_layout.prop_enum(property_group, 'bone_filter_mode', item.identifier) + item_layout.prop_enum(pg, 'bone_filter_mode', item.identifier) item_layout.enabled = is_bone_filter_mode_item_available(context, identifier) - if property_group.bone_filter_mode == 'BONE_GROUPS': - rows = max(3, min(len(property_group.bone_group_list), 10)) - layout.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, + if pg.bone_filter_mode == 'BONE_GROUPS': + rows = max(3, min(len(pg.bone_group_list), 10)) + layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) def is_action_for_armature(self, action): @@ -158,7 +157,7 @@ class PsaExportOperator(Operator, ExportHelper): return False def invoke(self, context, event): - property_group = context.scene.psa_export + pg = context.scene.psa_export if context.view_layer.objects.active is None: self.report({'ERROR_INVALID_CONTEXT'}, 'An armature must be selected') @@ -171,32 +170,32 @@ class PsaExportOperator(Operator, ExportHelper): self.armature = context.view_layer.objects.active # Populate actions list. - property_group.action_list.clear() + pg.action_list.clear() for action in bpy.data.actions: if not self.is_action_for_armature(action): continue - item = property_group.action_list.add() + item = pg.action_list.add() item.action = action item.action_name = action.name item.is_selected = True update_action_names(context) - if len(property_group.action_list) == 0: + if len(pg.action_list) == 0: # If there are no actions at all, we have nothing to export, so just cancel the operation. self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions to export.') return {'CANCELLED'} # Populate bone groups list. - populate_bone_group_list(self.armature, property_group.bone_group_list) + populate_bone_group_list(self.armature, pg.bone_group_list) context.window_manager.fileselect_add(self) return {'RUNNING_MODAL'} def execute(self, context): - property_group = context.scene.psa_export - actions = [x.action for x in property_group.action_list if x.is_selected] + pg = context.scene.psa_export + actions = [x.action for x in pg.action_list if x.is_selected] if len(actions) == 0: self.report({'ERROR_INVALID_CONTEXT'}, 'No actions were selected for export.') @@ -204,9 +203,9 @@ class PsaExportOperator(Operator, ExportHelper): options = PsaBuilderOptions() options.actions = actions - options.bone_filter_mode = property_group.bone_filter_mode - options.bone_group_indices = [x.index for x in property_group.bone_group_list if x.is_selected] - options.should_use_original_sequence_names = property_group.should_use_original_sequence_names + options.bone_filter_mode = pg.bone_filter_mode + options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected] + options.should_use_original_sequence_names = pg.should_use_original_sequence_names builder = PsaBuilder() try: psa = builder.build(context, options) @@ -246,14 +245,14 @@ class PsaExportSelectAll(bpy.types.Operator): @classmethod def poll(cls, context): - property_group = context.scene.psa_export - action_list = property_group.action_list + pg = context.scene.psa_export + action_list = pg.action_list has_unselected_actions = any(map(lambda action: not action.is_selected, action_list)) return len(action_list) > 0 and has_unselected_actions def execute(self, context): - property_group = context.scene.psa_export - for action in property_group.action_list: + pg = context.scene.psa_export + for action in pg.action_list: action.is_selected = True return {'FINISHED'} @@ -265,14 +264,14 @@ class PsaExportDeselectAll(bpy.types.Operator): @classmethod def poll(cls, context): - property_group = context.scene.psa_export - action_list = property_group.action_list + pg = context.scene.psa_export + action_list = pg.action_list has_selected_actions = any(map(lambda action: action.is_selected, action_list)) return len(action_list) > 0 and has_selected_actions def execute(self, context): - property_group = context.scene.psa_export - for action in property_group.action_list: + pg = context.scene.psa_export + for action in pg.action_list: action.is_selected = False return {'FINISHED'} diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 793721b..be19140 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -188,7 +188,7 @@ class PsaImporter(object): actions.append(action) - # If the user specifies, store the new animations as strips on a non-contributing NLA stack. + # If the user specifies, store the new animations as strips on a non-contributing NLA track. if options.should_stash: if armature_object.animation_data is None: armature_object.animation_data_create() @@ -199,43 +199,29 @@ class PsaImporter(object): nla_track.strips.new(name=action.name, start=0, action=action) -class PsaImportPsaBoneItem(PropertyGroup): - bone_name: StringProperty() - - @property - def name(self): - return self.bone_name - - class PsaImportActionListItem(PropertyGroup): action_name: StringProperty() - frame_count: IntProperty() is_selected: BoolProperty(default=False) - @property - def name(self): - return self.action_name - def load_psa_file(context): - property_group = context.scene.psa_import - property_group.sequence_list.clear() - property_group.psa_bones.clear() - property_group.action_list.clear() - property_group.psa_error = '' + pg = context.scene.psa_import + pg.sequence_list.clear() + pg.psa.bones.clear() + pg.action_list.clear() + pg.psa_error = '' try: # Read the file and populate the action list. - p = os.path.abspath(property_group.psa_file_path) + p = os.path.abspath(pg.psa_file_path) psa_reader = PsaReader(p) for sequence in psa_reader.sequences.values(): - item = property_group.sequence_list.add() + item = pg.sequence_list.add() item.action_name = sequence.name.decode('windows-1252') - item.frame_count = sequence.frame_count for psa_bone in psa_reader.bones: - item = property_group.psa_bones.add() + item = pg.psa.bones.add() item.bone_name = psa_bone.name.decode('windows-1252') except Exception as e: - property_group.psa_error = str(e) + pg.psa_error = str(e) def on_psa_file_path_updated(property, context): @@ -247,7 +233,6 @@ class PsaBonePropertyGroup(PropertyGroup): class PsaDataPropertyGroup(PropertyGroup): - bone_count: IntProperty(default=0) bones: CollectionProperty(type=PsaBonePropertyGroup) sequence_count: IntProperty(default=0) @@ -255,7 +240,7 @@ class PsaDataPropertyGroup(PropertyGroup): class PsaImportPropertyGroup(PropertyGroup): psa_file_path: StringProperty(default='', update=on_psa_file_path_updated, name='PSA File Path') psa_error: StringProperty(default='') - psa_bones: CollectionProperty(type=PsaImportPsaBoneItem) + psa: PointerProperty(type=PsaDataPropertyGroup) sequence_list: CollectionProperty(type=PsaImportActionListItem) sequence_list_index: IntProperty(name='', default=0) action_list: CollectionProperty(type=PsaImportActionListItem) @@ -288,7 +273,6 @@ class PSA_UL_SequenceList(UIList): def filter_items(self, context, data, property): actions = getattr(data, property) flt_flags = [] - flt_neworder = [] if self.filter_name: flt_flags = bpy.types.UI_UL_list.filter_items_by_name( self.filter_name, @@ -297,6 +281,7 @@ class PSA_UL_SequenceList(UIList): 'action_name', reverse=self.use_filter_invert ) + flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'action_name') return flt_flags, flt_neworder @@ -315,14 +300,14 @@ class PsaImportSequencesSelectAll(bpy.types.Operator): @classmethod def poll(cls, context): - property_group = context.scene.psa_import - sequence_list = property_group.sequence_list + pg = context.scene.psa_import + sequence_list = pg.sequence_list has_unselected_actions = any(map(lambda action: not action.is_selected, sequence_list)) return len(sequence_list) > 0 and has_unselected_actions def execute(self, context): - property_group = context.scene.psa_import - for action in property_group.sequence_list: + pg = context.scene.psa_import + for action in pg.sequence_list: action.is_selected = True return {'FINISHED'} @@ -334,14 +319,14 @@ class PsaImportActionsSelectAll(bpy.types.Operator): @classmethod def poll(cls, context): - property_group = context.scene.psa_import - action_list = property_group.action_list + pg = context.scene.psa_import + action_list = pg.action_list has_unselected_actions = any(map(lambda action: not action.is_selected, action_list)) return len(action_list) > 0 and has_unselected_actions def execute(self, context): - property_group = context.scene.psa_import - for action in property_group.action_list: + pg = context.scene.psa_import + for action in pg.action_list: action.is_selected = True return {'FINISHED'} @@ -353,14 +338,14 @@ class PsaImportSequencesDeselectAll(bpy.types.Operator): @classmethod def poll(cls, context): - property_group = context.scene.psa_import - sequence_list = property_group.sequence_list + pg = context.scene.psa_import + sequence_list = pg.sequence_list has_selected_sequences = any(map(lambda action: action.is_selected, sequence_list)) return len(sequence_list) > 0 and has_selected_sequences def execute(self, context): - property_group = context.scene.psa_import - for action in property_group.sequence_list: + pg = context.scene.psa_import + for action in pg.sequence_list: action.is_selected = False return {'FINISHED'} @@ -372,14 +357,14 @@ class PsaImportActionsDeselectAll(bpy.types.Operator): @classmethod def poll(cls, context): - property_group = context.scene.psa_import - action_list = property_group.action_list + pg = context.scene.psa_import + action_list = pg.action_list has_selected_actions = any(map(lambda action: action.is_selected, action_list)) return len(action_list) > 0 and has_selected_actions def execute(self, context): - property_group = context.scene.psa_import - for action in property_group.action_list: + pg = context.scene.psa_import + for action in pg.action_list: action.is_selected = False return {'FINISHED'} @@ -393,20 +378,35 @@ class PSA_PT_ImportPanel_Advanced(Panel): def draw(self, context): layout = self.layout - property_group = context.scene.psa_import + pg = context.scene.psa_import col = layout.column(heading="Options") col.use_property_split = True col.use_property_decorate = False - col.prop(property_group, 'should_clean_keys') + col.prop(pg, 'should_clean_keys') col.separator() - col.prop(property_group, 'should_use_fake_user') - col.prop(property_group, 'should_stash') + col.prop(pg, 'should_use_fake_user') + col.prop(pg, 'should_stash') col.separator() - col.prop(property_group, 'should_use_action_name_prefix') + col.prop(pg, 'should_use_action_name_prefix') - if property_group.should_use_action_name_prefix: - col.prop(property_group, 'action_name_prefix') + if pg.should_use_action_name_prefix: + col.prop(pg, 'action_name_prefix') + + +class PSA_PT_ImportPanel_PsaData(Panel): + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_label = 'PSA Info' + bl_options = {'DEFAULT_CLOSED'} + bl_parent_id = 'PSA_PT_ImportPanel' + + def draw(self, context): + layout = self.layout + pg = context.scene.psa_import.psa + + layout.label(text=f'{len(pg.bones)} Bones', icon='BONE_DATA') + layout.label(text=f'{len(pg.sequence_count)} Sequences', icon='SEQUENCE') class PSA_PT_ImportPanel(Panel): @@ -423,14 +423,14 @@ class PSA_PT_ImportPanel(Panel): def draw(self, context): layout = self.layout - property_group = context.scene.psa_import + pg = context.scene.psa_import row = layout.row(align=True) - row.operator('psa_import.select_file', text='', icon='FILEBROWSER') - row.prop(property_group, 'psa_file_path', text='') - row.operator('psa_import.file_reload', text='', icon='FILE_REFRESH') + row.operator(PsaImportSelectFile.bl_idname, text='', icon='FILEBROWSER') + row.prop(pg, 'psa_file_path', text='') + row.operator(PsaImportFileReload.bl_idname, text='', icon='FILE_REFRESH') - if property_group.psa_error != '': + if pg.psa_error != '': row = layout.row() row.label(text='File could not be read', icon='ERROR') @@ -439,33 +439,31 @@ class PSA_PT_ImportPanel(Panel): box.label(text=f'Sequences', icon='ARMATURE_DATA') # select - rows = max(3, min(len(property_group.sequence_list) + len(property_group.action_list), 10)) + rows = max(3, min(len(pg.sequence_list) + len(pg.action_list), 10)) row = box.row() col = row.column() row2 = col.row(align=True) row2.label(text='Select') - row2.operator('psa_import.sequences_select_all', text='All') - row2.operator('psa_import.sequences_deselect_all', text='None') + row2.operator(PsaImportSequencesSelectAll.bl_idname, text='All') + row2.operator(PsaImportSequencesDeselectAll.bl_idname, text='None') col = col.row() - col.template_list('PSA_UL_ImportSequenceList', '', property_group, 'sequence_list', property_group, - 'sequence_list_index', rows=rows) + col.template_list('PSA_UL_ImportSequenceList', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows) col = row.column(align=True) - col.operator('psa_import.push_to_actions', icon='TRIA_RIGHT', text='') - col.operator('psa_import.pop_from_actions', icon='TRIA_LEFT', text='') + col.operator(PsaImportPushToActions.bl_idname, icon='TRIA_RIGHT', text='') + col.operator(PsaImportPopFromActions.bl_idname, icon='TRIA_LEFT', text='') col = row.column() row2 = col.row(align=True) row2.label(text='Select') - row2.operator('psa_import.actions_select_all', text='All') - row2.operator('psa_import.actions_deselect_all', text='None') - col.template_list('PSA_UL_ImportActionList', '', property_group, 'action_list', property_group, - 'action_list_index', rows=rows) + row2.operator(PsaImportActionsSelectAll.bl_idname, text='All') + row2.operator(PsaImportActionsDeselectAll.bl_idname, text='None') + col.template_list('PSA_UL_ImportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) col.separator() - col.operator('psa_import.import', text=f'Import') + col.operator(PsaImportOperator.bl_idname, text=f'Import') class PsaImportFileReload(Operator): @@ -503,21 +501,21 @@ class PsaImportOperator(Operator): @classmethod def poll(cls, context): - property_group = context.scene.psa_import + pg = context.scene.psa_import active_object = context.view_layer.objects.active - action_list = property_group.action_list + action_list = pg.action_list return len(action_list) and active_object is not None and active_object.type == 'ARMATURE' def execute(self, context): - property_group = context.scene.psa_import - psa_reader = PsaReader(property_group.psa_file_path) - sequence_names = [x.action_name for x in property_group.action_list] + pg = context.scene.psa_import + psa_reader = PsaReader(pg.psa_file_path) + sequence_names = [x.action_name for x in pg.action_list] options = PsaImportOptions() options.sequence_names = sequence_names - options.should_clean_keys = property_group.should_clean_keys - options.should_use_fake_user = property_group.should_use_fake_user - options.should_stash = property_group.should_stash - options.action_name_prefix = property_group.action_name_prefix + options.should_clean_keys = pg.should_clean_keys + options.should_use_fake_user = pg.should_use_fake_user + options.should_stash = pg.should_stash + options.action_name_prefix = pg.action_name_prefix PsaImporter().import_psa(psa_reader, context.view_layer.objects.active, options) self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)') return {'FINISHED'} @@ -529,20 +527,20 @@ class PsaImportPushToActions(Operator): @classmethod def poll(cls, context): - property_group = context.scene.psa_import - has_sequences_selected = any(map(lambda x: x.is_selected, property_group.sequence_list)) + pg = context.scene.psa_import + has_sequences_selected = any(map(lambda x: x.is_selected, pg.sequence_list)) return has_sequences_selected def execute(self, context): - property_group = context.scene.psa_import + pg = context.scene.psa_import indices_to_remove = [] - for sequence_index, item in enumerate(property_group.sequence_list): + for sequence_index, item in enumerate(pg.sequence_list): if item.is_selected: indices_to_remove.append(sequence_index) - action = property_group.action_list.add() + action = pg.action_list.add() action.action_name = item.action_name for index in reversed(indices_to_remove): - property_group.sequence_list.remove(index) + pg.sequence_list.remove(index) return {'FINISHED'} @@ -552,20 +550,20 @@ class PsaImportPopFromActions(Operator): @classmethod def poll(cls, context): - property_group = context.scene.psa_import - has_actions_selected = any(map(lambda x: x.is_selected, property_group.action_list)) + pg = context.scene.psa_import + has_actions_selected = any(map(lambda x: x.is_selected, pg.action_list)) return has_actions_selected def execute(self, context): - property_group = context.scene.psa_import + pg = context.scene.psa_import indices_to_remove = [] - for action_index, item in enumerate(property_group.action_list): + for action_index, item in enumerate(pg.action_list): if item.is_selected: indices_to_remove.append(action_index) - sequence = property_group.sequence_list.add() + sequence = pg.sequence_list.add() sequence.action_name = item.action_name for index in reversed(indices_to_remove): - property_group.action_list.remove(index) + pg.action_list.remove(index) return {'FINISHED'} @@ -585,15 +583,15 @@ class PsaImportFileSelectOperator(Operator, ImportHelper): return {'RUNNING_MODAL'} def execute(self, context): - property_group = context.scene.psa_import - property_group.psa_file_path = self.filepath - # Load the sequence names from the selected file + pg = context.scene.psa_import + pg.psa_file_path = self.filepath return {'FINISHED'} classes = ( - PsaImportPsaBoneItem, PsaImportActionListItem, + PsaBonePropertyGroup, + PsaDataPropertyGroup, PsaImportPropertyGroup, PSA_UL_SequenceList, PSA_UL_ImportSequenceList, @@ -605,6 +603,7 @@ classes = ( PsaImportFileReload, PSA_PT_ImportPanel, PSA_PT_ImportPanel_Advanced, + PSA_PT_ImportPanel_PsaData, PsaImportOperator, PsaImportFileSelectOperator, PsaImportSelectFile, diff --git a/io_scene_psk_psa/psk/exporter.py b/io_scene_psk_psa/psk/exporter.py index ee0b188..504f29d 100644 --- a/io_scene_psk_psa/psk/exporter.py +++ b/io_scene_psk_psa/psk/exporter.py @@ -90,10 +90,10 @@ class PskExportOperator(Operator, ExportHelper): self.report({'ERROR_INVALID_CONTEXT'}, str(e)) return {'CANCELLED'} - property_group = context.scene.psk_export + pg = context.scene.psk_export # Populate bone groups list. - populate_bone_group_list(input_objects.armature_object, property_group.bone_group_list) + populate_bone_group_list(input_objects.armature_object, pg.bone_group_list) context.window_manager.fileselect_add(self) @@ -102,29 +102,29 @@ class PskExportOperator(Operator, ExportHelper): def draw(self, context): layout = self.layout scene = context.scene - property_group = scene.psk_export + pg = scene.psk_export # BONES box = layout.box() box.label(text='Bones', icon='BONE_DATA') - bone_filter_mode_items = property_group.bl_rna.properties['bone_filter_mode'].enum_items_static + bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static row = box.row(align=True) for item in bone_filter_mode_items: identifier = item.identifier item_layout = row.row(align=True) - item_layout.prop_enum(property_group, 'bone_filter_mode', item.identifier) + item_layout.prop_enum(pg, 'bone_filter_mode', item.identifier) item_layout.enabled = is_bone_filter_mode_item_available(context, identifier) - if property_group.bone_filter_mode == 'BONE_GROUPS': + if pg.bone_filter_mode == 'BONE_GROUPS': row = box.row() - rows = max(3, min(len(property_group.bone_group_list), 10)) - row.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, 'bone_group_list_index', rows=rows) + rows = max(3, min(len(pg.bone_group_list), 10)) + row.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) def execute(self, context): - property_group = context.scene.psk_export + pg = context.scene.psk_export builder = PskBuilder() options = PskBuilderOptions() - options.bone_group_indices = [x.index for x in property_group.bone_group_list if x.is_selected] + options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected] try: psk = builder.build(context, options) exporter = PskExporter(psk) From 7ad8f0238a3a70e0fbea8939c152d408bdae6e47 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Fri, 11 Feb 2022 15:21:31 -0800 Subject: [PATCH 04/22] Added the ability to export sequences using timeline markers (WIP, not thoroughly tested yet!) A bunch of clean up --- io_scene_psk_psa/helpers.py | 38 +++++++-- io_scene_psk_psa/psa/builder.py | 123 ++++++++++++++++++++++++----- io_scene_psk_psa/psa/exporter.py | 128 +++++++++++++++++++++++-------- io_scene_psk_psa/psa/importer.py | 22 ++++-- io_scene_psk_psa/psk/builder.py | 16 ++-- io_scene_psk_psa/psk/exporter.py | 1 + io_scene_psk_psa/psk/importer.py | 2 +- io_scene_psk_psa/types.py | 7 +- 8 files changed, 262 insertions(+), 75 deletions(-) diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index 8bf856a..6164a30 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -1,4 +1,6 @@ +from bpy.types import NlaStrip from typing import List +from collections import Counter def rgb_to_srgb(c): @@ -8,19 +10,45 @@ def rgb_to_srgb(c): return 12.92 * c +def get_nla_strips_ending_at_frame(object, frame) -> List[NlaStrip]: + if object is None or object.animation_data is None: + return [] + strips = [] + for nla_track in object.animation_data.nla_tracks: + for strip in nla_track.strips: + if strip.frame_end == frame: + strips.append(strip) + return strips + + +def get_nla_strips_in_timeframe(object, frame_min, frame_max) -> List[NlaStrip]: + if object is None or object.animation_data is None: + return [] + strips = [] + for nla_track in object.animation_data.nla_tracks: + for strip in nla_track.strips: + if strip.frame_end >= frame_min and strip.frame_start <= frame_max: + strips.append(strip) + return strips + + def populate_bone_group_list(armature_object, bone_group_list): bone_group_list.clear() - item = bone_group_list.add() - item.name = '(unassigned)' - item.index = -1 - item.is_selected = True - if armature_object and armature_object.pose: + bone_group_counts = Counter(map(lambda x: x.bone_group, armature_object.pose.bones)) + + item = bone_group_list.add() + item.name = 'Unassigned' + item.index = -1 + item.count = 0 if None not in bone_group_counts else bone_group_counts[None] + item.is_selected = True + for bone_group_index, bone_group in enumerate(armature_object.pose.bone_groups): item = bone_group_list.add() item.name = bone_group.name item.index = bone_group_index + item.count = 0 if bone_group not in bone_group_counts else bone_group_counts[bone_group] item.is_selected = True diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index a359096..74da670 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -1,13 +1,17 @@ from .data import * from ..helpers import * +from typing import Dict class PsaBuilderOptions(object): def __init__(self): + self.sequence_source = 'ACTIONS' self.actions = [] + self.marker_names = [] self.bone_filter_mode = 'ALL' self.bone_group_indices = [] self.should_use_original_sequence_names = False + self.should_trim_timeline_marker_sequences = True class PsaBuilder(object): @@ -25,6 +29,12 @@ class PsaBuilder(object): if armature.animation_data is None: raise RuntimeError('No animation data for armature') + # Ensure that we actually have items that we are going to be exporting. + if options.sequence_source == 'ACTIONS' and len(options.actions) == 0: + raise RuntimeError('No actions were selected for export') + elif options.sequence_source == 'TIMELINE_MARKERS' and len(options.marker_names) == 0: + raise RuntimeError('No timeline markers were selected for export') + psa = Psa() bones = list(armature.data.bones) @@ -59,6 +69,7 @@ class PsaBuilder(object): raise RuntimeError('Exported bone hierarchy must have a single root bone.' f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}') + # Build list of PSA bones. for pose_bone in bones: psa_bone = Psa.Bone() psa_bone.name = bytes(pose_bone.name, encoding='utf-8') @@ -95,28 +106,65 @@ class PsaBuilder(object): psa.bones.append(psa_bone) + # Populate the export sequence list. + class ExportSequence: + def __init__(self): + self.name = '' + self.frame_min = 0 + self.frame_max = 0 + self.action = None + self.nla_strips_to_be_muted = [] + + export_sequences = [] + + if options.sequence_source == 'ACTIONS': + for action in options.actions: + if len(action.fcurves) == 0: + continue + export_sequence = ExportSequence() + export_sequence.action = action + export_sequence.name = get_psa_sequence_name(action, options.should_use_original_sequence_names) + export_sequence.frame_min, export_sequence.frame_max = [int(x) for x in action.frame_range] + export_sequences.append(export_sequence) + pass + elif options.sequence_source == 'TIMELINE_MARKERS': + sequence_frame_ranges = self.get_timeline_marker_sequence_frame_ranges(armature, context, options) + for name, (frame_min, frame_max) in sequence_frame_ranges.items(): + export_sequence = ExportSequence() + export_sequence.action = None + export_sequence.name = name + export_sequence.frame_min = frame_min + export_sequence.frame_max = frame_max + export_sequence.nla_strips_to_be_muted = get_nla_strips_ending_at_frame(armature, frame_min) + export_sequences.append(export_sequence) + else: + raise ValueError(f'Unhandled sequence source: {options.sequence_source}') + frame_start_index = 0 - for action in options.actions: - if len(action.fcurves) == 0: - continue - - armature.animation_data.action = action + # Now build the PSA sequences. + # We actually alter the timeline frame and simply record the resultant pose bone matrices. + for export_sequence in export_sequences: + armature.animation_data.action = export_sequence.action context.view_layer.update() - frame_min, frame_max = [int(x) for x in action.frame_range] + psa_sequence = Psa.Sequence() - sequence = Psa.Sequence() + frame_min = export_sequence.frame_min + frame_max = export_sequence.frame_max - sequence_name = get_psa_sequence_name(action, options.should_use_original_sequence_names) - - sequence.name = bytes(sequence_name, encoding='windows-1252') - sequence.frame_count = frame_max - frame_min + 1 - sequence.frame_start_index = frame_start_index - sequence.fps = context.scene.render.fps + psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252') + psa_sequence.frame_count = frame_max - frame_min + 1 + psa_sequence.frame_start_index = frame_start_index + psa_sequence.fps = context.scene.render.fps frame_count = frame_max - frame_min + 1 + # Store the mute state of the NLA strips we need to mute so we can restore the state after we are done. + nla_strip_mute_statuses = {x: x.mute for x in export_sequence.nla_strips_to_be_muted} + for nla_strip in export_sequence.nla_strips_to_be_muted: + nla_strip.mute = True + for frame in range(frame_count): context.scene.frame_set(frame_min + frame) @@ -143,15 +191,54 @@ class PsaBuilder(object): key.rotation.y = rotation.y key.rotation.z = rotation.z key.rotation.w = rotation.w - key.time = 1.0 / sequence.fps + key.time = 1.0 / psa_sequence.fps psa.keys.append(key) - frame_start_index += 1 + export_sequence.bone_count = len(pose_bones) + export_sequence.track_time = frame_count - sequence.bone_count = len(pose_bones) - sequence.track_time = frame_count + # Restore the mute state of the NLA strips we muted beforehand. + for nla_strip, mute in nla_strip_mute_statuses.items(): + nla_strip.mute = mute - psa.sequences[action.name] = sequence + frame_start_index += frame_count + + psa.sequences[export_sequence.name] = psa_sequence return psa + + def get_timeline_marker_sequence_frame_ranges(self, object, context, options: PsaBuilderOptions) -> Dict: + # Timeline markers need to be sorted so that we can determine the sequence start and end positions. + sequence_frame_ranges = dict() + sorted_timeline_markers = list(sorted(context.scene.timeline_markers, key=lambda x: x.frame)) + sorted_timeline_marker_names = list(map(lambda x: x.name, sorted_timeline_markers)) + + for marker_name in options.marker_names: + marker = context.scene.timeline_markers[marker_name] + frame_min = marker.frame + # Determine the final frame of the sequence based on the next marker. + # If no subsequent marker exists, use the maximum frame_end from all NLA strips. + marker_index = sorted_timeline_marker_names.index(marker_name) + next_marker_index = marker_index + 1 + frame_max = 0 + if next_marker_index < len(sorted_timeline_markers): + # There is a next marker. Use that next marker's frame position as the last frame of this sequence. + frame_max = sorted_timeline_markers[next_marker_index].frame + if options.should_trim_timeline_marker_sequences: + nla_strips = get_nla_strips_in_timeframe(object, marker.frame, frame_max) + frame_max = min(frame_max, max(map(lambda x: x.frame_end, nla_strips))) + frame_min = max(frame_min, min(map(lambda x: x.frame_start, nla_strips))) + else: + # There is no next marker. + # Find the final frame of all the NLA strips and use that as the last frame of this sequence. + for nla_track in object.animation_data.nla_tracks: + for strip in nla_track.strips: + frame_max = max(frame_max, strip.frame_end) + + if frame_min == frame_max: + continue + + sequence_frame_ranges[marker_name] = int(frame_min), int(frame_max) + + return sequence_frame_ranges diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 470b8c9..770df29 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -1,5 +1,5 @@ import bpy -from bpy.types import Operator, PropertyGroup, Action, UIList, BoneGroup, Panel +from bpy.types import Operator, PropertyGroup, Action, UIList, BoneGroup, Panel, TimelineMarker from bpy.props import CollectionProperty, IntProperty, PointerProperty, StringProperty, BoolProperty, EnumProperty from bpy_extras.io_utils import ExportHelper from typing import Type @@ -46,6 +46,16 @@ class PsaExportActionListItem(PropertyGroup): return self.action.name +class PsaExportTimelineMarkerListItem(PropertyGroup): + marker_index: IntProperty() + marker_name: StringProperty() + is_selected: BoolProperty(default=True) + + @property + def name(self): + return self.marker_name + + def update_action_names(context): pg = context.scene.psa_export for item in pg.action_list: @@ -58,18 +68,28 @@ def should_use_original_sequence_names_updated(property, context): class PsaExportPropertyGroup(PropertyGroup): + sequence_source: EnumProperty( + name='Source', + description='', + items=( + ('ACTIONS', 'Actions', 'Sequences will be exported using actions'), + ('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers'), + ) + ) action_list: CollectionProperty(type=PsaExportActionListItem) action_list_index: IntProperty(default=0) + marker_list: CollectionProperty(type=PsaExportTimelineMarkerListItem) + marker_list_index: IntProperty(default=0) bone_filter_mode: EnumProperty( name='Bone Filter', description='', items=( ('ALL', 'All', 'All bones will be exported.'), - ('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will be exported.') + ('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will be exported.'), ) ) bone_group_list: CollectionProperty(type=BoneGroupListItem) - bone_group_list_index: IntProperty(default=0) + bone_group_list_index: IntProperty(default=0, name='', description='') should_use_original_sequence_names: BoolProperty(default=False, name='Original Names', description='If the action was imported from the PSA Import panel, the original name of the sequence will be used instead of the Blender action name', update=should_use_original_sequence_names_updated) @@ -84,6 +104,7 @@ def is_bone_filter_mode_item_available(context, identifier): class PsaExportOperator(Operator, ExportHelper): bl_idname = 'psa_export.operator' bl_label = 'Export' + bl_options = {'INTERNAL', 'UNDO'} __doc__ = 'Export actions to PSA' filename_ext = '.psa' filter_glob: StringProperty(default='*.psa', options={'HIDDEN'}) @@ -100,24 +121,34 @@ class PsaExportOperator(Operator, ExportHelper): layout = self.layout pg = context.scene.psa_export - # ACTIONS - layout.label(text='Actions', icon='ACTION') - row = layout.row(align=True) - row.label(text='Select') - row.operator(PsaExportSelectAll.bl_idname, text='All') - row.operator(PsaExportDeselectAll.bl_idname, text='None') - row = layout.row() - rows = max(3, min(len(pg.action_list), 10)) - row.template_list('PSA_UL_ExportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) + # SOURCE + layout.prop(pg, 'sequence_source', text='Source') - col = layout.column(heading="Options") - col.use_property_split = True - col.use_property_decorate = False - col.prop(pg, 'should_use_original_sequence_names') + # ACTIONS + if pg.sequence_source == 'ACTIONS': + layout.label(text='Actions', icon='ACTION') + row = layout.row(align=True) + row.label(text='Select') + row.operator(PsaExportSelectAll.bl_idname, text='All') + row.operator(PsaExportDeselectAll.bl_idname, text='None') + row = layout.row() + rows = max(3, min(len(pg.action_list), 10)) + row.template_list('PSA_UL_ExportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) + + col = layout.column(heading="Options") + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'should_use_original_sequence_names') + elif pg.sequence_source == 'TIMELINE_MARKERS': + layout.label(text='Markers', icon='MARKER') + + row = layout.row() + rows = max(3, min(len(pg.marker_list), 10)) + row.template_list('PSA_UL_ExportTimelineMarkerList', '', pg, 'marker_list', pg, 'marker_list_index', rows=rows) # Determine if there is going to be a naming conflict and display an error, if so. - selected_actions = [x for x in pg.action_list if x.is_selected] - action_names = [x.action_name for x in selected_actions] + selected_items = [x for x in pg.action_list if x.is_selected] + action_names = [x.action_name for x in selected_items] action_name_counts = Counter(action_names) for action_name, count in action_name_counts.items(): if count > 1: @@ -180,9 +211,15 @@ class PsaExportOperator(Operator, ExportHelper): update_action_names(context) - if len(pg.action_list) == 0: + # Populate timeline markers list. + pg.marker_list.clear() + for marker in context.scene.timeline_markers: + item = pg.marker_list.add() + item.marker_name = marker.name + + if len(pg.action_list) == 0 and len(pg.marker_names) == 0: # If there are no actions at all, we have nothing to export, so just cancel the operation. - self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions to export.') + self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions or timeline markers to export.') return {'CANCELLED'} # Populate bone groups list. @@ -194,28 +231,51 @@ class PsaExportOperator(Operator, ExportHelper): def execute(self, context): pg = context.scene.psa_export - actions = [x.action for x in pg.action_list if x.is_selected] - if len(actions) == 0: - self.report({'ERROR_INVALID_CONTEXT'}, 'No actions were selected for export.') - return {'CANCELLED'} + actions = [x.action for x in pg.action_list if x.is_selected] + marker_names = [x.marker_name for x in pg.marker_list if x.is_selected] options = PsaBuilderOptions() + options.sequence_source = pg.sequence_source options.actions = actions + options.marker_names = marker_names options.bone_filter_mode = pg.bone_filter_mode options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected] options.should_use_original_sequence_names = pg.should_use_original_sequence_names builder = PsaBuilder() + try: psa = builder.build(context, options) except RuntimeError as e: self.report({'ERROR_INVALID_CONTEXT'}, str(e)) return {'CANCELLED'} + exporter = PsaExporter(psa) exporter.export(self.filepath) return {'FINISHED'} +class PSA_UL_ExportTimelineMarkerList(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): + layout.alignment = 'LEFT' + layout.prop(item, 'is_selected', icon_only=True) + layout.label(text=item.marker_name) + + def filter_items(self, context, data, property): + actions = getattr(data, property) + flt_flags = [] + flt_neworder = [] + if self.filter_name: + flt_flags = bpy.types.UI_UL_list.filter_items_by_name( + self.filter_name, + self.bitflag_filter_item, + actions, + 'marker_name', + reverse=self.use_filter_invert + ) + return flt_flags, flt_neworder + + class PSA_UL_ExportActionList(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): layout.alignment = 'LEFT' @@ -237,17 +297,18 @@ class PSA_UL_ExportActionList(UIList): return flt_flags, flt_neworder -class PsaExportSelectAll(bpy.types.Operator): +class PsaExportSelectAll(Operator): bl_idname = 'psa_export.actions_select_all' bl_label = 'Select All' bl_description = 'Select all actions' + bl_options = {'INTERNAL'} @classmethod def poll(cls, context): pg = context.scene.psa_export - action_list = pg.action_list - has_unselected_actions = any(map(lambda action: not action.is_selected, action_list)) - return len(action_list) > 0 and has_unselected_actions + item_list = pg.action_list + has_unselected_actions = any(map(lambda action: not action.is_selected, item_list)) + return len(item_list) > 0 and has_unselected_actions def execute(self, context): pg = context.scene.psa_export @@ -256,17 +317,18 @@ class PsaExportSelectAll(bpy.types.Operator): return {'FINISHED'} -class PsaExportDeselectAll(bpy.types.Operator): +class PsaExportDeselectAll(Operator): bl_idname = 'psa_export.actions_deselect_all' bl_label = 'Deselect All' bl_description = 'Deselect all actions' + bl_options = {'INTERNAL'} @classmethod def poll(cls, context): pg = context.scene.psa_export - action_list = pg.action_list - has_selected_actions = any(map(lambda action: action.is_selected, action_list)) - return len(action_list) > 0 and has_selected_actions + item_list = pg.action_list + has_selected_actions = any(map(lambda action: action.is_selected, item_list)) + return len(item_list) > 0 and has_selected_actions def execute(self, context): pg = context.scene.psa_export @@ -277,9 +339,11 @@ class PsaExportDeselectAll(bpy.types.Operator): classes = ( PsaExportActionListItem, + PsaExportTimelineMarkerListItem, PsaExportPropertyGroup, PsaExportOperator, PSA_UL_ExportActionList, + PSA_UL_ExportTimelineMarkerList, PsaExportSelectAll, PsaExportDeselectAll, ) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 4be3729..871a22a 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -293,10 +293,11 @@ class PSA_UL_ImportActionList(PSA_UL_SequenceList, UIList): pass -class PsaImportSequencesSelectAll(bpy.types.Operator): +class PsaImportSequencesSelectAll(Operator): bl_idname = 'psa_import.sequences_select_all' bl_label = 'All' bl_description = 'Select all sequences' + bl_options = {'INTERNAL'} @classmethod def poll(cls, context): @@ -312,10 +313,11 @@ class PsaImportSequencesSelectAll(bpy.types.Operator): return {'FINISHED'} -class PsaImportActionsSelectAll(bpy.types.Operator): +class PsaImportActionsSelectAll(Operator): bl_idname = 'psa_import.actions_select_all' bl_label = 'All' bl_description = 'Select all actions' + bl_options = {'INTERNAL'} @classmethod def poll(cls, context): @@ -331,10 +333,11 @@ class PsaImportActionsSelectAll(bpy.types.Operator): return {'FINISHED'} -class PsaImportSequencesDeselectAll(bpy.types.Operator): +class PsaImportSequencesDeselectAll(Operator): bl_idname = 'psa_import.sequences_deselect_all' bl_label = 'None' bl_description = 'Deselect all sequences' + bl_options = {'INTERNAL'} @classmethod def poll(cls, context): @@ -350,10 +353,11 @@ class PsaImportSequencesDeselectAll(bpy.types.Operator): return {'FINISHED'} -class PsaImportActionsDeselectAll(bpy.types.Operator): +class PsaImportActionsDeselectAll(Operator): bl_idname = 'psa_import.actions_deselect_all' bl_label = 'None' bl_description = 'Deselect all actions' + bl_options = {'INTERNAL'} @classmethod def poll(cls, context): @@ -406,7 +410,7 @@ class PSA_PT_ImportPanel_PsaData(Panel): pg = context.scene.psa_import.psa layout.label(text=f'{len(pg.bones)} Bones', icon='BONE_DATA') - layout.label(text=f'{len(pg.sequence_count)} Sequences', icon='SEQUENCE') + layout.label(text=f'{pg.sequence_count} Sequences', icon='SEQUENCE') class PSA_PT_ImportPanel(Panel): @@ -469,7 +473,7 @@ class PSA_PT_ImportPanel(Panel): class PsaImportFileReload(Operator): bl_idname = 'psa_import.file_reload' bl_label = 'Refresh' - bl_options = {'REGISTER'} + bl_options = {'INTERNAL'} bl_description = 'Refresh the PSA file' def execute(self, context): @@ -480,7 +484,7 @@ class PsaImportFileReload(Operator): class PsaImportSelectFile(Operator): bl_idname = 'psa_import.select_file' bl_label = 'Select' - bl_options = {'REGISTER', 'UNDO'} + bl_options = {'INTERNAL'} bl_description = 'Select a PSA file from which to import animations' filepath: bpy.props.StringProperty(subtype='FILE_PATH') filter_glob: bpy.props.StringProperty(default="*.psa", options={'HIDDEN'}) @@ -498,6 +502,7 @@ class PsaImportOperator(Operator): bl_idname = 'psa_import.import' bl_label = 'Import' bl_description = 'Import the selected animations into the scene as actions' + bl_options = {'INTERNAL', 'UNDO'} @classmethod def poll(cls, context): @@ -524,6 +529,7 @@ class PsaImportOperator(Operator): class PsaImportPushToActions(Operator): bl_idname = 'psa_import.push_to_actions' bl_label = 'Push to Actions' + bl_options = {'INTERNAL'} @classmethod def poll(cls, context): @@ -547,6 +553,7 @@ class PsaImportPushToActions(Operator): class PsaImportPopFromActions(Operator): bl_idname = 'psa_import.pop_from_actions' bl_label = 'Pop From Actions' + bl_options = {'INTERNAL'} @classmethod def poll(cls, context): @@ -570,6 +577,7 @@ class PsaImportPopFromActions(Operator): class PsaImportFileSelectOperator(Operator, ImportHelper): bl_idname = 'psa_import.file_select' bl_label = 'File Select' + bl_options = {'INTERNAL'} filename_ext = '.psa' filter_glob: StringProperty(default='*.psa', options={'HIDDEN'}) filepath: StringProperty( diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 44425cc..5b8362c 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -70,7 +70,7 @@ class PskBuilder(object): # If the mesh has no armature object, simply assign it a dummy bone at the root to satisfy the requirement # that a PSK file must have at least one bone. psk_bone = Psk.Bone() - psk_bone.name = bytes('static', encoding='utf-8') + psk_bone.name = bytes('static', encoding='windows-1252') psk_bone.flags = 0 psk_bone.children_count = 0 psk_bone.parent_index = 0 @@ -88,8 +88,6 @@ class PskBuilder(object): # Ensure that the exported hierarchy has a single root bone. root_bones = [x for x in bones if x.parent is None] - print('root bones') - print(root_bones) if len(root_bones) > 1: root_bone_names = [x.name for x in root_bones] raise RuntimeError('Exported bone hierarchy must have a single root bone.' @@ -97,7 +95,7 @@ class PskBuilder(object): for bone in bones: psk_bone = Psk.Bone() - psk_bone.name = bytes(bone.name, encoding='utf-8') + psk_bone.name = bytes(bone.name, encoding='windows-1252') psk_bone.flags = 0 psk_bone.children_count = 0 @@ -133,9 +131,9 @@ class PskBuilder(object): psk.bones.append(psk_bone) - vertex_offset = 0 - for object in input_objects.mesh_objects: + vertex_offset = len(psk.points) + # VERTICES for vertex in object.data.vertices: point = Vector3() @@ -153,8 +151,10 @@ class PskBuilder(object): if m is None: raise RuntimeError('Material cannot be empty (index ' + str(i) + ')') if m.name in materials: + # Material already evaluated, just get its index. material_index = list(materials.keys()).index(m.name) else: + # New material. material = Psk.Material() material.name = bytes(m.name, encoding='utf-8') material.texture_index = len(psk.materials) @@ -230,9 +230,9 @@ class PskBuilder(object): bone = bone.parent for vertex_group_index, vertex_group in enumerate(object.vertex_groups): if vertex_group_index not in vertex_group_bone_indices: + # Vertex group has no associated bone, skip it. continue bone_index = vertex_group_bone_indices[vertex_group_index] - # TODO: exclude vertex group if it doesn't match to a bone we are exporting for vertex_index in range(len(object.data.vertices)): try: weight = vertex_group.weight(vertex_index) @@ -246,6 +246,4 @@ class PskBuilder(object): w.weight = weight psk.weights.append(w) - vertex_offset = len(psk.points) - return psk diff --git a/io_scene_psk_psa/psk/exporter.py b/io_scene_psk_psa/psk/exporter.py index 504f29d..893559d 100644 --- a/io_scene_psk_psa/psk/exporter.py +++ b/io_scene_psk_psa/psk/exporter.py @@ -73,6 +73,7 @@ def is_bone_filter_mode_item_available(context, identifier): class PskExportOperator(Operator, ExportHelper): bl_idname = 'export.psk' bl_label = 'Export' + bl_options = {'INTERNAL', 'UNDO'} __doc__ = 'Export mesh and armature to PSK' filename_ext = '.psk' filter_glob: StringProperty(default='*.psk', options={'HIDDEN'}) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index c47d183..cd0f90c 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -245,6 +245,7 @@ class PskImportPropertyGroup(PropertyGroup): class PskImportOperator(Operator, ImportHelper): bl_idname = 'import.psk' bl_label = 'Export' + bl_options = {'INTERNAL', 'UNDO'} __doc__ = 'Load a PSK file' filename_ext = '.psk' filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'}) @@ -276,7 +277,6 @@ class PskImportOperator(Operator, ImportHelper): layout.prop(pg, 'vertex_color_space') - classes = ( PskImportOperator, PskImportPropertyGroup, diff --git a/io_scene_psk_psa/types.py b/io_scene_psk_psa/types.py index 89b6b26..142f4fb 100644 --- a/io_scene_psk_psa/types.py +++ b/io_scene_psk_psa/types.py @@ -4,14 +4,15 @@ from bpy.props import StringProperty, IntProperty, BoolProperty class PSX_UL_BoneGroupList(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - layout.alignment = 'LEFT' - layout.prop(item, 'is_selected', icon_only=True) - layout.label(text=item.name, icon='GROUP_BONE' if item.index >= 0 else 'NONE') + row = layout.row() + row.prop(item, 'is_selected', text=item.name) + row.label(text=str(item.count), icon='BONE_DATA') class BoneGroupListItem(PropertyGroup): name: StringProperty() index: IntProperty() + count: IntProperty() is_selected: BoolProperty(default=False) @property From 5e7c2535e225334074cf7f1ca1ab1af0f5e889a4 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 12 Feb 2022 01:45:19 -0800 Subject: [PATCH 05/22] * Unified how bones are filtered for export based on export settings (bone filter mode + bone groups) * Bone Group filtering now works properly for PSK export * Fixed a number of bugs that broke animation export --- io_scene_psk_psa/helpers.py | 57 ++++++++++++++++++++------ io_scene_psk_psa/psa/builder.py | 44 ++++++++------------- io_scene_psk_psa/psa/exporter.py | 68 ++++++++++++++++++++++++++------ io_scene_psk_psa/psk/builder.py | 16 +------- io_scene_psk_psa/psk/exporter.py | 1 + 5 files changed, 123 insertions(+), 63 deletions(-) diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index 6164a30..e2af18b 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -1,5 +1,5 @@ from bpy.types import NlaStrip -from typing import List +from typing import List, Tuple, Optional from collections import Counter @@ -59,13 +59,14 @@ def get_psa_sequence_name(action, should_use_original_sequence_name): return action.name -def get_export_bone_indices_for_bone_groups(armature_object, bone_group_indices: List[int]) -> List[int]: +def get_export_bone_names(armature_object, bone_filter_mode, bone_group_indices: List[int]) -> List[str]: """ - Returns a sorted list of bone indices that should be exported for the given bone groups. + Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone groups. Note that the ancestors of bones within the bone groups will also be present in the returned list. :param armature_object: Blender object with type 'ARMATURE' + :param bone_filter_mode: One of ['ALL', 'BONE_GROUPS'] :param bone_group_indices: List of bone group indices to be exported. :return: A sorted list of bone indices that should be exported. """ @@ -76,24 +77,58 @@ def get_export_bone_indices_for_bone_groups(armature_object, bone_group_indices: pose_bones = armature_object.pose.bones bone_names = [x.name for x in bones] - # Get a list of the bone indices that are explicitly part of the bone groups we are including. + # Get a list of the bone indices that we are explicitly including. bone_index_stack = [] is_exporting_none_bone_groups = -1 in bone_group_indices for bone_index, pose_bone in enumerate(pose_bones): - if (pose_bone.bone_group is None and is_exporting_none_bone_groups) or \ + if bone_filter_mode == 'ALL' or \ + (pose_bone.bone_group is None and is_exporting_none_bone_groups) or \ (pose_bone.bone_group is not None and pose_bone.bone_group_index in bone_group_indices): - bone_index_stack.append(bone_index) + bone_index_stack.append((bone_index, None)) # For each bone that is explicitly being added, recursively walk up the hierarchy and ensure that all of # those ancestor bone indices are also in the list. - bone_indices = set() + bone_indices = dict() while len(bone_index_stack) > 0: - bone_index = bone_index_stack.pop() + bone_index, instigator_bone_index = bone_index_stack.pop() bone = bones[bone_index] if bone.parent is not None: parent_bone_index = bone_names.index(bone.parent.name) if parent_bone_index not in bone_indices: - bone_index_stack.append(parent_bone_index) - bone_indices.add(bone_index) + bone_index_stack.append((parent_bone_index, bone_index)) + bone_indices[bone_index] = instigator_bone_index - return list(sorted(list(bone_indices))) + # Sort the bone index list in-place. + bone_indices = [(x[0], x[1]) for x in bone_indices.items()] + bone_indices.sort(key=lambda x: x[0]) + + # Split out the bone indices and the instigator bone names into separate lists. + # We use the bone names for the return values because the bone name is a more universal way of referencing them. + # For example, users of this function may modify bone lists, which would invalidate the indices and require a + # index mapping scheme to resolve it. Using strings is more comfy and results in less code downstream. + instigator_bone_names = [bones[x[1]].name if x[1] is not None else None for x in bone_indices] + bone_names = [bones[x[0]].name for x in bone_indices] + + # Ensure that the hierarchy we are sending back has a single root bone. + bone_indices = [x[0] for x in bone_indices] + root_bones = [bones[bone_index] for bone_index in bone_indices if bones[bone_index].parent is None] + if len(root_bones) > 1: + # There is more than one root bone. + # Print out why each root bone was included by linking it to one of the explicitly included bones. + root_bone_names = [bone.name for bone in root_bones] + for root_bone_name in root_bone_names: + bone_name = root_bone_name + while True: + # Traverse the instigator chain until the end to find the true instigator bone. + # TODO: in future, it would be preferential to have a readout of *all* instigator bones. + instigator_bone_name = instigator_bone_names[bone_names.index(bone_name)] + if instigator_bone_name is None: + print(f'Root bone "{root_bone_name}" was included because {bone_name} was marked for export') + break + bone_name = instigator_bone_name + + raise RuntimeError('Exported bone hierarchy must have a single root bone.\n' + f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}.\n' + f'Additional debugging information has been written to the console.') + + return bone_names diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 74da670..20a5f6f 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -44,55 +44,45 @@ class PsaBuilder(object): # armature bones. bone_names = [x.name for x in bones] pose_bones = [(bone_names.index(bone.name), bone) for bone in armature.pose.bones] - del bone_names pose_bones.sort(key=lambda x: x[0]) pose_bones = [x[1] for x in pose_bones] - bone_indices = list(range(len(bones))) - - # If bone groups are specified, get only the bones that are in that specified bone groups and their ancestors. - if options.bone_filter_mode == 'BONE_GROUPS': - bone_indices = get_export_bone_indices_for_bone_groups(armature, options.bone_group_indices) + # Get a list of all the bone indices and instigator bones for the bone filter settings. + bone_names = get_export_bone_names(armature, options.bone_filter_mode, options.bone_group_indices) + bone_indices = [bone_names.index(x) for x in bone_names] # Make the bone lists contain only the bones that are going to be exported. bones = [bones[bone_index] for bone_index in bone_indices] pose_bones = [pose_bones[bone_index] for bone_index in bone_indices] + # No bones are going to be exported. if len(bones) == 0: - # No bones are going to be exported. raise RuntimeError('No bones available for export') - # Ensure that the exported hierarchy has a single root bone. - root_bones = [x for x in bones if x.parent is None] - if len(root_bones) > 1: - root_bone_names = [x.name for x in root_bones] - raise RuntimeError('Exported bone hierarchy must have a single root bone.' - f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}') - # Build list of PSA bones. - for pose_bone in bones: + for bone in bones: psa_bone = Psa.Bone() - psa_bone.name = bytes(pose_bone.name, encoding='utf-8') + psa_bone.name = bytes(bone.name, encoding='utf-8') try: - parent_index = bones.index(pose_bone.parent) + parent_index = bones.index(bone.parent) psa_bone.parent_index = parent_index psa.bones[parent_index].children_count += 1 except ValueError: psa_bone.parent_index = -1 - if pose_bone.parent is not None: - rotation = pose_bone.matrix.to_quaternion() + if bone.parent is not None: + rotation = bone.matrix.to_quaternion() rotation.x = -rotation.x rotation.y = -rotation.y rotation.z = -rotation.z - quat_parent = pose_bone.parent.matrix.to_quaternion().inverted() - parent_head = quat_parent @ pose_bone.parent.head - parent_tail = quat_parent @ pose_bone.parent.tail - location = (parent_tail - parent_head) + pose_bone.head + quat_parent = bone.parent.matrix.to_quaternion().inverted() + parent_head = quat_parent @ bone.parent.head + parent_tail = quat_parent @ bone.parent.tail + location = (parent_tail - parent_head) + bone.head else: - location = armature.matrix_local @ pose_bone.head - rot_matrix = pose_bone.matrix @ armature.matrix_local.to_3x3() + location = armature.matrix_local @ bone.head + rot_matrix = bone.matrix @ armature.matrix_local.to_3x3() rotation = rot_matrix.to_quaternion() psa_bone.location.x = location.x @@ -195,8 +185,8 @@ class PsaBuilder(object): psa.keys.append(key) - export_sequence.bone_count = len(pose_bones) - export_sequence.track_time = frame_count + psa_sequence.bone_count = len(pose_bones) + psa_sequence.track_time = frame_count # Restore the mute state of the NLA strips we muted beforehand. for nla_strip, mute in nla_strip_mute_statuses.items(): diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 770df29..e03bb4a 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -129,8 +129,8 @@ class PsaExportOperator(Operator, ExportHelper): layout.label(text='Actions', icon='ACTION') row = layout.row(align=True) row.label(text='Select') - row.operator(PsaExportSelectAll.bl_idname, text='All') - row.operator(PsaExportDeselectAll.bl_idname, text='None') + row.operator(PsaExportActionsSelectAll.bl_idname, text='All') + row.operator(PsaExportActionsDeselectAll.bl_idname, text='None') row = layout.row() rows = max(3, min(len(pg.action_list), 10)) row.template_list('PSA_UL_ExportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) @@ -172,6 +172,10 @@ class PsaExportOperator(Operator, ExportHelper): if pg.bone_filter_mode == 'BONE_GROUPS': rows = max(3, min(len(pg.bone_group_list), 10)) layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) + row = layout.row(align=True) + row.label(text='Select') + row.operator(PsaExportBoneGroupsSelectAll.bl_idname, text='All') + row.operator(PsaExportBoneGroupsDeselectAll.bl_idname, text='None') def is_action_for_armature(self, action): if len(action.fcurves) == 0: @@ -297,7 +301,7 @@ class PSA_UL_ExportActionList(UIList): return flt_flags, flt_neworder -class PsaExportSelectAll(Operator): +class PsaExportActionsSelectAll(Operator): bl_idname = 'psa_export.actions_select_all' bl_label = 'Select All' bl_description = 'Select all actions' @@ -307,17 +311,17 @@ class PsaExportSelectAll(Operator): def poll(cls, context): pg = context.scene.psa_export item_list = pg.action_list - has_unselected_actions = any(map(lambda action: not action.is_selected, item_list)) - return len(item_list) > 0 and has_unselected_actions + has_unselected_items = any(map(lambda item: not item.is_selected, item_list)) + return len(item_list) > 0 and has_unselected_items def execute(self, context): pg = context.scene.psa_export - for action in pg.action_list: - action.is_selected = True + for item in pg.action_list: + item.is_selected = True return {'FINISHED'} -class PsaExportDeselectAll(Operator): +class PsaExportActionsDeselectAll(Operator): bl_idname = 'psa_export.actions_deselect_all' bl_label = 'Deselect All' bl_description = 'Deselect all actions' @@ -327,12 +331,52 @@ class PsaExportDeselectAll(Operator): def poll(cls, context): pg = context.scene.psa_export item_list = pg.action_list + has_selected_items = any(map(lambda item: item.is_selected, item_list)) + return len(item_list) > 0 and has_selected_items + + def execute(self, context): + pg = context.scene.psa_export + for item in pg.action_list: + item.is_selected = False + return {'FINISHED'} + + +class PsaExportBoneGroupsSelectAll(Operator): + bl_idname = 'psa_export.bone_groups_select_all' + bl_label = 'Select All' + bl_description = 'Select all bone groups' + bl_options = {'INTERNAL'} + + @classmethod + def poll(cls, context): + pg = context.scene.psa_export + item_list = pg.bone_group_list + has_unselected_items = any(map(lambda action: not action.is_selected, item_list)) + return len(item_list) > 0 and has_unselected_items + + def execute(self, context): + pg = context.scene.psa_export + for item in pg.bone_group_list: + item.is_selected = True + return {'FINISHED'} + + +class PsaExportBoneGroupsDeselectAll(Operator): + bl_idname = 'psa_export.bone_groups_deselect_all' + bl_label = 'Deselect All' + bl_description = 'Deselect all bone groups' + bl_options = {'INTERNAL'} + + @classmethod + def poll(cls, context): + pg = context.scene.psa_export + item_list = pg.bone_group_list has_selected_actions = any(map(lambda action: action.is_selected, item_list)) return len(item_list) > 0 and has_selected_actions def execute(self, context): pg = context.scene.psa_export - for action in pg.action_list: + for action in pg.bone_group_list: action.is_selected = False return {'FINISHED'} @@ -344,6 +388,8 @@ classes = ( PsaExportOperator, PSA_UL_ExportActionList, PSA_UL_ExportTimelineMarkerList, - PsaExportSelectAll, - PsaExportDeselectAll, + PsaExportActionsSelectAll, + PsaExportActionsDeselectAll, + PsaExportBoneGroupsSelectAll, + PsaExportBoneGroupsDeselectAll, ) diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 5b8362c..de222b1 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -78,20 +78,8 @@ class PskBuilder(object): psk_bone.rotation = Quaternion(0, 0, 0, 1) psk.bones.append(psk_bone) else: - bones = list(armature_object.data.bones) - - # If we are filtering by bone groups, get only the bones that are in the specified bone groups and their - # ancestors. - if options.bone_filter_mode == 'BONE_GROUPS': - bone_indices = get_export_bone_indices_for_bone_groups(armature_object, options.bone_group_indices) - bones = [bones[bone_index] for bone_index in bone_indices] - - # Ensure that the exported hierarchy has a single root bone. - root_bones = [x for x in bones if x.parent is None] - if len(root_bones) > 1: - root_bone_names = [x.name for x in root_bones] - raise RuntimeError('Exported bone hierarchy must have a single root bone.' - f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}') + bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices) + bones = [armature_object.data.bones[bone_name] for bone_name in bone_names] for bone in bones: psk_bone = Psk.Bone() diff --git a/io_scene_psk_psa/psk/exporter.py b/io_scene_psk_psa/psk/exporter.py index 893559d..cc65e6b 100644 --- a/io_scene_psk_psa/psk/exporter.py +++ b/io_scene_psk_psa/psk/exporter.py @@ -125,6 +125,7 @@ class PskExportOperator(Operator, ExportHelper): pg = context.scene.psk_export builder = PskBuilder() options = PskBuilderOptions() + options.bone_filter_mode = pg.bone_filter_mode options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected] try: psk = builder.build(context, options) From 15b27ac4d62cc6f87a07f4773af7d888670aff07 Mon Sep 17 00:00:00 2001 From: Yurii Ti Date: Sat, 12 Feb 2022 15:21:47 +0200 Subject: [PATCH 06/22] Fixed the bug where sequences weren't trimmed correctly --- io_scene_psk_psa/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index e2af18b..47a2e02 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -27,7 +27,7 @@ def get_nla_strips_in_timeframe(object, frame_min, frame_max) -> List[NlaStrip]: strips = [] for nla_track in object.animation_data.nla_tracks: for strip in nla_track.strips: - if strip.frame_end >= frame_min and strip.frame_start <= frame_max: + if strip.frame_start >= frame_min and strip.frame_end <= frame_max: strips.append(strip) return strips From bcf5117baef00f567fe848dd9770e87d7287c99c Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 12 Feb 2022 19:58:34 -0800 Subject: [PATCH 07/22] Fixed a bug in `get_nla_strips_in_timeframe` --- io_scene_psk_psa/helpers.py | 4 +++- io_scene_psk_psa/psa/builder.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index 47a2e02..ab8bfa1 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -27,7 +27,9 @@ def get_nla_strips_in_timeframe(object, frame_min, frame_max) -> List[NlaStrip]: strips = [] for nla_track in object.animation_data.nla_tracks: for strip in nla_track.strips: - if strip.frame_start >= frame_min and strip.frame_end <= frame_max: + if (strip.frame_start < frame_min and strip.frame_end > frame_max) or \ + (frame_min <= strip.frame_start < frame_max) or \ + (frame_min < strip.frame_end <= frame_max): strips.append(strip) return strips diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 20a5f6f..4cefce5 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -119,6 +119,7 @@ class PsaBuilder(object): pass elif options.sequence_source == 'TIMELINE_MARKERS': sequence_frame_ranges = self.get_timeline_marker_sequence_frame_ranges(armature, context, options) + for name, (frame_min, frame_max) in sequence_frame_ranges.items(): export_sequence = ExportSequence() export_sequence.action = None @@ -142,9 +143,10 @@ class PsaBuilder(object): frame_min = export_sequence.frame_min frame_max = export_sequence.frame_max + frame_count = frame_max - frame_min + 1 psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252') - psa_sequence.frame_count = frame_max - frame_min + 1 + psa_sequence.frame_count = frame_count psa_sequence.frame_start_index = frame_start_index psa_sequence.fps = context.scene.render.fps From e52aa8975ac81cd1f35c45e637180aa65848723c Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 12 Feb 2022 19:59:31 -0800 Subject: [PATCH 08/22] Removed pointless NLA track muting functionality --- io_scene_psk_psa/psa/builder.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 4cefce5..1578796 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -103,7 +103,6 @@ class PsaBuilder(object): self.frame_min = 0 self.frame_max = 0 self.action = None - self.nla_strips_to_be_muted = [] export_sequences = [] @@ -126,7 +125,6 @@ class PsaBuilder(object): export_sequence.name = name export_sequence.frame_min = frame_min export_sequence.frame_max = frame_max - export_sequence.nla_strips_to_be_muted = get_nla_strips_ending_at_frame(armature, frame_min) export_sequences.append(export_sequence) else: raise ValueError(f'Unhandled sequence source: {options.sequence_source}') @@ -152,11 +150,6 @@ class PsaBuilder(object): frame_count = frame_max - frame_min + 1 - # Store the mute state of the NLA strips we need to mute so we can restore the state after we are done. - nla_strip_mute_statuses = {x: x.mute for x in export_sequence.nla_strips_to_be_muted} - for nla_strip in export_sequence.nla_strips_to_be_muted: - nla_strip.mute = True - for frame in range(frame_count): context.scene.frame_set(frame_min + frame) @@ -190,10 +183,6 @@ class PsaBuilder(object): psa_sequence.bone_count = len(pose_bones) psa_sequence.track_time = frame_count - # Restore the mute state of the NLA strips we muted beforehand. - for nla_strip, mute in nla_strip_mute_statuses.items(): - nla_strip.mute = mute - frame_start_index += frame_count psa.sequences[export_sequence.name] = psa_sequence From 04503ed2825d5a7ce3de7dfe639cf706cb8ea761 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sun, 13 Feb 2022 16:08:17 -0800 Subject: [PATCH 09/22] Added the ability to prefix and suffix sequence names on PSA export --- io_scene_psk_psa/psa/builder.py | 8 ++- io_scene_psk_psa/psa/exporter.py | 108 ++++++++++++++++++------------- io_scene_psk_psa/psa/importer.py | 2 +- 3 files changed, 71 insertions(+), 47 deletions(-) diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 1578796..d093206 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -12,6 +12,8 @@ class PsaBuilderOptions(object): self.bone_group_indices = [] self.should_use_original_sequence_names = False self.should_trim_timeline_marker_sequences = True + self.sequence_name_prefix = '' + self.sequence_name_suffix = '' class PsaBuilder(object): @@ -129,10 +131,14 @@ class PsaBuilder(object): else: raise ValueError(f'Unhandled sequence source: {options.sequence_source}') - frame_start_index = 0 + # Add prefixes and suffices to the names of the export sequences and strip whitespace. + for export_sequence in export_sequences: + export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}'.strip() # Now build the PSA sequences. # We actually alter the timeline frame and simply record the resultant pose bone matrices. + frame_start_index = 0 + for export_sequence in export_sequences: armature.animation_data.action = export_sequence.action context.view_layer.update() diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index e03bb4a..9686a6d 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -91,6 +91,9 @@ class PsaExportPropertyGroup(PropertyGroup): bone_group_list: CollectionProperty(type=BoneGroupListItem) bone_group_list_index: IntProperty(default=0, name='', description='') should_use_original_sequence_names: BoolProperty(default=False, name='Original Names', description='If the action was imported from the PSA Import panel, the original name of the sequence will be used instead of the Blender action name', update=should_use_original_sequence_names_updated) + should_trim_timeline_marker_sequences: BoolProperty(default=True, name='Trim Sequences', description='Frames without NLA track information at the boundaries of timeline markers will be excluded from the exported sequences') + sequence_name_prefix: StringProperty(name='Prefix') + sequence_name_suffix: StringProperty(name='Suffix') def is_bone_filter_mode_item_available(context, identifier): @@ -122,29 +125,36 @@ class PsaExportOperator(Operator, ExportHelper): pg = context.scene.psa_export # SOURCE - layout.prop(pg, 'sequence_source', text='Source') + layout.prop(pg, 'sequence_source', text='Source', icon='ACTION' if pg.sequence_source == 'ACTIONS' else 'MARKER') + + # SELECT ALL/NONE + row = layout.row(align=True) + row.label(text='Select') + row.operator(PsaExportActionsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT') + row.operator(PsaExportActionsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT') # ACTIONS if pg.sequence_source == 'ACTIONS': - layout.label(text='Actions', icon='ACTION') - row = layout.row(align=True) - row.label(text='Select') - row.operator(PsaExportActionsSelectAll.bl_idname, text='All') - row.operator(PsaExportActionsDeselectAll.bl_idname, text='None') - row = layout.row() rows = max(3, min(len(pg.action_list), 10)) - row.template_list('PSA_UL_ExportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) + layout.template_list('PSA_UL_ExportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) - col = layout.column(heading="Options") + col = layout.column() col.use_property_split = True col.use_property_decorate = False col.prop(pg, 'should_use_original_sequence_names') - elif pg.sequence_source == 'TIMELINE_MARKERS': - layout.label(text='Markers', icon='MARKER') + col.prop(pg, 'sequence_name_prefix') + col.prop(pg, 'sequence_name_suffix') - row = layout.row() + elif pg.sequence_source == 'TIMELINE_MARKERS': rows = max(3, min(len(pg.marker_list), 10)) - row.template_list('PSA_UL_ExportTimelineMarkerList', '', pg, 'marker_list', pg, 'marker_list_index', rows=rows) + layout.template_list('PSA_UL_ExportTimelineMarkerList', '', pg, 'marker_list', pg, 'marker_list_index', rows=rows) + + col = layout.column() + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'should_trim_timeline_marker_sequences') + col.prop(pg, 'sequence_name_prefix') + col.prop(pg, 'sequence_name_suffix') # Determine if there is going to be a naming conflict and display an error, if so. selected_items = [x for x in pg.action_list if x.is_selected] @@ -158,24 +168,16 @@ class PsaExportOperator(Operator, ExportHelper): layout.separator() # BONES - box = layout.row() - box.label(text='Bones', icon='BONE_DATA') - bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static - row = box.row(align=True) - - for item in bone_filter_mode_items: - identifier = item.identifier - item_layout = row.row(align=True) - item_layout.prop_enum(pg, 'bone_filter_mode', item.identifier) - item_layout.enabled = is_bone_filter_mode_item_available(context, identifier) + row = layout.row(align=True) + row.prop(pg, 'bone_filter_mode', text='Bones') if pg.bone_filter_mode == 'BONE_GROUPS': - rows = max(3, min(len(pg.bone_group_list), 10)) - layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) row = layout.row(align=True) row.label(text='Select') - row.operator(PsaExportBoneGroupsSelectAll.bl_idname, text='All') - row.operator(PsaExportBoneGroupsDeselectAll.bl_idname, text='None') + row.operator(PsaExportBoneGroupsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT') + row.operator(PsaExportBoneGroupsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT') + rows = max(3, min(len(pg.bone_group_list), 10)) + layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) def is_action_for_armature(self, action): if len(action.fcurves) == 0: @@ -246,6 +248,10 @@ class PsaExportOperator(Operator, ExportHelper): options.bone_filter_mode = pg.bone_filter_mode options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected] options.should_use_original_sequence_names = pg.should_use_original_sequence_names + options.should_trim_timeline_marker_sequences = pg.should_trim_timeline_marker_sequences + options.sequence_name_prefix = pg.sequence_name_prefix + options.sequence_name_suffix = pg.sequence_name_suffix + builder = PsaBuilder() try: @@ -261,9 +267,7 @@ class PsaExportOperator(Operator, ExportHelper): class PSA_UL_ExportTimelineMarkerList(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - layout.alignment = 'LEFT' - layout.prop(item, 'is_selected', icon_only=True) - layout.label(text=item.marker_name) + layout.prop(item, 'is_selected', icon_only=True, text=item.marker_name) def filter_items(self, context, data, property): actions = getattr(data, property) @@ -282,9 +286,7 @@ class PSA_UL_ExportTimelineMarkerList(UIList): class PSA_UL_ExportActionList(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - layout.alignment = 'LEFT' - layout.prop(item, 'is_selected', icon_only=True) - layout.label(text=item.action_name) + layout.prop(item, 'is_selected', icon_only=True, text=item.action_name) def filter_items(self, context, data, property): actions = getattr(data, property) @@ -302,41 +304,57 @@ class PSA_UL_ExportActionList(UIList): class PsaExportActionsSelectAll(Operator): - bl_idname = 'psa_export.actions_select_all' + bl_idname = 'psa_export.sequences_select_all' bl_label = 'Select All' - bl_description = 'Select all actions' + bl_description = 'Select all sequences' bl_options = {'INTERNAL'} @classmethod - def poll(cls, context): + def get_item_list(cls, context): pg = context.scene.psa_export - item_list = pg.action_list + if pg.sequence_source == 'ACTIONS': + return pg.action_list + elif pg.sequence_source == 'TIMELINE_MARKERS': + return pg.marker_list + return None + + @classmethod + def poll(cls, context): + item_list = cls.get_item_list(context) has_unselected_items = any(map(lambda item: not item.is_selected, item_list)) return len(item_list) > 0 and has_unselected_items def execute(self, context): - pg = context.scene.psa_export - for item in pg.action_list: + item_list = self.get_item_list(context) + for item in item_list: item.is_selected = True return {'FINISHED'} class PsaExportActionsDeselectAll(Operator): - bl_idname = 'psa_export.actions_deselect_all' + bl_idname = 'psa_export.sequences_deselect_all' bl_label = 'Deselect All' - bl_description = 'Deselect all actions' + bl_description = 'Deselect all sequences' bl_options = {'INTERNAL'} @classmethod - def poll(cls, context): + def get_item_list(cls, context): pg = context.scene.psa_export - item_list = pg.action_list + if pg.sequence_source == 'ACTIONS': + return pg.action_list + elif pg.sequence_source == 'TIMELINE_MARKERS': + return pg.marker_list + return None + + @classmethod + def poll(cls, context): + item_list = cls.get_item_list(context) has_selected_items = any(map(lambda item: item.is_selected, item_list)) return len(item_list) > 0 and has_selected_items def execute(self, context): - pg = context.scene.psa_export - for item in pg.action_list: + item_list = self.get_item_list(context) + for item in item_list: item.is_selected = False return {'FINISHED'} diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 871a22a..3a4093d 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -153,7 +153,7 @@ class PsaImporter(object): sequence_data_matrix[frame_index, bone_index] = calculate_fcurve_data(import_bone, key_data) # Clean the keyframe data. This is accomplished by writing zeroes to the write matrix when there is an - # insufficiently large change in the data from frame-to-frame. + # insufficiently large change in the data from the last written frame. if options.should_clean_keys: threshold = 0.001 for bone_index, import_bone in enumerate(import_bones): From eb7a497010ce44c1ac0b9c907b103e0eed75aa2f Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 5 Mar 2022 13:43:24 -0800 Subject: [PATCH 10/22] Fixed the options of many properties to not be `ANIMATABLE` --- io_scene_psk_psa/psa/exporter.py | 26 +++++++++++++++++++++----- io_scene_psk_psa/psa/importer.py | 18 +++++++++--------- io_scene_psk_psa/psk/exporter.py | 1 + io_scene_psk_psa/psk/importer.py | 22 +++++++++++++++++++--- 4 files changed, 50 insertions(+), 17 deletions(-) diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 9686a6d..18123f7 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -70,6 +70,7 @@ def should_use_original_sequence_names_updated(property, context): class PsaExportPropertyGroup(PropertyGroup): sequence_source: EnumProperty( name='Source', + options=set(), description='', items=( ('ACTIONS', 'Actions', 'Sequences will be exported using actions'), @@ -82,18 +83,33 @@ class PsaExportPropertyGroup(PropertyGroup): marker_list_index: IntProperty(default=0) bone_filter_mode: EnumProperty( name='Bone Filter', + options=set(), description='', items=( ('ALL', 'All', 'All bones will be exported.'), - ('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will be exported.'), + ('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will ' + 'be exported.'), ) ) bone_group_list: CollectionProperty(type=BoneGroupListItem) bone_group_list_index: IntProperty(default=0, name='', description='') - should_use_original_sequence_names: BoolProperty(default=False, name='Original Names', description='If the action was imported from the PSA Import panel, the original name of the sequence will be used instead of the Blender action name', update=should_use_original_sequence_names_updated) - should_trim_timeline_marker_sequences: BoolProperty(default=True, name='Trim Sequences', description='Frames without NLA track information at the boundaries of timeline markers will be excluded from the exported sequences') - sequence_name_prefix: StringProperty(name='Prefix') - sequence_name_suffix: StringProperty(name='Suffix') + should_use_original_sequence_names: BoolProperty( + default=False, + name='Original Names', + options=set(), + update=should_use_original_sequence_names_updated, + description='If the action was imported from the PSA Import panel, the original name of the sequence will be ' + 'used instead of the Blender action name', + ) + should_trim_timeline_marker_sequences: BoolProperty( + default=True, + name='Trim Sequences', + options=set(), + description='Frames without NLA track information at the boundaries of timeline markers will be excluded from ' + 'the exported sequences ' + ) + sequence_name_prefix: StringProperty(name='Prefix', options=set()) + sequence_name_suffix: StringProperty(name='Suffix', options=set()) def is_bone_filter_mode_item_available(context, identifier): diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 3a4093d..e6b82de 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -200,8 +200,8 @@ class PsaImporter(object): class PsaImportActionListItem(PropertyGroup): - action_name: StringProperty() - is_selected: BoolProperty(default=False) + action_name: StringProperty(options=set()) + is_selected: BoolProperty(default=False, options=set()) def load_psa_file(context): @@ -229,7 +229,7 @@ def on_psa_file_path_updated(property, context): class PsaBonePropertyGroup(PropertyGroup): - bone_name: StringProperty() + bone_name: StringProperty(options=set()) class PsaDataPropertyGroup(PropertyGroup): @@ -238,18 +238,18 @@ class PsaDataPropertyGroup(PropertyGroup): class PsaImportPropertyGroup(PropertyGroup): - psa_file_path: StringProperty(default='', update=on_psa_file_path_updated, name='PSA File Path') + psa_file_path: StringProperty(default='', options=set(), update=on_psa_file_path_updated, name='PSA File Path') psa_error: StringProperty(default='') psa: PointerProperty(type=PsaDataPropertyGroup) sequence_list: CollectionProperty(type=PsaImportActionListItem) sequence_list_index: IntProperty(name='', default=0) action_list: CollectionProperty(type=PsaImportActionListItem) action_list_index: IntProperty(name='', default=0) - should_clean_keys: BoolProperty(default=True, name='Clean Keyframes', description='Exclude unnecessary keyframes from being written to the actions.') - should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is saved even it has no users.') - should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track') - should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name') - action_name_prefix: StringProperty(default='', name='Prefix') + should_clean_keys: BoolProperty(default=True, name='Clean Keyframes', description='Exclude unnecessary keyframes from being written to the actions.', options=set()) + should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is saved even it has no users.', options=set()) + should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track', options=set()) + should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=set()) + action_name_prefix: StringProperty(default='', name='Prefix', options=set()) class PSA_UL_SequenceList(UIList): diff --git a/io_scene_psk_psa/psk/exporter.py b/io_scene_psk_psa/psk/exporter.py index cc65e6b..9d047b7 100644 --- a/io_scene_psk_psa/psk/exporter.py +++ b/io_scene_psk_psa/psk/exporter.py @@ -140,6 +140,7 @@ class PskExportOperator(Operator, ExportHelper): class PskExportPropertyGroup(PropertyGroup): bone_filter_mode: EnumProperty( name='Bone Filter', + options=set(), description='', items=( ('ALL', 'All', 'All bones will be exported.'), diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index cd0f90c..8de4e81 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -228,9 +228,15 @@ class PskImporter(object): class PskImportPropertyGroup(PropertyGroup): - should_import_vertex_colors: BoolProperty(default=True, name='Vertex Colors', description='Import vertex colors from PSKX files, if available') + should_import_vertex_colors: BoolProperty( + default=True, + options=set(), + name='Vertex Colors', + description='Import vertex colors from PSKX files, if available' + ) vertex_color_space: EnumProperty( name='Vertex Color Space', + options=set(), description='The source vertex color space', default='SRGBA', items=( @@ -238,8 +244,18 @@ class PskImportPropertyGroup(PropertyGroup): ('SRGBA', 'sRGBA', ''), ) ) - should_import_vertex_normals: BoolProperty(default=True, name='Vertex Normals', description='Import vertex normals from PSKX files, if available') - should_import_extra_uvs: BoolProperty(default=True, name='Extra UVs', description='Import extra UV maps from PSKX files, if available') + should_import_vertex_normals: BoolProperty( + default=True, + name='Vertex Normals', + options=set(), + description='Import vertex normals from PSKX files, if available' + ) + should_import_extra_uvs: BoolProperty( + default=True, + name='Extra UVs', + options=set(), + description='Import extra UV maps from PSKX files, if available' + ) class PskImportOperator(Operator, ImportHelper): From a5955bf09bcbc2270c2b1ccb7c44379ef3bf2167 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 4 Apr 2022 00:32:12 -0700 Subject: [PATCH 11/22] Lots of interface tweaks --- io_scene_psk_psa/data.py | 8 ++ io_scene_psk_psa/helpers.py | 19 ++++ io_scene_psk_psa/psa/builder.py | 96 ++++++++++++-------- io_scene_psk_psa/psa/exporter.py | 130 +++++++++++++++++---------- io_scene_psk_psa/psa/importer.py | 147 ++++++------------------------- io_scene_psk_psa/psk/builder.py | 6 +- 6 files changed, 200 insertions(+), 206 deletions(-) diff --git a/io_scene_psk_psa/data.py b/io_scene_psk_psa/data.py index 1639a01..0d3db93 100644 --- a/io_scene_psk_psa/data.py +++ b/io_scene_psk_psa/data.py @@ -55,6 +55,10 @@ class Vector3(Structure): def __repr__(self): return repr(tuple(self)) + @classmethod + def zero(cls): + return Vector3(0, 0, 0) + class Quaternion(Structure): _fields_ = [ @@ -73,6 +77,10 @@ class Quaternion(Structure): def __repr__(self): return repr(tuple(self)) + @classmethod + def identity(cls): + return Quaternion(0, 0, 0, 1) + class Section(Structure): _fields_ = [ diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index ab8bfa1..df60781 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -1,6 +1,25 @@ from bpy.types import NlaStrip from typing import List, Tuple, Optional from collections import Counter +import datetime + + +class Timer: + def __enter__(self): + self.start = datetime.datetime.now() + self.interval = None + return self + + def __exit__(self, *args): + self.end = datetime.datetime.now() + self.interval = self.end - self.start + + @property + def duration(self): + if self.interval is not None: + return self.interval + else: + return datetime.datetime.now() - self.start def rgb_to_srgb(c): diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index d093206..983bdf8 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -16,17 +16,25 @@ class PsaBuilderOptions(object): self.sequence_name_suffix = '' +class PsaBuilderPerformance: + def __init__(self): + self.frame_set_duration = datetime.timedelta() + self.key_build_duration = datetime.timedelta() + self.key_add_duration = datetime.timedelta() + + class PsaBuilder(object): def __init__(self): pass def build(self, context, options: PsaBuilderOptions) -> Psa: - object = context.view_layer.objects.active + performance = PsaBuilderPerformance() + active_object = context.view_layer.objects.active - if object.type != 'ARMATURE': + if active_object.type != 'ARMATURE': raise RuntimeError('Selected object must be an Armature') - armature = object + armature = active_object if armature.animation_data is None: raise RuntimeError('No animation data for armature') @@ -99,13 +107,17 @@ class PsaBuilder(object): psa.bones.append(psa_bone) # Populate the export sequence list. - class ExportSequence: + class NlaState: def __init__(self): - self.name = '' self.frame_min = 0 self.frame_max = 0 self.action = None + class ExportSequence: + def __init__(self): + self.name = '' + self.nla_state = NlaState() + export_sequences = [] if options.sequence_source == 'ACTIONS': @@ -113,9 +125,11 @@ class PsaBuilder(object): if len(action.fcurves) == 0: continue export_sequence = ExportSequence() - export_sequence.action = action + export_sequence.nla_state.action = action export_sequence.name = get_psa_sequence_name(action, options.should_use_original_sequence_names) - export_sequence.frame_min, export_sequence.frame_max = [int(x) for x in action.frame_range] + frame_min, frame_max = [int(x) for x in action.frame_range] + export_sequence.nla_state.frame_min = frame_min + export_sequence.nla_state.frame_max = frame_max export_sequences.append(export_sequence) pass elif options.sequence_source == 'TIMELINE_MARKERS': @@ -123,10 +137,10 @@ class PsaBuilder(object): for name, (frame_min, frame_max) in sequence_frame_ranges.items(): export_sequence = ExportSequence() - export_sequence.action = None export_sequence.name = name - export_sequence.frame_min = frame_min - export_sequence.frame_max = frame_max + export_sequence.nla_state.action = None + export_sequence.nla_state.frame_min = frame_min + export_sequence.nla_state.frame_max = frame_max export_sequences.append(export_sequence) else: raise ValueError(f'Unhandled sequence source: {options.sequence_source}') @@ -140,13 +154,13 @@ class PsaBuilder(object): frame_start_index = 0 for export_sequence in export_sequences: - armature.animation_data.action = export_sequence.action + armature.animation_data.action = export_sequence.nla_state.action context.view_layer.update() psa_sequence = Psa.Sequence() - frame_min = export_sequence.frame_min - frame_max = export_sequence.frame_max + frame_min = export_sequence.nla_state.frame_min + frame_max = export_sequence.nla_state.frame_max frame_count = frame_max - frame_min + 1 psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252') @@ -157,34 +171,40 @@ class PsaBuilder(object): frame_count = frame_max - frame_min + 1 for frame in range(frame_count): - context.scene.frame_set(frame_min + frame) + with Timer() as t: + context.scene.frame_set(frame_min + frame) + performance.frame_set_duration += t.duration for pose_bone in pose_bones: - key = Psa.Key() - pose_bone_matrix = pose_bone.matrix + with Timer() as t: + key = Psa.Key() + pose_bone_matrix = pose_bone.matrix - if pose_bone.parent is not None: - pose_bone_parent_matrix = pose_bone.parent.matrix - pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix + if pose_bone.parent is not None: + pose_bone_parent_matrix = pose_bone.parent.matrix + pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix - location = pose_bone_matrix.to_translation() - rotation = pose_bone_matrix.to_quaternion().normalized() + location = pose_bone_matrix.to_translation() + rotation = pose_bone_matrix.to_quaternion().normalized() - if pose_bone.parent is not None: - rotation.x = -rotation.x - rotation.y = -rotation.y - rotation.z = -rotation.z + if pose_bone.parent is not None: + rotation.x = -rotation.x + rotation.y = -rotation.y + rotation.z = -rotation.z - key.location.x = location.x - key.location.y = location.y - key.location.z = location.z - key.rotation.x = rotation.x - key.rotation.y = rotation.y - key.rotation.z = rotation.z - key.rotation.w = rotation.w - key.time = 1.0 / psa_sequence.fps + key.location.x = location.x + key.location.y = location.y + key.location.z = location.z + key.rotation.x = rotation.x + key.rotation.y = rotation.y + key.rotation.z = rotation.z + key.rotation.w = rotation.w + key.time = 1.0 / psa_sequence.fps + performance.key_build_duration += t.duration - psa.keys.append(key) + with Timer() as t: + psa.keys.append(key) + performance.key_add_duration += t.duration psa_sequence.bone_count = len(pose_bones) psa_sequence.track_time = frame_count @@ -193,6 +213,10 @@ class PsaBuilder(object): psa.sequences[export_sequence.name] = psa_sequence + print(f'frame set duration: {performance.frame_set_duration}') + print(f'key build duration: {performance.key_build_duration}') + print(f'key add duration: {performance.key_add_duration}') + return psa def get_timeline_marker_sequence_frame_ranges(self, object, context, options: PsaBuilderOptions) -> Dict: @@ -214,8 +238,8 @@ class PsaBuilder(object): frame_max = sorted_timeline_markers[next_marker_index].frame if options.should_trim_timeline_marker_sequences: nla_strips = get_nla_strips_in_timeframe(object, marker.frame, frame_max) - frame_max = min(frame_max, max(map(lambda x: x.frame_end, nla_strips))) - frame_min = max(frame_min, min(map(lambda x: x.frame_start, nla_strips))) + frame_max = min(frame_max, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips))) + frame_min = max(frame_min, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips))) else: # There is no next marker. # Find the final frame of all the NLA strips and use that as the last frame of this sequence. diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 18123f7..b53ed9d 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -38,23 +38,15 @@ class PsaExporter(object): class PsaExportActionListItem(PropertyGroup): action: PointerProperty(type=Action) - action_name: StringProperty() + name: StringProperty() is_selected: BoolProperty(default=False) - @property - def name(self): - return self.action.name - class PsaExportTimelineMarkerListItem(PropertyGroup): marker_index: IntProperty() - marker_name: StringProperty() + name: StringProperty() is_selected: BoolProperty(default=True) - @property - def name(self): - return self.marker_name - def update_action_names(context): pg = context.scene.psa_export @@ -110,6 +102,10 @@ class PsaExportPropertyGroup(PropertyGroup): ) sequence_name_prefix: StringProperty(name='Prefix', options=set()) sequence_name_suffix: StringProperty(name='Suffix', options=set()) + sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) + sequence_use_filter_invert: BoolProperty(default=False, options=set()) + sequence_filter_asset: BoolProperty(default=False, name='Show assets', description='Show actions that belong to an asset library', options=set()) + sequence_use_filter_sort_reverse: BoolProperty(default=True, options=set()) def is_bone_filter_mode_item_available(context, identifier): @@ -152,6 +148,7 @@ class PsaExportOperator(Operator, ExportHelper): # ACTIONS if pg.sequence_source == 'ACTIONS': rows = max(3, min(len(pg.action_list), 10)) + layout.template_list('PSA_UL_ExportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) col = layout.column() @@ -174,7 +171,7 @@ class PsaExportOperator(Operator, ExportHelper): # Determine if there is going to be a naming conflict and display an error, if so. selected_items = [x for x in pg.action_list if x.is_selected] - action_names = [x.action_name for x in selected_items] + action_names = [x.name for x in selected_items] action_name_counts = Counter(action_names) for action_name, count in action_name_counts.items(): if count > 1: @@ -195,6 +192,9 @@ class PsaExportOperator(Operator, ExportHelper): rows = max(3, min(len(pg.bone_group_list), 10)) layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) + def should_action_be_selected_by_default(self, action): + return action is not None and action.asset_data is None + def is_action_for_armature(self, action): if len(action.fcurves) == 0: return False @@ -228,8 +228,8 @@ class PsaExportOperator(Operator, ExportHelper): continue item = pg.action_list.add() item.action = action - item.action_name = action.name - item.is_selected = True + item.name = action.name + item.is_selected = self.should_action_be_selected_by_default(action) update_action_names(context) @@ -237,7 +237,7 @@ class PsaExportOperator(Operator, ExportHelper): pg.marker_list.clear() for marker in context.scene.timeline_markers: item = pg.marker_list.add() - item.marker_name = marker.name + item.name = marker.name if len(pg.action_list) == 0 and len(pg.marker_names) == 0: # If there are no actions at all, we have nothing to export, so just cancel the operation. @@ -255,7 +255,7 @@ class PsaExportOperator(Operator, ExportHelper): pg = context.scene.psa_export actions = [x.action for x in pg.action_list if x.is_selected] - marker_names = [x.marker_name for x in pg.marker_list if x.is_selected] + marker_names = [x.name for x in pg.marker_list if x.is_selected] options = PsaBuilderOptions() options.sequence_source = pg.sequence_source @@ -283,39 +283,75 @@ class PsaExportOperator(Operator, ExportHelper): class PSA_UL_ExportTimelineMarkerList(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - layout.prop(item, 'is_selected', icon_only=True, text=item.marker_name) + layout.prop(item, 'is_selected', icon_only=True, text=item.name) def filter_items(self, context, data, property): - actions = getattr(data, property) - flt_flags = [] - flt_neworder = [] - if self.filter_name: - flt_flags = bpy.types.UI_UL_list.filter_items_by_name( - self.filter_name, - self.bitflag_filter_item, - actions, - 'marker_name', - reverse=self.use_filter_invert - ) + pg = context.scene.psa_export + sequences = getattr(data, property) + flt_flags = filter_sequences(pg, sequences) + flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'name') return flt_flags, flt_neworder +def filter_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[int]: + bitflag_filter_item = 1 << 30 + flt_flags = [bitflag_filter_item] * len(sequences) + + if pg.sequence_filter_name is not None: + # Filter name is non-empty. + import fnmatch + for i, sequence in enumerate(sequences): + if not fnmatch.fnmatch(sequence.name, f'*{pg.sequence_filter_name}*'): + flt_flags[i] &= ~bitflag_filter_item + + if not pg.sequence_filter_asset: + for i, sequence in enumerate(sequences): + if hasattr(sequence, 'action') and sequence.action.asset_data is not None: + flt_flags[i] &= ~bitflag_filter_item + + if pg.sequence_use_filter_invert: + # Invert filter flags for all items. + for i, sequence in enumerate(sequences): + flt_flags[i] ^= ~bitflag_filter_item + + return flt_flags + + +def get_visible_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[PsaExportActionListItem]: + visible_sequences = [] + for i, flag in enumerate(filter_sequences(pg, sequences)): + if bool(flag & (1 << 30)): + visible_sequences.append(sequences[i]) + return visible_sequences + + class PSA_UL_ExportActionList(UIList): + + def __init__(self): + super(PSA_UL_ExportActionList, self).__init__() + # Show the filtering options by default. + self.use_filter_show = True + def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - layout.prop(item, 'is_selected', icon_only=True, text=item.action_name) + layout.prop(item, 'is_selected', icon_only=True, text=item.name) + if item.action.asset_data is not None: + layout.label(text='', icon='ASSET_MANAGER') + + def draw_filter(self, context, layout): + pg = context.scene.psa_export + row = layout.row() + subrow = row.row(align=True) + subrow.prop(pg, 'sequence_filter_name', text="") + subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT') + subrow = row.row(align=True) + subrow.prop(pg, 'sequence_filter_asset', icon_only=True, icon='ASSET_MANAGER') + # subrow.prop(pg, 'sequence_use_filter_sort_reverse', text='', icon='SORT_ASC') def filter_items(self, context, data, property): + pg = context.scene.psa_export actions = getattr(data, property) - flt_flags = [] - flt_neworder = [] - if self.filter_name: - flt_flags = bpy.types.UI_UL_list.filter_items_by_name( - self.filter_name, - self.bitflag_filter_item, - actions, - 'action_name', - reverse=self.use_filter_invert - ) + flt_flags = filter_sequences(pg, actions) + flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name') return flt_flags, flt_neworder @@ -336,14 +372,17 @@ class PsaExportActionsSelectAll(Operator): @classmethod def poll(cls, context): + pg = context.scene.psa_export item_list = cls.get_item_list(context) - has_unselected_items = any(map(lambda item: not item.is_selected, item_list)) - return len(item_list) > 0 and has_unselected_items + visible_sequences = get_visible_sequences(pg, item_list) + has_unselected_sequences = any(map(lambda item: not item.is_selected, visible_sequences)) + return has_unselected_sequences def execute(self, context): - item_list = self.get_item_list(context) - for item in item_list: - item.is_selected = True + pg = context.scene.psa_export + sequences = self.get_item_list(context) + for sequence in get_visible_sequences(pg, sequences): + sequence.is_selected = True return {'FINISHED'} @@ -369,9 +408,10 @@ class PsaExportActionsDeselectAll(Operator): return len(item_list) > 0 and has_selected_items def execute(self, context): + pg = context.scene.psa_export item_list = self.get_item_list(context) - for item in item_list: - item.is_selected = False + for sequence in get_visible_sequences(pg, item_list): + sequence.is_selected = False return {'FINISHED'} diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index e6b82de..a9af2cf 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -208,7 +208,6 @@ def load_psa_file(context): pg = context.scene.psa_import pg.sequence_list.clear() pg.psa.bones.clear() - pg.action_list.clear() pg.psa_error = '' try: # Read the file and populate the action list. @@ -243,13 +242,13 @@ class PsaImportPropertyGroup(PropertyGroup): psa: PointerProperty(type=PsaDataPropertyGroup) sequence_list: CollectionProperty(type=PsaImportActionListItem) sequence_list_index: IntProperty(name='', default=0) - action_list: CollectionProperty(type=PsaImportActionListItem) - action_list_index: IntProperty(name='', default=0) should_clean_keys: BoolProperty(default=True, name='Clean Keyframes', description='Exclude unnecessary keyframes from being written to the actions.', options=set()) should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is saved even it has no users.', options=set()) should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track', options=set()) should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=set()) action_name_prefix: StringProperty(default='', name='Prefix', options=set()) + sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) + sequence_use_filter_invert: BoolProperty(default=False, options=set()) class PSA_UL_SequenceList(UIList): @@ -263,25 +262,26 @@ class PSA_UL_SequenceList(UIList): column.label(text=item.action_name) def draw_filter(self, context, layout): + pg = context.scene.psa_import row = layout.row() subrow = row.row(align=True) - subrow.prop(self, 'filter_name', text="") - subrow.prop(self, 'use_filter_invert', text="", icon='ARROW_LEFTRIGHT') - subrow = row.row(align=True) - subrow.prop(self, 'use_filter_sort_reverse', text='', icon='SORT_ASC') + # TODO: current used for both, not good! + subrow.prop(pg, 'sequence_filter_name', text="") + subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT') def filter_items(self, context, data, property): - actions = getattr(data, property) + pg = context.scene.psa_import + sequences = getattr(data, property) flt_flags = [] - if self.filter_name: + if pg.sequence_filter_name: flt_flags = bpy.types.UI_UL_list.filter_items_by_name( - self.filter_name, + pg.sequence_filter_name, self.bitflag_filter_item, - actions, + sequences, 'action_name', - reverse=self.use_filter_invert + reverse=pg.sequence_use_filter_invert ) - flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'action_name') + flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'action_name') return flt_flags, flt_neworder @@ -313,26 +313,6 @@ class PsaImportSequencesSelectAll(Operator): return {'FINISHED'} -class PsaImportActionsSelectAll(Operator): - bl_idname = 'psa_import.actions_select_all' - bl_label = 'All' - bl_description = 'Select all actions' - bl_options = {'INTERNAL'} - - @classmethod - def poll(cls, context): - pg = context.scene.psa_import - action_list = pg.action_list - has_unselected_actions = any(map(lambda action: not action.is_selected, action_list)) - return len(action_list) > 0 and has_unselected_actions - - def execute(self, context): - pg = context.scene.psa_import - for action in pg.action_list: - action.is_selected = True - return {'FINISHED'} - - class PsaImportSequencesDeselectAll(Operator): bl_idname = 'psa_import.sequences_deselect_all' bl_label = 'None' @@ -353,26 +333,6 @@ class PsaImportSequencesDeselectAll(Operator): return {'FINISHED'} -class PsaImportActionsDeselectAll(Operator): - bl_idname = 'psa_import.actions_deselect_all' - bl_label = 'None' - bl_description = 'Deselect all actions' - bl_options = {'INTERNAL'} - - @classmethod - def poll(cls, context): - pg = context.scene.psa_import - action_list = pg.action_list - has_selected_actions = any(map(lambda action: action.is_selected, action_list)) - return len(action_list) > 0 and has_selected_actions - - def execute(self, context): - pg = context.scene.psa_import - for action in pg.action_list: - action.is_selected = False - return {'FINISHED'} - - class PSA_PT_ImportPanel_Advanced(Panel): bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' @@ -443,31 +403,21 @@ class PSA_PT_ImportPanel(Panel): box.label(text=f'Sequences', icon='ARMATURE_DATA') # select - rows = max(3, min(len(pg.sequence_list) + len(pg.action_list), 10)) + rows = max(3, max(len(pg.sequence_list), 10)) row = box.row() col = row.column() row2 = col.row(align=True) row2.label(text='Select') - row2.operator(PsaImportSequencesSelectAll.bl_idname, text='All') - row2.operator(PsaImportSequencesDeselectAll.bl_idname, text='None') + row2.operator(PsaImportSequencesSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT') + row2.operator(PsaImportSequencesDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT') col = col.row() col.template_list('PSA_UL_ImportSequenceList', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows) - col = row.column(align=True) - col.operator(PsaImportPushToActions.bl_idname, icon='TRIA_RIGHT', text='') - col.operator(PsaImportPopFromActions.bl_idname, icon='TRIA_LEFT', text='') - - col = row.column() - row2 = col.row(align=True) - row2.label(text='Select') - row2.operator(PsaImportActionsSelectAll.bl_idname, text='All') - row2.operator(PsaImportActionsDeselectAll.bl_idname, text='None') - col.template_list('PSA_UL_ImportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) - col.separator() - col.operator(PsaImportOperator.bl_idname, text=f'Import') + row = box.row() + row.operator(PsaImportOperator.bl_idname, text=f'Import') class PsaImportFileReload(Operator): @@ -508,69 +458,26 @@ class PsaImportOperator(Operator): def poll(cls, context): pg = context.scene.psa_import active_object = context.view_layer.objects.active - action_list = pg.action_list - return len(action_list) and active_object is not None and active_object.type == 'ARMATURE' + if active_object is None or active_object.type != 'ARMATURE': + return False + return any(map(lambda x: x.is_selected, pg.sequence_list)) def execute(self, context): pg = context.scene.psa_import psa_reader = PsaReader(pg.psa_file_path) - sequence_names = [x.action_name for x in pg.action_list] + sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected] + options = PsaImportOptions() options.sequence_names = sequence_names options.should_clean_keys = pg.should_clean_keys options.should_use_fake_user = pg.should_use_fake_user options.should_stash = pg.should_stash options.action_name_prefix = pg.action_name_prefix + PsaImporter().import_psa(psa_reader, context.view_layer.objects.active, options) + self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)') - return {'FINISHED'} - -class PsaImportPushToActions(Operator): - bl_idname = 'psa_import.push_to_actions' - bl_label = 'Push to Actions' - bl_options = {'INTERNAL'} - - @classmethod - def poll(cls, context): - pg = context.scene.psa_import - has_sequences_selected = any(map(lambda x: x.is_selected, pg.sequence_list)) - return has_sequences_selected - - def execute(self, context): - pg = context.scene.psa_import - indices_to_remove = [] - for sequence_index, item in enumerate(pg.sequence_list): - if item.is_selected: - indices_to_remove.append(sequence_index) - action = pg.action_list.add() - action.action_name = item.action_name - for index in reversed(indices_to_remove): - pg.sequence_list.remove(index) - return {'FINISHED'} - - -class PsaImportPopFromActions(Operator): - bl_idname = 'psa_import.pop_from_actions' - bl_label = 'Pop From Actions' - bl_options = {'INTERNAL'} - - @classmethod - def poll(cls, context): - pg = context.scene.psa_import - has_actions_selected = any(map(lambda x: x.is_selected, pg.action_list)) - return has_actions_selected - - def execute(self, context): - pg = context.scene.psa_import - indices_to_remove = [] - for action_index, item in enumerate(pg.action_list): - if item.is_selected: - indices_to_remove.append(action_index) - sequence = pg.sequence_list.add() - sequence.action_name = item.action_name - for index in reversed(indices_to_remove): - pg.action_list.remove(index) return {'FINISHED'} @@ -606,8 +513,6 @@ classes = ( PSA_UL_ImportActionList, PsaImportSequencesSelectAll, PsaImportSequencesDeselectAll, - PsaImportActionsSelectAll, - PsaImportActionsDeselectAll, PsaImportFileReload, PSA_PT_ImportPanel, PSA_PT_ImportPanel_Advanced, @@ -615,6 +520,4 @@ classes = ( PsaImportOperator, PsaImportFileSelectOperator, PsaImportSelectFile, - PsaImportPushToActions, - PsaImportPopFromActions, ) diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index de222b1..f48e139 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -70,12 +70,12 @@ class PskBuilder(object): # If the mesh has no armature object, simply assign it a dummy bone at the root to satisfy the requirement # that a PSK file must have at least one bone. psk_bone = Psk.Bone() - psk_bone.name = bytes('static', encoding='windows-1252') + psk_bone.name = bytes('root', encoding='windows-1252') psk_bone.flags = 0 psk_bone.children_count = 0 psk_bone.parent_index = 0 - psk_bone.location = Vector3(0, 0, 0) - psk_bone.rotation = Quaternion(0, 0, 0, 1) + psk_bone.location = Vector3.zero() + psk_bone.rotation = Quaternion.identity() psk.bones.append(psk_bone) else: bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices) From e383ce9fe455d973103bbe4a2b65f973a574be47 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 12 Apr 2022 11:24:02 -0700 Subject: [PATCH 12/22] Fixed a bug with the default length of PSA import list --- io_scene_psk_psa/psa/importer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index a9af2cf..6ed3ac4 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -403,7 +403,7 @@ class PSA_PT_ImportPanel(Panel): box.label(text=f'Sequences', icon='ARMATURE_DATA') # select - rows = max(3, max(len(pg.sequence_list), 10)) + rows = max(3, min(len(pg.sequence_list), 10)) row = box.row() col = row.column() From 762e13ac38e33863a5c3740fc8392d2515f3e692 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 12 Apr 2022 11:25:18 -0700 Subject: [PATCH 13/22] Fixed an incorrect reference to the marker list --- io_scene_psk_psa/psa/exporter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index b53ed9d..9fd9736 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -239,7 +239,7 @@ class PsaExportOperator(Operator, ExportHelper): item = pg.marker_list.add() item.name = marker.name - if len(pg.action_list) == 0 and len(pg.marker_names) == 0: + if len(pg.action_list) == 0 and len(pg.marker_list) == 0: # If there are no actions at all, we have nothing to export, so just cancel the operation. self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions or timeline markers to export.') return {'CANCELLED'} From d81f8286a12fc96c946c4f3b8226170aca091813 Mon Sep 17 00:00:00 2001 From: Yurii Ti Date: Tue, 12 Apr 2022 23:19:49 +0300 Subject: [PATCH 14/22] Fixed a bug that would incorrectly include/exclude some bones on export --- io_scene_psk_psa/psa/builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 983bdf8..aff1e98 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -58,8 +58,8 @@ class PsaBuilder(object): pose_bones = [x[1] for x in pose_bones] # Get a list of all the bone indices and instigator bones for the bone filter settings. - bone_names = get_export_bone_names(armature, options.bone_filter_mode, options.bone_group_indices) - bone_indices = [bone_names.index(x) for x in bone_names] + export_bone_names = get_export_bone_names(armature, options.bone_filter_mode, options.bone_group_indices) + bone_indices = [bone_names.index(x) for x in export_bone_names] # Make the bone lists contain only the bones that are going to be exported. bones = [bones[bone_index] for bone_index in bone_indices] From 99da7e67c0fb96b667b1062de6d8ab9cf3d43e47 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Wed, 13 Apr 2022 22:52:55 -0700 Subject: [PATCH 15/22] Determining the last frame when using timeline markers no longer considers muted NLA tracks --- io_scene_psk_psa/psa/builder.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index aff1e98..c0a84de 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -244,6 +244,8 @@ class PsaBuilder(object): # There is no next marker. # Find the final frame of all the NLA strips and use that as the last frame of this sequence. for nla_track in object.animation_data.nla_tracks: + if nla_track.mute: + continue for strip in nla_track.strips: frame_max = max(frame_max, strip.frame_end) From 7af97d53bd3d8bd31e0dd084419c92d8e1e37501 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 14 Apr 2022 00:07:43 -0700 Subject: [PATCH 16/22] `get_nla_strips_in_timeframe` now ignored muted NLA tracks --- io_scene_psk_psa/helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index df60781..be62dea 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -45,6 +45,8 @@ def get_nla_strips_in_timeframe(object, frame_min, frame_max) -> List[NlaStrip]: return [] strips = [] for nla_track in object.animation_data.nla_tracks: + if nla_track.mute: + continue for strip in nla_track.strips: if (strip.frame_start < frame_min and strip.frame_end > frame_max) or \ (frame_min <= strip.frame_start < frame_max) or \ From 37f14a2a19e6efcf906e495cd8c3819b787e3eaa Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Fri, 15 Apr 2022 16:50:58 -0700 Subject: [PATCH 17/22] * Added the ability to import the sequence FPS as a custom property to the Action (`psa_fps`) * Added additional options for exporting sequence FPS values (scene, action metadata (custom data), custom,) * The user can now choose to reuse existing action data blocks when importing sequence data. * The user can choose whether or not to import keyframe data and metadata --- io_scene_psk_psa/helpers.py | 4 +- io_scene_psk_psa/psa/builder.py | 35 ++++- io_scene_psk_psa/psa/exporter.py | 27 +++- io_scene_psk_psa/psa/importer.py | 214 ++++++++++++++++++++----------- io_scene_psk_psa/psa/reader.py | 2 +- 5 files changed, 192 insertions(+), 90 deletions(-) diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index be62dea..f5864b2 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -76,8 +76,8 @@ def populate_bone_group_list(armature_object, bone_group_list): def get_psa_sequence_name(action, should_use_original_sequence_name): - if should_use_original_sequence_name and 'original_sequence_name' in action: - return action['original_sequence_name'] + if should_use_original_sequence_name and 'psa_sequence_name' in action: + return action['psa_sequence_name'] else: return action.name diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index c0a84de..db7dbcd 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -1,10 +1,13 @@ from .data import * from ..helpers import * -from typing import Dict +from typing import Dict, Iterable +from bpy.types import Action class PsaBuilderOptions(object): def __init__(self): + self.fps_source = 'SCENE' + self.fps_custom = 30.0 self.sequence_source = 'ACTIONS' self.actions = [] self.marker_names = [] @@ -117,9 +120,30 @@ class PsaBuilder(object): def __init__(self): self.name = '' self.nla_state = NlaState() + self.fps = 30.0 export_sequences = [] + def get_sequence_fps(context, options: PsaBuilderOptions, actions: Iterable[Action]) -> float: + if options.fps_source == 'SCENE': + return context.scene.render.fps + if options.fps_source == 'CUSTOM': + return options.fps_custom + elif options.fps_source == 'ACTION_METADATA': + # Get the minimum value of action metadata FPS values. + psa_fps_list = [] + for action in filter(lambda x: 'psa_fps' in x, actions): + psa_fps = action['psa_fps'] + if type(psa_fps) == int or type(psa_fps) == float: + psa_fps_list.append(psa_fps) + if len(psa_fps_list) > 0: + return min(psa_fps_list) + else: + # No valid action metadata to use, fallback to scene FPS + return context.scene.render.fps + else: + raise RuntimeError(f'Invalid FPS source "{options.fps_source}"') + if options.sequence_source == 'ACTIONS': for action in options.actions: if len(action.fcurves) == 0: @@ -130,6 +154,7 @@ class PsaBuilder(object): frame_min, frame_max = [int(x) for x in action.frame_range] export_sequence.nla_state.frame_min = frame_min export_sequence.nla_state.frame_max = frame_max + export_sequence.fps = get_sequence_fps(context, options, [action]) export_sequences.append(export_sequence) pass elif options.sequence_source == 'TIMELINE_MARKERS': @@ -141,6 +166,8 @@ class PsaBuilder(object): export_sequence.nla_state.action = None export_sequence.nla_state.frame_min = frame_min export_sequence.nla_state.frame_max = frame_max + nla_strips_actions = set(map(lambda x: x.action, get_nla_strips_in_timeframe(active_object, frame_min, frame_max))) + export_sequence.fps = get_sequence_fps(context, options, nla_strips_actions) export_sequences.append(export_sequence) else: raise ValueError(f'Unhandled sequence source: {options.sequence_source}') @@ -166,7 +193,7 @@ class PsaBuilder(object): psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252') psa_sequence.frame_count = frame_count psa_sequence.frame_start_index = frame_start_index - psa_sequence.fps = context.scene.render.fps + psa_sequence.fps = export_sequence.fps frame_count = frame_max - frame_min + 1 @@ -213,10 +240,6 @@ class PsaBuilder(object): psa.sequences[export_sequence.name] = psa_sequence - print(f'frame set duration: {performance.frame_set_duration}') - print(f'key build duration: {performance.key_build_duration}') - print(f'key add duration: {performance.key_add_duration}') - return psa def get_timeline_marker_sequence_frame_ranges(self, object, context, options: PsaBuilderOptions) -> Dict: diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 9fd9736..77fcfc7 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -1,6 +1,6 @@ import bpy from bpy.types import Operator, PropertyGroup, Action, UIList, BoneGroup, Panel, TimelineMarker -from bpy.props import CollectionProperty, IntProperty, PointerProperty, StringProperty, BoolProperty, EnumProperty +from bpy.props import CollectionProperty, IntProperty, FloatProperty, PointerProperty, StringProperty, BoolProperty, EnumProperty from bpy_extras.io_utils import ExportHelper from typing import Type from .builder import PsaBuilder, PsaBuilderOptions @@ -9,6 +9,7 @@ from ..types import BoneGroupListItem from ..helpers import * from collections import Counter import re +import sys class PsaExporter(object): @@ -65,10 +66,21 @@ class PsaExportPropertyGroup(PropertyGroup): options=set(), description='', items=( - ('ACTIONS', 'Actions', 'Sequences will be exported using actions'), - ('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers'), + ('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0), + ('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers', 'MARKER_HLT', 1), ) ) + fps_source: EnumProperty( + name='FPS Source', + options=set(), + description='', + items=( + ('SCENE', 'Scene', '', 'SCENE_DATA', 0), + ('ACTION_METADATA', 'Action Metadata', 'The frame rate will be determined by action\'s "psa_fps" custom property, if it exists. If the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used. If no metadata is available, the scene\'s frame rate will be used.', 'PROPERTIES', 1), + ('CUSTOM', 'Custom', '', 2) + ) + ) + fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=set(), step=100, soft_max=60.0) action_list: CollectionProperty(type=PsaExportActionListItem) action_list_index: IntProperty(default=0) marker_list: CollectionProperty(type=PsaExportTimelineMarkerListItem) @@ -136,8 +148,13 @@ class PsaExportOperator(Operator, ExportHelper): layout = self.layout pg = context.scene.psa_export + # FPS + layout.prop(pg, 'fps_source', text='FPS') + if pg.fps_source == 'CUSTOM': + layout.prop(pg, 'fps_custom', text='Custom') + # SOURCE - layout.prop(pg, 'sequence_source', text='Source', icon='ACTION' if pg.sequence_source == 'ACTIONS' else 'MARKER') + layout.prop(pg, 'sequence_source', text='Source') # SELECT ALL/NONE row = layout.row(align=True) @@ -258,6 +275,8 @@ class PsaExportOperator(Operator, ExportHelper): marker_names = [x.name for x in pg.marker_list if x.is_selected] options = PsaBuilderOptions() + options.fps_source = pg.fps_source + options.fps_custom = pg.fps_custom options.sequence_source = pg.sequence_source options.actions = actions options.marker_names = marker_names diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 6ed3ac4..f4a6158 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -17,6 +17,9 @@ class PsaImportOptions(object): self.should_stash = False self.sequence_names = [] self.should_use_action_name_prefix = False + self.should_overwrite = False + self.should_write_keyframes = True + self.should_write_metadata = True self.action_name_prefix = '' @@ -119,75 +122,87 @@ class PsaImporter(object): # Add the action. sequence_name = sequence.name.decode('windows-1252') action_name = options.action_name_prefix + sequence_name - action = bpy.data.actions.new(name=action_name) - action.use_fake_user = options.should_use_fake_user - # Create f-curves for the rotation and location of each bone. - for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items(): - import_bone = import_bones[psa_bone_index] - pose_bone = import_bone.pose_bone - rotation_data_path = pose_bone.path_from_id('rotation_quaternion') - location_data_path = pose_bone.path_from_id('location') - import_bone.fcurves = [ - action.fcurves.new(rotation_data_path, index=0), # Qw - action.fcurves.new(rotation_data_path, index=1), # Qx - action.fcurves.new(rotation_data_path, index=2), # Qy - action.fcurves.new(rotation_data_path, index=3), # Qz - action.fcurves.new(location_data_path, index=0), # Lx - action.fcurves.new(location_data_path, index=1), # Ly - action.fcurves.new(location_data_path, index=2), # Lz - ] + if options.should_overwrite and action_name in bpy.data.actions: + action = bpy.data.actions[action_name] + else: + action = bpy.data.actions.new(name=action_name) - # Read the sequence data matrix from the PSA. - sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name) - keyframe_write_matrix = np.ones(sequence_data_matrix.shape, dtype=np.int8) + if options.should_write_keyframes: + # Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2) + while len(action.fcurves) > 0: + action.fcurves.remove(action.fcurves[-1]) - # Convert the sequence's data from world-space to local-space. - for bone_index, import_bone in enumerate(import_bones): - if import_bone is None: - continue - for frame_index in range(sequence.frame_count): - # This bone has writeable keyframes for this frame. - key_data = sequence_data_matrix[frame_index, bone_index] - # Calculate the local-space key data for the bone. - sequence_data_matrix[frame_index, bone_index] = calculate_fcurve_data(import_bone, key_data) + # Create f-curves for the rotation and location of each bone. + for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items(): + import_bone = import_bones[psa_bone_index] + pose_bone = import_bone.pose_bone + rotation_data_path = pose_bone.path_from_id('rotation_quaternion') + location_data_path = pose_bone.path_from_id('location') + import_bone.fcurves = [ + action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name), # Qw + action.fcurves.new(rotation_data_path, index=1, action_group=pose_bone.name), # Qx + action.fcurves.new(rotation_data_path, index=2, action_group=pose_bone.name), # Qy + action.fcurves.new(rotation_data_path, index=3, action_group=pose_bone.name), # Qz + action.fcurves.new(location_data_path, index=0, action_group=pose_bone.name), # Lx + action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name), # Ly + action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name), # Lz + ] - # Clean the keyframe data. This is accomplished by writing zeroes to the write matrix when there is an - # insufficiently large change in the data from the last written frame. - if options.should_clean_keys: - threshold = 0.001 + # Read the sequence data matrix from the PSA. + sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name) + keyframe_write_matrix = np.ones(sequence_data_matrix.shape, dtype=np.int8) + + # Convert the sequence's data from world-space to local-space. for bone_index, import_bone in enumerate(import_bones): if import_bone is None: continue - for fcurve_index in range(len(import_bone.fcurves)): - # Get all the keyframe data for the bone's f-curve data from the sequence data matrix. - fcurve_frame_data = sequence_data_matrix[:, bone_index, fcurve_index] - last_written_datum = 0 - for frame_index, datum in enumerate(fcurve_frame_data): - # If the f-curve data is not different enough to the last written frame, un-mark this data for writing. - if frame_index > 0 and abs(datum - last_written_datum) < threshold: - keyframe_write_matrix[frame_index, bone_index, fcurve_index] = 0 - else: - last_written_datum = datum - - # Write the keyframes out! - for frame_index in range(sequence.frame_count): - for bone_index, import_bone in enumerate(import_bones): - if import_bone is None: - continue - bone_has_writeable_keyframes = any(keyframe_write_matrix[frame_index, bone_index]) - if bone_has_writeable_keyframes: + for frame_index in range(sequence.frame_count): # This bone has writeable keyframes for this frame. key_data = sequence_data_matrix[frame_index, bone_index] - for fcurve, should_write, datum in zip(import_bone.fcurves, keyframe_write_matrix[frame_index, bone_index], key_data): - if should_write: - fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'}) + # Calculate the local-space key data for the bone. + sequence_data_matrix[frame_index, bone_index] = calculate_fcurve_data(import_bone, key_data) - # Store the original sequence name for use when exporting this same action using the PSA exporter. - action['original_sequence_name'] = sequence_name + # Clean the keyframe data. This is accomplished by writing zeroes to the write matrix when there is an + # insufficiently large change in the data from the last written frame. + if options.should_clean_keys: + threshold = 0.001 + for bone_index, import_bone in enumerate(import_bones): + if import_bone is None: + continue + for fcurve_index in range(len(import_bone.fcurves)): + # Get all the keyframe data for the bone's f-curve data from the sequence data matrix. + fcurve_frame_data = sequence_data_matrix[:, bone_index, fcurve_index] + last_written_datum = 0 + for frame_index, datum in enumerate(fcurve_frame_data): + # If the f-curve data is not different enough to the last written frame, un-mark this data for writing. + if frame_index > 0 and abs(datum - last_written_datum) < threshold: + keyframe_write_matrix[frame_index, bone_index, fcurve_index] = 0 + else: + last_written_datum = datum + + # Write the keyframes out! + for frame_index in range(sequence.frame_count): + for bone_index, import_bone in enumerate(import_bones): + if import_bone is None: + continue + bone_has_writeable_keyframes = any(keyframe_write_matrix[frame_index, bone_index]) + if bone_has_writeable_keyframes: + # This bone has writeable keyframes for this frame. + key_data = sequence_data_matrix[frame_index, bone_index] + for fcurve, should_write, datum in zip(import_bone.fcurves, keyframe_write_matrix[frame_index, bone_index], key_data): + if should_write: + fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'}) + + # Write + if options.should_write_metadata: + action['psa_sequence_name'] = sequence_name + action['psa_fps'] = sequence.fps actions.append(action) + action.use_fake_user = options.should_use_fake_user + # If the user specifies, store the new animations as strips on a non-contributing NLA track. if options.should_stash: if armature_object.animation_data is None: @@ -246,11 +261,41 @@ class PsaImportPropertyGroup(PropertyGroup): should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is saved even it has no users.', options=set()) should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track', options=set()) should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=set()) + should_overwrite: BoolProperty(default=False, name='Reuse Existing Datablocks', options=set()) + should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=set()) + should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(), description='Additional data will be written to the custom properties of the Action (e.g., frame rate)') action_name_prefix: StringProperty(default='', name='Prefix', options=set()) sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) sequence_use_filter_invert: BoolProperty(default=False, options=set()) +def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[int]: + bitflag_filter_item = 1 << 30 + flt_flags = [bitflag_filter_item] * len(sequences) + + if pg.sequence_filter_name is not None: + # Filter name is non-empty. + import fnmatch + for i, sequence in enumerate(sequences): + if not fnmatch.fnmatch(sequence.action_name, f'*{pg.sequence_filter_name}*'): + flt_flags[i] &= ~bitflag_filter_item + + if pg.sequence_use_filter_invert: + # Invert filter flags for all items. + for i, sequence in enumerate(sequences): + flt_flags[i] ^= ~bitflag_filter_item + + return flt_flags + + +def get_visible_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[PsaImportActionListItem]: + visible_sequences = [] + for i, flag in enumerate(filter_sequences(pg, sequences)): + if bool(flag & (1 << 30)): + visible_sequences.append(sequences[i]) + return visible_sequences + + class PSA_UL_SequenceList(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): @@ -265,7 +310,6 @@ class PSA_UL_SequenceList(UIList): pg = context.scene.psa_import row = layout.row() subrow = row.row(align=True) - # TODO: current used for both, not good! subrow.prop(pg, 'sequence_filter_name', text="") subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT') @@ -274,13 +318,7 @@ class PSA_UL_SequenceList(UIList): sequences = getattr(data, property) flt_flags = [] if pg.sequence_filter_name: - flt_flags = bpy.types.UI_UL_list.filter_items_by_name( - pg.sequence_filter_name, - self.bitflag_filter_item, - sequences, - 'action_name', - reverse=pg.sequence_use_filter_invert - ) + flt_flags = filter_sequences(pg, sequences) flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'action_name') return flt_flags, flt_neworder @@ -302,14 +340,15 @@ class PsaImportSequencesSelectAll(Operator): @classmethod def poll(cls, context): pg = context.scene.psa_import - sequence_list = pg.sequence_list - has_unselected_actions = any(map(lambda action: not action.is_selected, sequence_list)) - return len(sequence_list) > 0 and has_unselected_actions + visible_sequences = get_visible_sequences(pg, pg.sequence_list) + has_unselected_actions = any(map(lambda action: not action.is_selected, visible_sequences)) + return len(visible_sequences) > 0 and has_unselected_actions def execute(self, context): pg = context.scene.psa_import - for action in pg.sequence_list: - action.is_selected = True + visible_sequences = get_visible_sequences(pg, pg.sequence_list) + for sequence in visible_sequences: + sequence.is_selected = True return {'FINISHED'} @@ -322,14 +361,15 @@ class PsaImportSequencesDeselectAll(Operator): @classmethod def poll(cls, context): pg = context.scene.psa_import - sequence_list = pg.sequence_list - has_selected_sequences = any(map(lambda action: action.is_selected, sequence_list)) - return len(sequence_list) > 0 and has_selected_sequences + visible_sequences = get_visible_sequences(pg, pg.sequence_list) + has_selected_sequences = any(map(lambda sequence: sequence.is_selected, visible_sequences)) + return len(visible_sequences) > 0 and has_selected_sequences def execute(self, context): pg = context.scene.psa_import - for action in pg.sequence_list: - action.is_selected = False + visible_sequences = get_visible_sequences(pg, pg.sequence_list) + for sequence in visible_sequences: + sequence.is_selected = False return {'FINISHED'} @@ -351,7 +391,6 @@ class PSA_PT_ImportPanel_Advanced(Panel): col.separator() col.prop(pg, 'should_use_fake_user') col.prop(pg, 'should_stash') - col.separator() col.prop(pg, 'should_use_action_name_prefix') if pg.should_use_action_name_prefix: @@ -416,8 +455,26 @@ class PSA_PT_ImportPanel(Panel): col = col.row() col.template_list('PSA_UL_ImportSequenceList', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows) - row = box.row() - row.operator(PsaImportOperator.bl_idname, text=f'Import') + col = layout.column(heading='') + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'should_overwrite') + + col = layout.column(heading='Write') + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'should_write_keyframes') + col.prop(pg, 'should_write_metadata') + + selected_sequence_count = sum(map(lambda x: x.is_selected, pg.sequence_list)) + + row = layout.row() + + import_button_text = 'Import' + if selected_sequence_count > 0: + import_button_text = f'Import ({selected_sequence_count})' + + row.operator(PsaImportOperator.bl_idname, text=import_button_text) class PsaImportFileReload(Operator): @@ -473,6 +530,9 @@ class PsaImportOperator(Operator): options.should_use_fake_user = pg.should_use_fake_user options.should_stash = pg.should_stash options.action_name_prefix = pg.action_name_prefix + options.should_overwrite = pg.should_overwrite + options.should_write_metadata = pg.should_write_metadata + options.should_write_keyframes = pg.should_write_keyframes PsaImporter().import_psa(psa_reader, context.view_layer.objects.active, options) diff --git a/io_scene_psk_psa/psa/reader.py b/io_scene_psk_psa/psa/reader.py index b9246cd..5f4acd5 100644 --- a/io_scene_psk_psa/psa/reader.py +++ b/io_scene_psk_psa/psa/reader.py @@ -19,7 +19,7 @@ class PsaReader(object): return self.psa.bones @property - def sequences(self): + def sequences(self) -> OrderedDict[Psa.Sequence]: return self.psa.sequences @staticmethod From 962fb0a791930b2619d692dfe1f10ba79a1c8c71 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Fri, 15 Apr 2022 17:35:06 -0700 Subject: [PATCH 18/22] Fixed a bug where `use_fake_user` would not be set correctly on import --- io_scene_psk_psa/psa/importer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index f4a6158..ce88a2a 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -199,9 +199,9 @@ class PsaImporter(object): action['psa_sequence_name'] = sequence_name action['psa_fps'] = sequence.fps - actions.append(action) + action.use_fake_user = options.should_use_fake_user - action.use_fake_user = options.should_use_fake_user + actions.append(action) # If the user specifies, store the new animations as strips on a non-contributing NLA track. if options.should_stash: From ab52b1520ed41acbb1a77272de6f87b6f6866857 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sun, 17 Apr 2022 16:30:15 -0700 Subject: [PATCH 19/22] Added the ability to select sequences from the import seqquence list by a text file --- io_scene_psk_psa/psa/importer.py | 38 +++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index ce88a2a..f06bdda 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -267,6 +267,7 @@ class PsaImportPropertyGroup(PropertyGroup): action_name_prefix: StringProperty(default='', name='Prefix', options=set()) sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) sequence_use_filter_invert: BoolProperty(default=False, options=set()) + select_text: PointerProperty(type=bpy.types.Text) def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[int]: @@ -331,6 +332,39 @@ class PSA_UL_ImportActionList(PSA_UL_SequenceList, UIList): pass +class PsaImportSequencesFromText(Operator): + bl_idname = 'psa_import.sequences_select_from_text' + bl_label = 'Select By Text List' + bl_description = 'Select sequences by name from text list' + bl_options = {'INTERNAL', 'UNDO'} + + @classmethod + def poll(cls, context): + pg = context.scene.psa_import + return len(pg.sequence_list) > 0 + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self, width=256) + + def draw(self, context): + layout = self.layout + pg = context.scene.psa_import + layout.label(icon='INFO', text='Each sequence name should be on a new line.') + layout.prop(pg, 'select_text', text='') + + def execute(self, context): + pg = context.scene.psa_import + contents = pg.select_text.as_string() + count = 0 + for line in contents.split('\n'): + for sequence in pg.sequence_list: + if sequence.action_name == line: + sequence.is_selected = True + count += 1 + self.report({'INFO'}, f'Selected {count} sequence(s)') + return {'FINISHED'} + + class PsaImportSequencesSelectAll(Operator): bl_idname = 'psa_import.sequences_select_all' bl_label = 'All' @@ -439,7 +473,7 @@ class PSA_PT_ImportPanel(Panel): box = layout.box() - box.label(text=f'Sequences', icon='ARMATURE_DATA') + box.label(text=f'Sequences ({len(pg.sequence_list)})', icon='ARMATURE_DATA') # select rows = max(3, min(len(pg.sequence_list), 10)) @@ -449,6 +483,7 @@ class PSA_PT_ImportPanel(Panel): row2 = col.row(align=True) row2.label(text='Select') + row2.operator(PsaImportSequencesFromText.bl_idname, text='', icon='TEXT') row2.operator(PsaImportSequencesSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT') row2.operator(PsaImportSequencesDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT') @@ -573,6 +608,7 @@ classes = ( PSA_UL_ImportActionList, PsaImportSequencesSelectAll, PsaImportSequencesDeselectAll, + PsaImportSequencesFromText, PsaImportFileReload, PSA_PT_ImportPanel, PSA_PT_ImportPanel_Advanced, From e8e8d6ce8bacf6cd46ef3c0ed24fb857c29140a4 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sun, 17 Apr 2022 17:31:52 -0700 Subject: [PATCH 20/22] Added regex fitlering to the PSA sequence list --- io_scene_psk_psa/psa/builder.py | 44 ++++++++++++++++---------------- io_scene_psk_psa/psa/exporter.py | 8 +++--- io_scene_psk_psa/psa/importer.py | 27 +++++++++++++++----- 3 files changed, 46 insertions(+), 33 deletions(-) diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index db7dbcd..0a24a34 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -30,6 +30,26 @@ class PsaBuilder(object): def __init__(self): pass + def get_sequence_fps(self, context, options: PsaBuilderOptions, actions: Iterable[Action]) -> float: + if options.fps_source == 'SCENE': + return context.scene.render.fps + if options.fps_source == 'CUSTOM': + return options.fps_custom + elif options.fps_source == 'ACTION_METADATA': + # Get the minimum value of action metadata FPS values. + psa_fps_list = [] + for action in filter(lambda x: 'psa_fps' in x, actions): + psa_fps = action['psa_fps'] + if type(psa_fps) == int or type(psa_fps) == float: + psa_fps_list.append(psa_fps) + if len(psa_fps_list) > 0: + return min(psa_fps_list) + else: + # No valid action metadata to use, fallback to scene FPS + return context.scene.render.fps + else: + raise RuntimeError(f'Invalid FPS source "{options.fps_source}"') + def build(self, context, options: PsaBuilderOptions) -> Psa: performance = PsaBuilderPerformance() active_object = context.view_layer.objects.active @@ -124,26 +144,6 @@ class PsaBuilder(object): export_sequences = [] - def get_sequence_fps(context, options: PsaBuilderOptions, actions: Iterable[Action]) -> float: - if options.fps_source == 'SCENE': - return context.scene.render.fps - if options.fps_source == 'CUSTOM': - return options.fps_custom - elif options.fps_source == 'ACTION_METADATA': - # Get the minimum value of action metadata FPS values. - psa_fps_list = [] - for action in filter(lambda x: 'psa_fps' in x, actions): - psa_fps = action['psa_fps'] - if type(psa_fps) == int or type(psa_fps) == float: - psa_fps_list.append(psa_fps) - if len(psa_fps_list) > 0: - return min(psa_fps_list) - else: - # No valid action metadata to use, fallback to scene FPS - return context.scene.render.fps - else: - raise RuntimeError(f'Invalid FPS source "{options.fps_source}"') - if options.sequence_source == 'ACTIONS': for action in options.actions: if len(action.fcurves) == 0: @@ -154,7 +154,7 @@ class PsaBuilder(object): frame_min, frame_max = [int(x) for x in action.frame_range] export_sequence.nla_state.frame_min = frame_min export_sequence.nla_state.frame_max = frame_max - export_sequence.fps = get_sequence_fps(context, options, [action]) + export_sequence.fps = self.get_sequence_fps(context, options, [action]) export_sequences.append(export_sequence) pass elif options.sequence_source == 'TIMELINE_MARKERS': @@ -167,7 +167,7 @@ class PsaBuilder(object): export_sequence.nla_state.frame_min = frame_min export_sequence.nla_state.frame_max = frame_max nla_strips_actions = set(map(lambda x: x.action, get_nla_strips_in_timeframe(active_object, frame_min, frame_max))) - export_sequence.fps = get_sequence_fps(context, options, nla_strips_actions) + export_sequence.fps = self.get_sequence_fps(context, options, nla_strips_actions) export_sequences.append(export_sequence) else: raise ValueError(f'Unhandled sequence source: {options.sequence_source}') diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 77fcfc7..fd5d15e 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -10,6 +10,7 @@ from ..helpers import * from collections import Counter import re import sys +import fnmatch class PsaExporter(object): @@ -318,7 +319,6 @@ def filter_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_c if pg.sequence_filter_name is not None: # Filter name is non-empty. - import fnmatch for i, sequence in enumerate(sequences): if not fnmatch.fnmatch(sequence.name, f'*{pg.sequence_filter_name}*'): flt_flags[i] &= ~bitflag_filter_item @@ -331,7 +331,7 @@ def filter_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_c if pg.sequence_use_filter_invert: # Invert filter flags for all items. for i, sequence in enumerate(sequences): - flt_flags[i] ^= ~bitflag_filter_item + flt_flags[i] ^= bitflag_filter_item return flt_flags @@ -377,7 +377,7 @@ class PSA_UL_ExportActionList(UIList): class PsaExportActionsSelectAll(Operator): bl_idname = 'psa_export.sequences_select_all' bl_label = 'Select All' - bl_description = 'Select all sequences' + bl_description = 'Select all visible sequences' bl_options = {'INTERNAL'} @classmethod @@ -408,7 +408,7 @@ class PsaExportActionsSelectAll(Operator): class PsaExportActionsDeselectAll(Operator): bl_idname = 'psa_export.sequences_deselect_all' bl_label = 'Deselect All' - bl_description = 'Deselect all sequences' + bl_description = 'Deselect all visible sequences' bl_options = {'INTERNAL'} @classmethod diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index f06bdda..1713627 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -1,6 +1,8 @@ import bpy import os import numpy as np +import re +import fnmatch from mathutils import Vector, Quaternion, Matrix from .data import Psa from typing import List, AnyStr, Optional @@ -267,6 +269,7 @@ class PsaImportPropertyGroup(PropertyGroup): action_name_prefix: StringProperty(default='', name='Prefix', options=set()) sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) sequence_use_filter_invert: BoolProperty(default=False, options=set()) + sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression', description='Filter using regular expressions', options=set()) select_text: PointerProperty(type=bpy.types.Text) @@ -276,15 +279,24 @@ def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_c if pg.sequence_filter_name is not None: # Filter name is non-empty. - import fnmatch - for i, sequence in enumerate(sequences): - if not fnmatch.fnmatch(sequence.action_name, f'*{pg.sequence_filter_name}*'): - flt_flags[i] &= ~bitflag_filter_item + if pg.sequence_use_filter_regex: + # Use regular expression + try: + regex = re.compile(pg.sequence_filter_name) + for i, sequence in enumerate(sequences): + if not regex.match(sequence.action_name): + flt_flags[i] &= ~bitflag_filter_item + except re.error: + pass + else: + for i, sequence in enumerate(sequences): + if not fnmatch.fnmatch(sequence.action_name, f'*{pg.sequence_filter_name}*'): + flt_flags[i] &= ~bitflag_filter_item if pg.sequence_use_filter_invert: # Invert filter flags for all items. for i, sequence in enumerate(sequences): - flt_flags[i] ^= ~bitflag_filter_item + flt_flags[i] ^= bitflag_filter_item return flt_flags @@ -313,6 +325,7 @@ class PSA_UL_SequenceList(UIList): subrow = row.row(align=True) subrow.prop(pg, 'sequence_filter_name', text="") subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT') + subrow.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT') def filter_items(self, context, data, property): pg = context.scene.psa_import @@ -368,7 +381,7 @@ class PsaImportSequencesFromText(Operator): class PsaImportSequencesSelectAll(Operator): bl_idname = 'psa_import.sequences_select_all' bl_label = 'All' - bl_description = 'Select all sequences' + bl_description = 'Select all visible sequences' bl_options = {'INTERNAL'} @classmethod @@ -389,7 +402,7 @@ class PsaImportSequencesSelectAll(Operator): class PsaImportSequencesDeselectAll(Operator): bl_idname = 'psa_import.sequences_deselect_all' bl_label = 'None' - bl_description = 'Deselect all sequences' + bl_description = 'Deselect all visible sequences' bl_options = {'INTERNAL'} @classmethod From d56aa3ab657fc6e6a1c7ca6606d9a4997ed36e41 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sun, 17 Apr 2022 17:55:15 -0700 Subject: [PATCH 21/22] Added the ability to hide deselected sequences from the PSA import sequence list --- io_scene_psk_psa/psa/importer.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 1713627..bc7f2fe 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -268,6 +268,7 @@ class PsaImportPropertyGroup(PropertyGroup): should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(), description='Additional data will be written to the custom properties of the Action (e.g., frame rate)') action_name_prefix: StringProperty(default='', name='Prefix', options=set()) sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) + sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected', description='Only show selected sequences') sequence_use_filter_invert: BoolProperty(default=False, options=set()) sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression', description='Filter using regular expressions', options=set()) select_text: PointerProperty(type=bpy.types.Text) @@ -280,7 +281,7 @@ def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_c if pg.sequence_filter_name is not None: # Filter name is non-empty. if pg.sequence_use_filter_regex: - # Use regular expression + # Use regular expression. If regex pattern doesn't compile, just ignore it. try: regex = re.compile(pg.sequence_filter_name) for i, sequence in enumerate(sequences): @@ -289,10 +290,16 @@ def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_c except re.error: pass else: + # User regular matching for i, sequence in enumerate(sequences): if not fnmatch.fnmatch(sequence.action_name, f'*{pg.sequence_filter_name}*'): flt_flags[i] &= ~bitflag_filter_item + if pg.sequence_filter_is_selected: + for i, sequence in enumerate(sequences): + if not sequence.is_selected: + flt_flags[i] &= ~bitflag_filter_item + if pg.sequence_use_filter_invert: # Invert filter flags for all items. for i, sequence in enumerate(sequences): @@ -302,9 +309,10 @@ def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_c def get_visible_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[PsaImportActionListItem]: + bitflag_filter_item = 1 << 30 visible_sequences = [] for i, flag in enumerate(filter_sequences(pg, sequences)): - if bool(flag & (1 << 30)): + if bool(flag & bitflag_filter_item): visible_sequences.append(sequences[i]) return visible_sequences @@ -326,13 +334,12 @@ class PSA_UL_SequenceList(UIList): subrow.prop(pg, 'sequence_filter_name', text="") subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT') subrow.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT') + subrow.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT') def filter_items(self, context, data, property): pg = context.scene.psa_import sequences = getattr(data, property) - flt_flags = [] - if pg.sequence_filter_name: - flt_flags = filter_sequences(pg, sequences) + flt_flags = filter_sequences(pg, sequences) flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'action_name') return flt_flags, flt_neworder From 1eafb71dce88a4b5fec336ac3abc20833c0db75b Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sun, 24 Apr 2022 22:08:36 -0700 Subject: [PATCH 22/22] * Fixed a bug where the action name prefix could be applied even if the checkbox was deselected * Fixed a typo in the PSA reader * Added the ability to define the bone length of imported PSK armatures * The PSK import options (extra UVs, vertex colors etc.) are now actually respected if turned off * Ran automated formatting on all the code to quell the PEP8 gods * Incremented version to 3.0.0 --- io_scene_psk_psa/__init__.py | 2 +- io_scene_psk_psa/helpers.py | 7 +-- io_scene_psk_psa/psa/builder.py | 23 ++++++---- io_scene_psk_psa/psa/data.py | 3 +- io_scene_psk_psa/psa/exporter.py | 49 ++++++++++++-------- io_scene_psk_psa/psa/importer.py | 79 ++++++++++++++++---------------- io_scene_psk_psa/psa/reader.py | 26 ++++++----- io_scene_psk_psa/psk/builder.py | 3 +- io_scene_psk_psa/psk/data.py | 2 +- io_scene_psk_psa/psk/exporter.py | 17 ++++--- io_scene_psk_psa/psk/importer.py | 40 +++++++++++----- io_scene_psk_psa/psk/reader.py | 3 +- io_scene_psk_psa/types.py | 2 +- 13 files changed, 147 insertions(+), 109 deletions(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 44b2ddd..5d8f735 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "PSK/PSA Importer/Exporter", "author": "Colin Basnett", - "version": (2, 1, 0), + "version": (3, 0, 0), "blender": (2, 80, 0), # "location": "File > Export > PSK Export (.psk)", "description": "PSK/PSA Import/Export (.psk/.psa)", diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index f5864b2..8c5c69c 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -1,7 +1,8 @@ -from bpy.types import NlaStrip -from typing import List, Tuple, Optional -from collections import Counter import datetime +from collections import Counter +from typing import List + +from bpy.types import NlaStrip class Timer: diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 0a24a34..ae953c1 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -1,7 +1,9 @@ +from typing import Dict, Iterable + +from bpy.types import Action + from .data import * from ..helpers import * -from typing import Dict, Iterable -from bpy.types import Action class PsaBuilderOptions(object): @@ -37,13 +39,13 @@ class PsaBuilder(object): return options.fps_custom elif options.fps_source == 'ACTION_METADATA': # Get the minimum value of action metadata FPS values. - psa_fps_list = [] - for action in filter(lambda x: 'psa_fps' in x, actions): - psa_fps = action['psa_fps'] - if type(psa_fps) == int or type(psa_fps) == float: - psa_fps_list.append(psa_fps) - if len(psa_fps_list) > 0: - return min(psa_fps_list) + fps_list = [] + for action in filter(lambda x: 'psa_sequence_fps' in x, actions): + fps = action['psa_sequence_fps'] + if type(fps) == int or type(fps) == float: + fps_list.append(fps) + if len(fps_list) > 0: + return min(fps_list) else: # No valid action metadata to use, fallback to scene FPS return context.scene.render.fps @@ -166,7 +168,8 @@ class PsaBuilder(object): export_sequence.nla_state.action = None export_sequence.nla_state.frame_min = frame_min export_sequence.nla_state.frame_max = frame_max - nla_strips_actions = set(map(lambda x: x.action, get_nla_strips_in_timeframe(active_object, frame_min, frame_max))) + nla_strips_actions = set( + map(lambda x: x.action, get_nla_strips_in_timeframe(active_object, frame_min, frame_max))) export_sequence.fps = self.get_sequence_fps(context, options, nla_strips_actions) export_sequences.append(export_sequence) else: diff --git a/io_scene_psk_psa/psa/data.py b/io_scene_psk_psa/psa/data.py index 6f4e484..76395d1 100644 --- a/io_scene_psk_psa/psa/data.py +++ b/io_scene_psk_psa/psa/data.py @@ -1,6 +1,7 @@ import typing -from typing import List from collections import OrderedDict +from typing import List + from ..data import * """ diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index fd5d15e..3f76655 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -1,16 +1,19 @@ -import bpy -from bpy.types import Operator, PropertyGroup, Action, UIList, BoneGroup, Panel, TimelineMarker -from bpy.props import CollectionProperty, IntProperty, FloatProperty, PointerProperty, StringProperty, BoolProperty, EnumProperty -from bpy_extras.io_utils import ExportHelper -from typing import Type -from .builder import PsaBuilder, PsaBuilderOptions -from .data import * -from ..types import BoneGroupListItem -from ..helpers import * -from collections import Counter +import fnmatch import re import sys -import fnmatch +from collections import Counter +from typing import Type + +import bpy +from bpy.props import BoolProperty, CollectionProperty, EnumProperty, FloatProperty, IntProperty, PointerProperty, \ + StringProperty +from bpy.types import Action, Operator, PropertyGroup, UIList +from bpy_extras.io_utils import ExportHelper + +from .builder import PsaBuilder, PsaBuilderOptions +from .data import * +from ..helpers import * +from ..types import BoneGroupListItem class PsaExporter(object): @@ -57,7 +60,7 @@ def update_action_names(context): item.action_name = get_psa_sequence_name(action, pg.should_use_original_sequence_names) -def should_use_original_sequence_names_updated(property, context): +def should_use_original_sequence_names_updated(_, context): update_action_names(context) @@ -68,7 +71,8 @@ class PsaExportPropertyGroup(PropertyGroup): description='', items=( ('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0), - ('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers', 'MARKER_HLT', 1), + ('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers', 'MARKER_HLT', + 1), ) ) fps_source: EnumProperty( @@ -77,11 +81,14 @@ class PsaExportPropertyGroup(PropertyGroup): description='', items=( ('SCENE', 'Scene', '', 'SCENE_DATA', 0), - ('ACTION_METADATA', 'Action Metadata', 'The frame rate will be determined by action\'s "psa_fps" custom property, if it exists. If the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used. If no metadata is available, the scene\'s frame rate will be used.', 'PROPERTIES', 1), + ('ACTION_METADATA', 'Action Metadata', + 'The frame rate will be determined by action\'s "psa_sequence_fps" custom property, if it exists. If the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used. If no metadata is available, the scene\'s frame rate will be used.', + 'PROPERTIES', 1), ('CUSTOM', 'Custom', '', 2) ) ) - fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=set(), step=100, soft_max=60.0) + fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=set(), step=100, + soft_max=60.0) action_list: CollectionProperty(type=PsaExportActionListItem) action_list_index: IntProperty(default=0) marker_list: CollectionProperty(type=PsaExportTimelineMarkerListItem) @@ -117,7 +124,8 @@ class PsaExportPropertyGroup(PropertyGroup): sequence_name_suffix: StringProperty(name='Suffix', options=set()) sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) sequence_use_filter_invert: BoolProperty(default=False, options=set()) - sequence_filter_asset: BoolProperty(default=False, name='Show assets', description='Show actions that belong to an asset library', options=set()) + sequence_filter_asset: BoolProperty(default=False, name='Show assets', + description='Show actions that belong to an asset library', options=set()) sequence_use_filter_sort_reverse: BoolProperty(default=True, options=set()) @@ -178,7 +186,8 @@ class PsaExportOperator(Operator, ExportHelper): elif pg.sequence_source == 'TIMELINE_MARKERS': rows = max(3, min(len(pg.marker_list), 10)) - layout.template_list('PSA_UL_ExportTimelineMarkerList', '', pg, 'marker_list', pg, 'marker_list_index', rows=rows) + layout.template_list('PSA_UL_ExportTimelineMarkerList', '', pg, 'marker_list', pg, 'marker_list_index', + rows=rows) col = layout.column() col.use_property_split = True @@ -208,7 +217,8 @@ class PsaExportOperator(Operator, ExportHelper): row.operator(PsaExportBoneGroupsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT') row.operator(PsaExportBoneGroupsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT') rows = max(3, min(len(pg.bone_group_list), 10)) - layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) + layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', + rows=rows) def should_action_be_selected_by_default(self, action): return action is not None and action.asset_data is None @@ -336,7 +346,8 @@ def filter_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_c return flt_flags -def get_visible_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[PsaExportActionListItem]: +def get_visible_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[ + PsaExportActionListItem]: visible_sequences = [] for i, flag in enumerate(filter_sequences(pg, sequences)): if bool(flag & (1 << 30)): diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index bc7f2fe..6d3151f 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -1,14 +1,16 @@ -import bpy -import os -import numpy as np -import re import fnmatch -from mathutils import Vector, Quaternion, Matrix -from .data import Psa -from typing import List, AnyStr, Optional -from bpy.types import Operator, Action, UIList, PropertyGroup, Panel, Armature, FileSelectParams -from bpy_extras.io_utils import ImportHelper +import os +import re +from typing import List, Optional + +import bpy +import numpy as np from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty +from bpy.types import Operator, UIList, PropertyGroup, Panel +from bpy_extras.io_utils import ImportHelper +from mathutils import Vector, Quaternion + +from .data import Psa from .reader import PsaReader @@ -18,7 +20,6 @@ class PsaImportOptions(object): self.should_use_fake_user = False self.should_stash = False self.sequence_names = [] - self.should_use_action_name_prefix = False self.should_overwrite = False self.should_write_keyframes = True self.should_write_metadata = True @@ -76,7 +77,8 @@ class PsaImporter(object): # Report if there are missing bones in the target armature. missing_bone_names = set(psa_bone_names).difference(set(armature_bone_names)) if len(missing_bone_names) > 0: - print(f'The armature object \'{armature_object.name}\' is missing the following bones that exist in the PSA:') + print( + f'The armature object \'{armature_object.name}\' is missing the following bones that exist in the PSA:') print(list(sorted(missing_bone_names))) del armature_bone_names @@ -192,14 +194,16 @@ class PsaImporter(object): if bone_has_writeable_keyframes: # This bone has writeable keyframes for this frame. key_data = sequence_data_matrix[frame_index, bone_index] - for fcurve, should_write, datum in zip(import_bone.fcurves, keyframe_write_matrix[frame_index, bone_index], key_data): + for fcurve, should_write, datum in zip(import_bone.fcurves, + keyframe_write_matrix[frame_index, bone_index], + key_data): if should_write: fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'}) # Write if options.should_write_metadata: action['psa_sequence_name'] = sequence_name - action['psa_fps'] = sequence.fps + action['psa_sequence_fps'] = sequence.fps action.use_fake_user = options.should_use_fake_user @@ -259,18 +263,28 @@ class PsaImportPropertyGroup(PropertyGroup): psa: PointerProperty(type=PsaDataPropertyGroup) sequence_list: CollectionProperty(type=PsaImportActionListItem) sequence_list_index: IntProperty(name='', default=0) - should_clean_keys: BoolProperty(default=True, name='Clean Keyframes', description='Exclude unnecessary keyframes from being written to the actions.', options=set()) - should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is saved even it has no users.', options=set()) - should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track', options=set()) + should_clean_keys: BoolProperty(default=True, name='Clean Keyframes', + description='Exclude unnecessary keyframes from being written to the actions', + options=set()) + should_use_fake_user: BoolProperty(default=True, name='Fake User', + description='Assign each imported action a fake user so that the data block is saved even it has no users', + options=set()) + should_stash: BoolProperty(default=False, name='Stash', + description='Stash each imported action as a strip on a new non-contributing NLA track', + options=set()) should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=set()) - should_overwrite: BoolProperty(default=False, name='Reuse Existing Datablocks', options=set()) - should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=set()) - should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(), description='Additional data will be written to the custom properties of the Action (e.g., frame rate)') action_name_prefix: StringProperty(default='', name='Prefix', options=set()) + should_overwrite: BoolProperty(default=False, name='Reuse Existing Actions', options=set(), + description='If an action with a matching name already exists, the existing action will have it\'s data overwritten instead of a new action being created') + should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=set()) + should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(), + description='Additional data will be written to the custom properties of the Action (e.g., frame rate)') sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) - sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected', description='Only show selected sequences') + sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected', + description='Only show selected sequences') sequence_use_filter_invert: BoolProperty(default=False, options=set()) - sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression', description='Filter using regular expressions', options=set()) + sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression', + description='Filter using regular expressions', options=set()) select_text: PointerProperty(type=bpy.types.Text) @@ -290,7 +304,7 @@ def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_c except re.error: pass else: - # User regular matching + # User regular text matching. for i, sequence in enumerate(sequences): if not fnmatch.fnmatch(sequence.action_name, f'*{pg.sequence_filter_name}*'): flt_flags[i] &= ~bitflag_filter_item @@ -308,7 +322,8 @@ def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_c return flt_flags -def get_visible_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[PsaImportActionListItem]: +def get_visible_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[ + PsaImportActionListItem]: bitflag_filter_item = 1 << 30 visible_sequences = [] for i, flag in enumerate(filter_sequences(pg, sequences)): @@ -451,21 +466,6 @@ class PSA_PT_ImportPanel_Advanced(Panel): col.prop(pg, 'action_name_prefix') -class PSA_PT_ImportPanel_PsaData(Panel): - bl_space_type = 'PROPERTIES' - bl_region_type = 'WINDOW' - bl_label = 'PSA Info' - bl_options = {'DEFAULT_CLOSED'} - bl_parent_id = 'PSA_PT_ImportPanel' - - def draw(self, context): - layout = self.layout - pg = context.scene.psa_import.psa - - layout.label(text=f'{len(pg.bones)} Bones', icon='BONE_DATA') - layout.label(text=f'{pg.sequence_count} Sequences', icon='SEQUENCE') - - class PSA_PT_ImportPanel(Panel): bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' @@ -584,7 +584,7 @@ class PsaImportOperator(Operator): options.should_clean_keys = pg.should_clean_keys options.should_use_fake_user = pg.should_use_fake_user options.should_stash = pg.should_stash - options.action_name_prefix = pg.action_name_prefix + options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else '' options.should_overwrite = pg.should_overwrite options.should_write_metadata = pg.should_write_metadata options.should_write_keyframes = pg.should_write_keyframes @@ -632,7 +632,6 @@ classes = ( PsaImportFileReload, PSA_PT_ImportPanel, PSA_PT_ImportPanel_Advanced, - PSA_PT_ImportPanel_PsaData, PsaImportOperator, PsaImportFileSelectOperator, PsaImportSelectFile, diff --git a/io_scene_psk_psa/psa/reader.py b/io_scene_psk_psa/psa/reader.py index 5f4acd5..c8f41d0 100644 --- a/io_scene_psk_psa/psa/reader.py +++ b/io_scene_psk_psa/psa/reader.py @@ -1,14 +1,17 @@ -from .data import * import ctypes + import numpy as np +from .data import * + class PsaReader(object): """ - This class will read the sequences and bone information immediately upon instantiation and hold onto a file handle. + This class reads the sequences and bone information immediately upon instantiation and hold onto a file handle. The key data is not read into memory upon instantiation due to it's potentially very large size. To read the key data for a particular sequence, call `read_sequence_keys`. """ + def __init__(self, path): self.keys_data_offset: int = 0 self.fp = open(path, 'rb') @@ -22,15 +25,6 @@ class PsaReader(object): def sequences(self) -> OrderedDict[Psa.Sequence]: return self.psa.sequences - @staticmethod - def _read_types(fp, data_class: ctypes.Structure, section: Section, data): - buffer_length = section.data_size * section.data_count - buffer = fp.read(buffer_length) - offset = 0 - for _ in range(section.data_count): - data.append(data_class.from_buffer_copy(buffer, offset)) - offset += section.data_size - def read_sequence_data_matrix(self, sequence_name: str): sequence = self.psa.sequences[sequence_name] keys = self.read_sequence_keys(sequence_name) @@ -65,6 +59,15 @@ class PsaReader(object): offset += data_size return keys + @staticmethod + def _read_types(fp, data_class: ctypes.Structure, section: Section, data): + buffer_length = section.data_size * section.data_count + buffer = fp.read(buffer_length) + offset = 0 + for _ in range(section.data_count): + data.append(data_class.from_buffer_copy(buffer, offset)) + offset += section.data_size + def _read(self, fp) -> Psa: psa = Psa() while fp.read(1): @@ -88,4 +91,3 @@ class PsaReader(object): else: raise RuntimeError(f'Unrecognized section "{section.name}"') return psa -1 \ No newline at end of file diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index f48e139..4421600 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -1,6 +1,5 @@ -import bpy -import bmesh from collections import OrderedDict + from .data import * from ..helpers import * diff --git a/io_scene_psk_psa/psk/data.py b/io_scene_psk_psa/psk/data.py index e663a22..cb87058 100644 --- a/io_scene_psk_psa/psk/data.py +++ b/io_scene_psk_psa/psk/data.py @@ -1,9 +1,9 @@ from typing import List + from ..data import * class Psk(object): - class Wedge(object): def __init__(self): self.point_index: int = 0 diff --git a/io_scene_psk_psa/psk/exporter.py b/io_scene_psk_psa/psk/exporter.py index 9d047b7..3327940 100644 --- a/io_scene_psk_psa/psk/exporter.py +++ b/io_scene_psk_psa/psk/exporter.py @@ -1,11 +1,13 @@ -from .data import * -from ..types import BoneGroupListItem -from ..helpers import populate_bone_group_list -from .builder import PskBuilder, PskBuilderOptions from typing import Type + +from bpy.props import StringProperty, CollectionProperty, IntProperty, EnumProperty from bpy.types import Operator, PropertyGroup from bpy_extras.io_utils import ExportHelper -from bpy.props import StringProperty, CollectionProperty, IntProperty, BoolProperty, EnumProperty + +from .builder import PskBuilder, PskBuilderOptions +from .data import * +from ..helpers import populate_bone_group_list +from ..types import BoneGroupListItem MAX_WEDGE_COUNT = 65536 MAX_POINT_COUNT = 4294967296 @@ -144,7 +146,8 @@ class PskExportPropertyGroup(PropertyGroup): description='', items=( ('ALL', 'All', 'All bones will be exported.'), - ('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will be exported.') + ('BONE_GROUPS', 'Bone Groups', + 'Only bones belonging to the selected bone groups and their ancestors will be exported.') ) ) bone_group_list: CollectionProperty(type=BoneGroupListItem) @@ -154,4 +157,4 @@ class PskExportPropertyGroup(PropertyGroup): classes = ( PskExportOperator, PskExportPropertyGroup -) \ No newline at end of file +) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 8de4e81..e2758df 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -1,16 +1,19 @@ import os -import bpy -import bmesh -import numpy as np +import sys from math import inf from typing import Optional -from .data import Psk -from ..helpers import rgb_to_srgb -from mathutils import Quaternion, Vector, Matrix -from .reader import PskReader -from bpy.props import StringProperty, EnumProperty, BoolProperty + +import bmesh +import bpy +import numpy as np +from bpy.props import BoolProperty, EnumProperty, FloatProperty, StringProperty from bpy.types import Operator, PropertyGroup from bpy_extras.io_utils import ImportHelper +from mathutils import Quaternion, Vector, Matrix + +from .data import Psk +from .reader import PskReader +from ..helpers import rgb_to_srgb class PskImportOptions(object): @@ -20,6 +23,7 @@ class PskImportOptions(object): self.vertex_color_space = 'sRGB' self.should_import_vertex_normals = True self.should_import_extra_uvs = True + self.bone_length = 1.0 class PskImporter(object): @@ -60,7 +64,6 @@ class PskImporter(object): self.post_quat: Quaternion = Quaternion() import_bones = [] - new_bone_size = 8.0 for bone_index, psk_bone in enumerate(psk.bones): import_bone = ImportBone(bone_index, psk_bone) @@ -93,7 +96,7 @@ class PskImporter(object): else: import_bone.local_rotation.conjugate() - edit_bone.tail = Vector((0.0, new_bone_size, 0.0)) + edit_bone.tail = Vector((0.0, options.bone_length, 0.0)) edit_bone_matrix = import_bone.local_rotation.conjugated() edit_bone_matrix.rotate(import_bone.world_matrix) edit_bone_matrix = edit_bone_matrix.to_matrix().to_4x4() @@ -209,7 +212,8 @@ class PskImporter(object): # Get a list of all bones that have weights associated with them. vertex_group_bone_indices = set(map(lambda weight: weight.bone_index, psk.weights)) for import_bone in map(lambda x: import_bones[x], sorted(list(vertex_group_bone_indices))): - import_bone.vertex_group = mesh_object.vertex_groups.new(name=import_bone.psk_bone.name.decode('windows-1252')) + import_bone.vertex_group = mesh_object.vertex_groups.new( + name=import_bone.psk_bone.name.decode('windows-1252')) for weight in psk.weights: import_bones[weight.bone_index].vertex_group.add((weight.point_index,), weight.weight, 'ADD') @@ -256,6 +260,15 @@ class PskImportPropertyGroup(PropertyGroup): options=set(), description='Import extra UV maps from PSKX files, if available' ) + bone_length: FloatProperty( + default=1.0, + min=sys.float_info.epsilon, + step=100, + soft_min=1.0, + name='Bone Length', + options=set(), + description='Length of the bones' + ) class PskImportOperator(Operator, ImportHelper): @@ -277,7 +290,11 @@ class PskImportOperator(Operator, ImportHelper): psk = reader.read(self.filepath) options = PskImportOptions() options.name = os.path.splitext(os.path.basename(self.filepath))[0] + options.should_import_extra_uvs = pg.should_import_extra_uvs + options.should_import_vertex_colors = pg.should_import_vertex_colors + options.should_import_vertex_normals = pg.should_import_vertex_normals options.vertex_color_space = pg.vertex_color_space + options.bone_length = pg.bone_length PskImporter().import_psk(psk, context, options) return {'FINISHED'} @@ -291,6 +308,7 @@ class PskImportOperator(Operator, ImportHelper): layout.prop(pg, 'should_import_vertex_colors') if pg.should_import_vertex_colors: layout.prop(pg, 'vertex_color_space') + layout.prop(pg, 'bone_length') classes = ( diff --git a/io_scene_psk_psa/psk/reader.py b/io_scene_psk_psa/psk/reader.py index 29eb4ba..7fde8b6 100644 --- a/io_scene_psk_psa/psk/reader.py +++ b/io_scene_psk_psa/psk/reader.py @@ -1,6 +1,7 @@ -from .data import * import ctypes +from .data import * + class PskReader(object): diff --git a/io_scene_psk_psa/types.py b/io_scene_psk_psa/types.py index 142f4fb..aa38595 100644 --- a/io_scene_psk_psa/types.py +++ b/io_scene_psk_psa/types.py @@ -1,5 +1,5 @@ -from bpy.types import PropertyGroup, UIList from bpy.props import StringProperty, IntProperty, BoolProperty +from bpy.types import PropertyGroup, UIList class PSX_UL_BoneGroupList(UIList):