diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index 24b3d0a..aaeefe8 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -1,9 +1,9 @@ from collections import Counter -from typing import List, Iterable, Dict, Tuple, cast, Optional +from typing import List, Iterable, Dict, Tuple, Optional import bpy from bpy.props import StringProperty -from bpy.types import Context, Armature, Action, Object, AnimData, TimelineMarker +from bpy.types import Context, Action, Object, AnimData, TimelineMarker from bpy_extras.io_utils import ExportHelper from bpy_types import Operator @@ -29,15 +29,22 @@ def get_sequences_propnames_from_source(sequence_source: str) -> Optional[Tuple[ raise ValueError(f'Unhandled sequence source: {sequence_source}') -def is_action_for_armature(armature: Armature, action: Action): +def is_action_for_object(obj: Object, action: Action): if len(action.fcurves) == 0: return False + if obj.animation_data is None: + return False + + if obj.type != 'ARMATURE': + return False + version = SemanticVersion(bpy.app.version) if version < SemanticVersion((4, 4, 0)): import re - bone_names = set([x.name for x in armature.bones]) + armature_data = obj.data + bone_names = set([x.name for x in armature_data.bones]) for fcurve in action.fcurves: match = re.match(r'pose\.bones\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path) if not match: @@ -46,12 +53,9 @@ def is_action_for_armature(armature: Armature, action: Action): if bone_name in bone_names: return True else: - # Look up the armature by ID and check if its data block pointer matches the armature. - for slot in filter(lambda x: x.id_root == 'OBJECT', action.slots): - # Lop off the 'OB' prefix from the identifier for the lookup. - object = bpy.data.objects.get(slot.identifier[2:], None) - if object and object.data == armature: - return True + # In 4.4.0 and later, we can check if the object's action slot handle matches an action slot handle in the action. + if any(obj.animation_data.action_slot_handle == slot.handle for slot in action.slots): + return True return False @@ -71,22 +75,20 @@ def update_actions_and_timeline_markers(context: Context): if animation_data is None: return - active_armature = cast(Armature, context.active_object.data) - # Populate actions list. for action in bpy.data.actions: - if not is_action_for_armature(active_armature, action): + if not is_action_for_object(context.active_object, action): continue - if action.name != '' and not action.name.startswith('#'): - for (name, frame_start, frame_end) in get_sequences_from_action(action): - item = pg.action_list.add() - item.action = action - item.name = name - item.is_selected = False - item.is_pose_marker = False - item.frame_start = frame_start - item.frame_end = frame_end + for (name, frame_start, frame_end) in get_sequences_from_action(action): + print(name) + item = pg.action_list.add() + item.action = action + item.name = name + item.is_selected = False + item.is_pose_marker = False + item.frame_start = frame_start + item.frame_end = frame_end # Pose markers are not guaranteed to be in frame-order, so make sure that they are. pose_markers = sorted(action.pose_markers, key=lambda x: x.frame) @@ -217,13 +219,15 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context: return sequence_frame_ranges -def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]: +def get_sequences_from_action(action: Action): + if action.name == '' or action.name.startswith('#'): + return frame_start = int(action.frame_range[0]) frame_end = int(action.frame_range[1]) - return get_sequences_from_name_and_frame_range(action.name, frame_start, frame_end) + yield from get_sequences_from_name_and_frame_range(action.name, frame_start, frame_end) -def get_sequences_from_action_pose_markers(action: Action, pose_markers: List[TimelineMarker], pose_marker: TimelineMarker, pose_marker_index: int) -> List[Tuple[str, int, int]]: +def get_sequences_from_action_pose_markers(action: Action, pose_markers: List[TimelineMarker], pose_marker: TimelineMarker, pose_marker_index: int): frame_start = pose_marker.frame sequence_name = pose_marker.name if pose_marker.name.startswith('!'): @@ -234,7 +238,7 @@ def get_sequences_from_action_pose_markers(action: Action, pose_markers: List[Ti frame_end = pose_markers[pose_marker_index + 1].frame else: frame_end = int(action.frame_range[1]) - return get_sequences_from_name_and_frame_range(sequence_name, frame_start, frame_end) + yield from get_sequences_from_name_and_frame_range(sequence_name, frame_start, frame_end) def get_visible_sequences(pg: PSA_PG_export, sequences) -> List[PSA_PG_export_action_list_item]: @@ -246,7 +250,7 @@ def get_visible_sequences(pg: PSA_PG_export, sequences) -> List[PSA_PG_export_ac class PSA_OT_export(Operator, ExportHelper): - bl_idname = 'psa_export.operator' + bl_idname = 'psa.export' bl_label = 'Export' bl_options = {'INTERNAL', 'UNDO'} bl_description = 'Export actions to PSA' @@ -515,7 +519,7 @@ class PSA_OT_export(Operator, ExportHelper): class PSA_OT_export_actions_select_all(Operator): - bl_idname = 'psa_export.sequences_select_all' + bl_idname = 'psa.export_actions_select_all' bl_label = 'Select All' bl_description = 'Select all visible sequences' bl_options = {'INTERNAL'} @@ -552,7 +556,7 @@ class PSA_OT_export_actions_select_all(Operator): class PSA_OT_export_actions_deselect_all(Operator): - bl_idname = 'psa_export.sequences_deselect_all' + bl_idname = 'psa.export_sequences_deselect_all' bl_label = 'Deselect All' bl_description = 'Deselect all visible sequences' bl_options = {'INTERNAL'} @@ -587,7 +591,7 @@ class PSA_OT_export_actions_deselect_all(Operator): class PSA_OT_export_bone_collections_select_all(Operator): - bl_idname = 'psa_export.bone_collections_select_all' + bl_idname = 'psa.export_bone_collections_select_all' bl_label = 'Select All' bl_description = 'Select all bone collections' bl_options = {'INTERNAL'} @@ -607,7 +611,7 @@ class PSA_OT_export_bone_collections_select_all(Operator): class PSA_OT_export_bone_collections_deselect_all(Operator): - bl_idname = 'psa_export.bone_collections_deselect_all' + bl_idname = 'psa.export_bone_collections_deselect_all' bl_label = 'Deselect All' bl_description = 'Deselect all bone collections' bl_options = {'INTERNAL'} diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index 46a12f0..b87575b 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -1,7 +1,7 @@ import re import sys from fnmatch import fnmatch -from typing import List, Optional, Tuple +from typing import List, Optional from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \ StringProperty @@ -52,18 +52,16 @@ class PSA_PG_export_nla_strip_list_item(PropertyGroup): is_selected: BoolProperty(default=True) -def get_sequences_from_name_and_frame_range(name: str, frame_start: int, frame_end: int) -> List[Tuple[str, int, int]]: +def get_sequences_from_name_and_frame_range(name: str, frame_start: int, frame_end: int): reversed_pattern = r'(.+)/(.+)' reversed_match = re.match(reversed_pattern, name) if reversed_match: forward_name = reversed_match.group(1) backwards_name = reversed_match.group(2) - return [ - (forward_name, frame_start, frame_end), - (backwards_name, frame_end, frame_start) - ] + yield forward_name, frame_start, frame_end + yield backwards_name, frame_end, frame_start else: - return [(name, frame_start, frame_end)] + yield name, frame_start, frame_end def nla_track_update_cb(self: 'PSA_PG_export', context: Context) -> None: diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index 9fa4fb4..4f21f9d 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -1,19 +1,27 @@ import os from pathlib import Path -from typing import List +from typing import Iterable from bpy.props import StringProperty, CollectionProperty from bpy.types import Operator, Event, Context, FileHandler, OperatorFileListElement, Object from bpy_extras.io_utils import ImportHelper -from .properties import get_visible_sequences +from .properties import get_visible_sequences, PsaImportMixin from ..config import read_psa_config from ..importer import import_psa, PsaImportOptions from ..reader import PsaReader -class PSA_OT_import_sequences_from_text(Operator): - bl_idname = 'psa_import.sequences_select_from_text' +def psa_import_poll(cls, context: Context): + active_object = context.view_layer.objects.active + if active_object is None or active_object.type != 'ARMATURE': + cls.poll_message_set('The active object must be an armature') + return False + return True + + +class PSA_OT_import_sequences_select_from_text(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'} @@ -49,7 +57,7 @@ class PSA_OT_import_sequences_from_text(Operator): class PSA_OT_import_sequences_select_all(Operator): - bl_idname = 'psa_import.sequences_select_all' + bl_idname = 'psa.import_sequences_select_all' bl_label = 'All' bl_description = 'Select all sequences' bl_options = {'INTERNAL'} @@ -70,7 +78,7 @@ class PSA_OT_import_sequences_select_all(Operator): class PSA_OT_import_sequences_deselect_all(Operator): - bl_idname = 'psa_import.sequences_deselect_all' + bl_idname = 'psa.import_sequences_deselect_all' bl_label = 'None' bl_description = 'Deselect all visible sequences' bl_options = {'INTERNAL'} @@ -113,8 +121,8 @@ def on_psa_file_path_updated(cls, context): load_psa_file(context, cls.filepath) -class PSA_OT_import_multiple(Operator): - bl_idname = 'psa_import.import_multiple' +class PSA_OT_import_drag_and_drop(Operator, PsaImportMixin): + bl_idname = 'psa.import_drag_and_drop' bl_label = 'Import PSA' bl_description = 'Import multiple PSA files' bl_options = {'INTERNAL', 'UNDO'} @@ -122,23 +130,25 @@ class PSA_OT_import_multiple(Operator): directory: StringProperty(subtype='FILE_PATH', options={'SKIP_SAVE', 'HIDDEN'}) files: CollectionProperty(type=OperatorFileListElement, options={'SKIP_SAVE', 'HIDDEN'}) - def execute(self, context): - pg = getattr(context.scene, 'psa_import') warnings = [] + sequence_names = [] for file in self.files: - psa_path = os.path.join(self.directory, file.name) + psa_path = str(os.path.join(self.directory, file.name)) psa_reader = PsaReader(psa_path) - sequence_names = list(psa_reader.sequences.keys()) + file_sequence_names = list(psa_reader.sequences.keys()) + options = psa_import_options_from_property_group(self, file_sequence_names) - result = _import_psa(context, pg, psa_path, sequence_names, context.view_layer.objects.active) - result.warnings.extend(warnings) + sequence_names.extend(file_sequence_names) - if len(result.warnings) > 0: - message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n' + result = _import_psa(context, options, psa_path, context.view_layer.objects.active) + warnings.extend(result.warnings) + + if len(warnings) > 0: + message = f'Imported {len(sequence_names)} action(s) with {len(warnings)} warning(s)\n' self.report({'INFO'}, message) - for warning in result.warnings: + for warning in warnings: self.report({'WARNING'}, warning) self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)') @@ -146,7 +156,7 @@ class PSA_OT_import_multiple(Operator): return {'FINISHED'} def invoke(self, context: Context, event): - # Make sure the selected object is an armature. + # Make sure the selected object is an obj. active_object = context.view_layer.objects.active if active_object is None or active_object.type != 'ARMATURE': self.report({'ERROR_INVALID_CONTEXT'}, 'The active object must be an armature') @@ -158,18 +168,12 @@ class PSA_OT_import_multiple(Operator): def draw(self, context): layout = self.layout - pg = getattr(context.scene, 'psa_import') - draw_psa_import_options_no_panels(layout, pg) + draw_psa_import_options_no_panels(layout, self) -def _import_psa(context, - pg, - filepath: str, - sequence_names: List[str], - armature_object: Object - ): +def psa_import_options_from_property_group(pg: PsaImportMixin, sequence_names: Iterable[str]) -> PsaImportOptions: options = PsaImportOptions() - options.sequence_names = sequence_names + options.sequence_names = list(sequence_names) options.should_use_fake_user = pg.should_use_fake_user options.should_stash = pg.should_stash options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else '' @@ -181,7 +185,14 @@ def _import_psa(context, options.fps_source = pg.fps_source options.fps_custom = pg.fps_custom options.translation_scale = pg.translation_scale + return options + +def _import_psa(context, + options: PsaImportOptions, + filepath: str, + armature_object: Object + ): warnings = [] if options.should_use_config_file: @@ -189,7 +200,7 @@ def _import_psa(context, config_path = Path(filepath).with_suffix('.config') if config_path.exists(): try: - options.psa_config = read_psa_config(sequence_names, str(config_path)) + options.psa_config = read_psa_config(options.sequence_names, str(config_path)) except Exception as e: warnings.append(f'Failed to read PSA config file: {e}') @@ -201,8 +212,8 @@ def _import_psa(context, return result -class PSA_OT_import(Operator, ImportHelper): - bl_idname = 'psa_import.import' +class PSA_OT_import(Operator, ImportHelper, PsaImportMixin): + bl_idname = 'psa.import' bl_label = 'Import' bl_description = 'Import the selected animations into the scene as actions' bl_options = {'INTERNAL', 'UNDO'} @@ -218,29 +229,25 @@ class PSA_OT_import(Operator, ImportHelper): @classmethod def poll(cls, context): - active_object = context.view_layer.objects.active - if active_object is None or active_object.type != 'ARMATURE': - cls.poll_message_set('The active object must be an armature') - return False - return True + return psa_import_poll(cls, context) def execute(self, context): pg = getattr(context.scene, 'psa_import') - sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected] + options = psa_import_options_from_property_group(self, [x.action_name for x in pg.sequence_list if x.is_selected]) - if len(sequence_names) == 0: + if len(options.sequence_names) == 0: self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected') return {'CANCELLED'} - result = _import_psa(context, pg, self.filepath, sequence_names, context.view_layer.objects.active) + result = _import_psa(context, options, self.filepath, context.view_layer.objects.active) if len(result.warnings) > 0: - message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n' + message = f'Imported {len(options.sequence_names)} action(s) with {len(result.warnings)} warning(s)\n' self.report({'WARNING'}, message) for warning in result.warnings: self.report({'WARNING'}, warning) else: - self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)') + self.report({'INFO'}, f'Imported {len(options.sequence_names)} action(s)') return {'FINISHED'} @@ -271,7 +278,7 @@ class PSA_OT_import(Operator, ImportHelper): row2 = col.row(align=True) row2.label(text='Select') - row2.operator(PSA_OT_import_sequences_from_text.bl_idname, text='', icon='TEXT') + row2.operator(PSA_OT_import_sequences_select_from_text.bl_idname, text='', icon='TEXT') row2.operator(PSA_OT_import_sequences_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') row2.operator(PSA_OT_import_sequences_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') @@ -281,13 +288,13 @@ class PSA_OT_import(Operator, ImportHelper): col = sequences_panel.column(heading='') col.use_property_split = True col.use_property_decorate = False - col.prop(pg, 'fps_source') - if pg.fps_source == 'CUSTOM': - col.prop(pg, 'fps_custom') - col.prop(pg, 'should_overwrite') - col.prop(pg, 'should_use_action_name_prefix') - if pg.should_use_action_name_prefix: - col.prop(pg, 'action_name_prefix') + col.prop(self, 'fps_source') + if self.fps_source == 'CUSTOM': + col.prop(self, 'fps_custom') + col.prop(self, 'should_overwrite') + col.prop(self, 'should_use_action_name_prefix') + if self.should_use_action_name_prefix: + col.prop(self, 'action_name_prefix') data_header, data_panel = layout.panel('data_panel_id', default_closed=False) data_header.label(text='Data') @@ -296,14 +303,14 @@ class PSA_OT_import(Operator, ImportHelper): col = data_panel.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') + col.prop(self, 'should_write_keyframes') + col.prop(self, 'should_write_metadata') - if pg.should_write_keyframes: + if self.should_write_keyframes: col = col.column(heading='Keyframes') col.use_property_split = True col.use_property_decorate = False - col.prop(pg, 'should_convert_to_samples') + col.prop(self, 'should_convert_to_samples') advanced_header, advanced_panel = layout.panel('advanced_panel_id', default_closed=True) advanced_header.label(text='Advanced') @@ -312,22 +319,22 @@ class PSA_OT_import(Operator, ImportHelper): col = advanced_panel.column() col.use_property_split = True col.use_property_decorate = False - col.prop(pg, 'bone_mapping_mode') + col.prop(self, 'bone_mapping_mode') col = advanced_panel.column() col.use_property_split = True col.use_property_decorate = False - col.prop(pg, 'translation_scale', text='Translation Scale') + col.prop(self, 'translation_scale', text='Translation Scale') col = advanced_panel.column(heading='Options') col.use_property_split = True col.use_property_decorate = False - col.prop(pg, 'should_use_fake_user') - col.prop(pg, 'should_stash') - col.prop(pg, 'should_use_config_file') + col.prop(self, 'should_use_fake_user') + col.prop(self, 'should_stash') + col.prop(self, 'should_use_config_file') -def draw_psa_import_options_no_panels(layout, pg): +def draw_psa_import_options_no_panels(layout, pg: PsaImportMixin): col = layout.column(heading='Sequences') col.use_property_split = True col.use_property_decorate = False @@ -365,11 +372,11 @@ def draw_psa_import_options_no_panels(layout, pg): col.prop(pg, 'should_use_config_file') -class PSA_FH_import(FileHandler): +class PSA_FH_import(FileHandler): # TODO: rename and add handling for PSA export. bl_idname = 'PSA_FH_import' bl_label = 'File handler for Unreal PSA import' - bl_import_operator = 'psa_import.import_multiple' - bl_export_operator = 'psa_export.export' + bl_import_operator = PSA_OT_import_drag_and_drop.bl_idname + # bl_export_operator = 'psa_export.export' bl_file_extensions = '.psa' @classmethod @@ -380,8 +387,8 @@ class PSA_FH_import(FileHandler): classes = ( PSA_OT_import_sequences_select_all, PSA_OT_import_sequences_deselect_all, - PSA_OT_import_sequences_from_text, + PSA_OT_import_sequences_select_from_text, PSA_OT_import, - PSA_OT_import_multiple, + PSA_OT_import_drag_and_drop, PSA_FH_import, ) diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py index c3d0408..dd3e81b 100644 --- a/io_scene_psk_psa/psa/import_/properties.py +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -23,21 +23,33 @@ class PSA_PG_data(PropertyGroup): sequence_count: IntProperty(default=0) -class PSA_PG_import(PropertyGroup): - psa_error: StringProperty(default='') - psa: PointerProperty(type=PSA_PG_data) - sequence_list: CollectionProperty(type=PSA_PG_import_action_list_item) - sequence_list_index: IntProperty(name='', default=0) +bone_mapping_items = ( + ('EXACT', 'Exact', 'Bone names must match exactly.', 'EXACT', 0), + ('CASE_INSENSITIVE', 'Case Insensitive', 'Bones names must match, ignoring case (e.g., the bone PSA bone \'root\' can be mapped to the armature bone \'Root\')', 'CASE_INSENSITIVE', 1), +) + +fps_source_items = ( + ('SEQUENCE', 'Sequence', 'The sequence frame rate matches the original frame rate', 'ACTION', 0), + ('SCENE', 'Scene', 'The sequence is resampled to the frame rate of the scene', 'SCENE_DATA', 1), + ('CUSTOM', 'Custom', 'The sequence is resampled to a custom frame rate', 2), +) + +compression_ratio_source_items = ( + ('ACTION', 'Action', 'The compression ratio is sourced from the action metadata', 'ACTION', 0), + ('CUSTOM', 'Custom', 'The compression ratio is set to a custom value', 1), +) + +class PsaImportMixin: 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=empty_set) should_use_config_file: BoolProperty(default=True, name='Use Config File', - description='Use the .config file that is sometimes generated when the PSA ' - 'file is exported from UEViewer. This file contains ' - 'options that can be used to filter out certain bones tracks ' - 'from the imported actions', - options=empty_set) + description='Use the .config file that is sometimes generated when the PSA ' + 'file is exported from UEViewer. This file contains ' + 'options that can be used to filter out certain bones tracks ' + 'from the imported actions', + options=empty_set) should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track', options=empty_set) @@ -56,7 +68,7 @@ class PSA_PG_import(PropertyGroup): sequence_use_filter_invert: BoolProperty(default=False, options=empty_set) sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression', description='Filter using regular expressions', options=empty_set) - select_text: PointerProperty(type=Text) + should_convert_to_samples: BoolProperty( default=False, name='Convert to Samples', @@ -67,18 +79,10 @@ class PSA_PG_import(PropertyGroup): name='Bone Mapping', options=empty_set, description='The method by which bones from the incoming PSA file are mapped to the armature', - items=( - ('EXACT', 'Exact', 'Bone names must match exactly.', 'EXACT', 0), - ('CASE_INSENSITIVE', 'Case Insensitive', 'Bones names must match, ignoring case (e.g., the bone PSA bone ' - '\'root\' can be mapped to the armature bone \'Root\')', 'CASE_INSENSITIVE', 1), - ), + items=bone_mapping_items, default='CASE_INSENSITIVE' ) - fps_source: EnumProperty(name='FPS Source', items=( - ('SEQUENCE', 'Sequence', 'The sequence frame rate matches the original frame rate', 'ACTION', 0), - ('SCENE', 'Scene', 'The sequence is resampled to the frame rate of the scene', 'SCENE_DATA', 1), - ('CUSTOM', 'Custom', 'The sequence is resampled to a custom frame rate', 2), - )) + fps_source: EnumProperty(name='FPS Source', items=fps_source_items) fps_custom: FloatProperty( default=30.0, name='Custom FPS', @@ -89,10 +93,7 @@ class PSA_PG_import(PropertyGroup): soft_max=60.0, step=100, ) - compression_ratio_source: EnumProperty(name='Compression Ratio Source', items=( - ('ACTION', 'Action', 'The compression ratio is sourced from the action metadata', 'ACTION', 0), - ('CUSTOM', 'Custom', 'The compression ratio is set to a custom value', 1), - )) + compression_ratio_source: EnumProperty(name='Compression Ratio Source', items=compression_ratio_source_items) compression_ratio_custom: FloatProperty( default=1.0, name='Custom Compression Ratio', @@ -110,6 +111,22 @@ class PSA_PG_import(PropertyGroup): ) +# This property group lives "globally" in the scene, since Operators cannot have PointerProperty or CollectionProperty +# properties. +class PSA_PG_import(PropertyGroup): + psa_error: StringProperty(default='') + psa: PointerProperty(type=PSA_PG_data) + sequence_list: CollectionProperty(type=PSA_PG_import_action_list_item) + sequence_list_index: IntProperty(name='', default=0) + sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) + sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected', + description='Only show selected sequences') + sequence_use_filter_invert: BoolProperty(default=False, options=empty_set) + sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression', + description='Filter using regular expressions', options=empty_set) + select_text: PointerProperty(type=Text) + + def filter_sequences(pg: PSA_PG_import, sequences) -> List[int]: bitflag_filter_item = 1 << 30 flt_flags = [bitflag_filter_item] * len(sequences) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 46f2140..fd3779c 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -12,21 +12,36 @@ from .reader import PsaReader class PsaImportOptions(object): - def __init__(self): - self.should_use_fake_user = False - self.should_stash = False - self.sequence_names = [] - self.should_overwrite = False - self.should_write_keyframes = True - self.should_write_metadata = True - self.action_name_prefix = '' - self.should_convert_to_samples = False - self.bone_mapping_mode = 'CASE_INSENSITIVE' - self.fps_source = 'SEQUENCE' - self.fps_custom: float = 30.0 - self.translation_scale: float = 1.0 - self.should_use_config_file = True - self.psa_config: PsaConfig = PsaConfig() + def __init__(self, + action_name_prefix: str = '', + bone_mapping_mode: str = 'CASE_INSENSITIVE', + fps_custom: float = 30.0, + fps_source: str = 'SEQUENCE', + psa_config: PsaConfig = PsaConfig(), + sequence_names: List[str] = None, + should_convert_to_samples: bool = False, + should_overwrite: bool = False, + should_stash: bool = False, + should_use_config_file: bool = True, + should_use_fake_user: bool = False, + should_write_keyframes: bool = True, + should_write_metadata: bool = True, + translation_scale: float = 1.0 + ): + self.action_name_prefix = action_name_prefix + self.bone_mapping_mode = bone_mapping_mode + self.fps_custom = fps_custom + self.fps_source = fps_source + self.psa_config = psa_config + self.sequence_names = sequence_names if sequence_names is not None else [] + self.should_convert_to_samples = should_convert_to_samples + self.should_overwrite = should_overwrite + self.should_stash = should_stash + self.should_use_config_file = should_use_config_file + self.should_use_fake_user = should_use_fake_user + self.should_write_keyframes = should_write_keyframes + self.should_write_metadata = should_write_metadata + self.translation_scale = translation_scale class ImportBone(object): diff --git a/io_scene_psk_psa/psk/export/operators.py b/io_scene_psk_psa/psk/export/operators.py index 9d7881b..83e9426 100644 --- a/io_scene_psk_psa/psk/export/operators.py +++ b/io_scene_psk_psa/psk/export/operators.py @@ -5,7 +5,7 @@ from bpy.props import StringProperty from bpy.types import Operator, Context, Object, Collection, SpaceProperties, Depsgraph, Material from bpy_extras.io_utils import ExportHelper -from .properties import add_psk_export_properties +from .properties import PskExportMixin from ..builder import build_psk, PskBuildOptions, get_psk_input_objects_for_context, \ get_psk_input_objects_for_collection from ..writer import write_psk @@ -14,16 +14,16 @@ from ...shared.ui import draw_bone_filter_mode def get_materials_for_mesh_objects(depsgraph: Depsgraph, mesh_objects: Iterable[Object]): - materials = [] + yielded_materials = set() for mesh_object in mesh_objects: evaluated_mesh_object = mesh_object.evaluated_get(depsgraph) for i, material_slot in enumerate(evaluated_mesh_object.material_slots): material = material_slot.material if material is None: raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')') - if material not in materials: - materials.append(material) - return materials + if material not in yielded_materials: + yielded_materials.add(material) + yield material def populate_material_name_list(depsgraph: Depsgraph, mesh_objects, material_list): @@ -60,7 +60,7 @@ def get_collection_export_operator_from_context(context: Context) -> Optional[ob class PSK_OT_populate_bone_collection_list(Operator): - bl_idname = 'psk_export.populate_bone_collection_list' + bl_idname = 'psk.export_populate_bone_collection_list' bl_label = 'Populate Bone Collection List' bl_description = 'Populate the bone collection list from the armature that will be used in this collection export' bl_options = {'INTERNAL'} @@ -79,7 +79,7 @@ class PSK_OT_populate_bone_collection_list(Operator): class PSK_OT_populate_material_name_list(Operator): - bl_idname = 'psk_export.populate_material_name_list' + bl_idname = 'psk.export_populate_material_name_list' bl_label = 'Populate Material Name List' bl_description = 'Populate the material name list from the objects that will be used in this export' bl_options = {'INTERNAL'} @@ -100,7 +100,7 @@ class PSK_OT_populate_material_name_list(Operator): class PSK_OT_material_list_move_up(Operator): - bl_idname = 'psk_export.material_list_item_move_up' + bl_idname = 'psk.export_material_list_item_move_up' bl_label = 'Move Up' bl_options = {'INTERNAL'} bl_description = 'Move the selected material up one slot' @@ -118,7 +118,7 @@ class PSK_OT_material_list_move_up(Operator): class PSK_OT_material_list_move_down(Operator): - bl_idname = 'psk_export.material_list_item_move_down' + bl_idname = 'psk.export_material_list_item_move_down' bl_label = 'Move Down' bl_options = {'INTERNAL'} bl_description = 'Move the selected material down one slot' @@ -136,7 +136,7 @@ class PSK_OT_material_list_move_down(Operator): class PSK_OT_material_list_name_move_up(Operator): - bl_idname = 'psk_export.material_name_list_item_move_up' + bl_idname = 'psk.export_material_name_list_item_move_up' bl_label = 'Move Up' bl_options = {'INTERNAL'} bl_description = 'Move the selected material name up one slot' @@ -159,7 +159,7 @@ class PSK_OT_material_list_name_move_up(Operator): class PSK_OT_material_list_name_move_down(Operator): - bl_idname = 'psk_export.material_name_list_item_move_down' + bl_idname = 'psk.export_material_name_list_item_move_down' bl_label = 'Move Down' bl_options = {'INTERNAL'} bl_description = 'Move the selected material name down one slot' @@ -218,8 +218,8 @@ def get_psk_build_options_from_property_group(mesh_objects: Iterable[Object], p return options -class PSK_OT_export_collection(Operator, ExportHelper): - bl_idname = 'export.psk_collection' +class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin): + bl_idname = 'psk.export_collection' bl_label = 'Export' bl_options = {'INTERNAL'} filename_ext = '.psk' @@ -312,12 +312,8 @@ class PSK_OT_export_collection(Operator, ExportHelper): -add_psk_export_properties(PSK_OT_export_collection) - - - class PSK_OT_export(Operator, ExportHelper): - bl_idname = 'export.psk' + bl_idname = 'psk.export' bl_label = 'Export' bl_options = {'INTERNAL', 'UNDO'} bl_description = 'Export mesh and armature to PSK' diff --git a/io_scene_psk_psa/psk/export/properties.py b/io_scene_psk_psa/psk/export/properties.py index 189b82a..71ba535 100644 --- a/io_scene_psk_psa/psk/export/properties.py +++ b/io_scene_psk_psa/psk/export/properties.py @@ -60,65 +60,58 @@ def up_axis_update(self, _context): self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X') - -# In order to share the same properties between the PSA and PSK export properties, we need to define the properties in a -# separate function and then apply them to the classes. This is because the collection exporter cannot have -# PointerProperties, so we must effectively duplicate the storage of the properties. -def add_psk_export_properties(cls): - cls.__annotations__['object_eval_state'] = EnumProperty( - items=object_eval_state_items, - name='Object Evaluation State', - default='EVALUATED' - ) - cls.__annotations__['should_exclude_hidden_meshes'] = BoolProperty( - default=False, - name='Visible Only', - description='Export only visible meshes' - ) - cls.__annotations__['scale'] = FloatProperty( - name='Scale', - default=1.0, - description='Scale factor to apply to the exported mesh and armature', - min=0.0001, - soft_max=100.0 - ) - cls.__annotations__['export_space'] = EnumProperty( - name='Export Space', - description='Space to export the mesh in', - items=export_space_items, - default='WORLD' - ) - cls.__annotations__['bone_filter_mode'] = EnumProperty( - name='Bone Filter', - options=empty_set, - description='', - items=bone_filter_mode_items, - ) - cls.__annotations__['bone_collection_list'] = CollectionProperty(type=PSX_PG_bone_collection_list_item) - cls.__annotations__['bone_collection_list_index'] = IntProperty(default=0) - cls.__annotations__['forward_axis'] = EnumProperty( - name='Forward', - items=forward_items, - default='X', - update=forward_axis_update - ) - cls.__annotations__['up_axis'] = EnumProperty( - name='Up', - items=up_items, - default='Z', - update=up_axis_update - ) - cls.__annotations__['material_name_list'] = CollectionProperty(type=PSK_PG_material_name_list_item) - cls.__annotations__['material_name_list_index'] = IntProperty(default=0) +class PskExportMixin: + object_eval_state: EnumProperty( + items=object_eval_state_items, + name='Object Evaluation State', + default='EVALUATED' + ) + should_exclude_hidden_meshes: BoolProperty( + default=False, + name='Visible Only', + description='Export only visible meshes' + ) + scale: FloatProperty( + name='Scale', + default=1.0, + description='Scale factor to apply to the exported mesh and armature', + min=0.0001, + soft_max=100.0 + ) + export_space: EnumProperty( + name='Export Space', + description='Space to export the mesh in', + items=export_space_items, + default='WORLD' + ) + bone_filter_mode: EnumProperty( + name='Bone Filter', + options=empty_set, + description='', + items=bone_filter_mode_items, + ) + bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item) + bone_collection_list_index: IntProperty(default=0) + forward_axis: EnumProperty( + name='Forward', + items=forward_items, + default='X', + update=forward_axis_update + ) + up_axis: EnumProperty( + name='Up', + items=up_items, + default='Z', + update=up_axis_update + ) + material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item) + material_name_list_index: IntProperty(default=0) -class PSK_PG_export(PropertyGroup): +class PSK_PG_export(PropertyGroup, PskExportMixin): pass -add_psk_export_properties(PSK_PG_export) - - classes = ( PSK_PG_material_list_item, PSK_PG_material_name_list_item, diff --git a/io_scene_psk_psa/psk/import_/operators.py b/io_scene_psk_psa/psk/import_/operators.py index 9b1597c..04ba965 100644 --- a/io_scene_psk_psa/psk/import_/operators.py +++ b/io_scene_psk_psa/psk/import_/operators.py @@ -1,11 +1,11 @@ import os -import sys -from bpy.props import StringProperty, BoolProperty, EnumProperty, FloatProperty +from bpy.props import StringProperty from bpy.types import Operator, FileHandler, Context from bpy_extras.io_utils import ImportHelper from ..importer import PskImportOptions, import_psk +from ..properties import PskImportMixin from ..reader import read_psk empty_set = set() @@ -23,8 +23,8 @@ class PSK_FH_import(FileHandler): return context.area and context.area.type == 'VIEW_3D' -class PSK_OT_import(Operator, ImportHelper): - bl_idname = 'import_scene.psk' +class PSK_OT_import(Operator, ImportHelper, PskImportMixin): + bl_idname = 'psk.import' bl_label = 'Import' bl_options = {'INTERNAL', 'UNDO', 'PRESET'} bl_description = 'Import a PSK file' @@ -36,79 +36,6 @@ class PSK_OT_import(Operator, ImportHelper): maxlen=1024, default='') - should_import_vertex_colors: BoolProperty( - default=True, - options=empty_set, - name='Import Vertex Colors', - description='Import vertex colors, if available' - ) - vertex_color_space: EnumProperty( - name='Vertex Color Space', - options=empty_set, - description='The source vertex color space', - default='SRGBA', - items=( - ('LINEAR', 'Linear', ''), - ('SRGBA', 'sRGBA', ''), - ) - ) - should_import_vertex_normals: BoolProperty( - default=True, - name='Import Vertex Normals', - options=empty_set, - description='Import vertex normals, if available' - ) - should_import_extra_uvs: BoolProperty( - default=True, - name='Import Extra UVs', - options=empty_set, - description='Import extra UV maps, if available' - ) - should_import_mesh: BoolProperty( - default=True, - name='Import Mesh', - options=empty_set, - description='Import mesh' - ) - should_import_materials: BoolProperty( - default=True, - name='Import Materials', - options=empty_set, - ) - should_import_skeleton: BoolProperty( - default=True, - name='Import Skeleton', - options=empty_set, - description='Import skeleton' - ) - bone_length: FloatProperty( - default=1.0, - min=sys.float_info.epsilon, - step=100, - soft_min=1.0, - name='Bone Length', - options=empty_set, - subtype='DISTANCE', - description='Length of the bones' - ) - should_import_shape_keys: BoolProperty( - default=True, - name='Import Shape Keys', - options=empty_set, - description='Import shape keys, if available' - ) - scale: FloatProperty( - name='Scale', - default=1.0, - soft_min=0.0, - ) - bdk_repository_id: StringProperty( - name='BDK Repository ID', - default='', - options=empty_set, - description='The ID of the BDK repository to use for loading materials' - ) - def execute(self, context): psk = read_psk(self.filepath) @@ -152,7 +79,6 @@ class PSK_OT_import(Operator, ImportHelper): col.use_property_split = True col.use_property_decorate = False col.prop(self, 'scale') - col.prop(self, 'export_space') mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False) mesh_header.prop(self, 'should_import_mesh') diff --git a/io_scene_psk_psa/psk/properties.py b/io_scene_psk_psa/psk/properties.py index 917f390..faa96e1 100644 --- a/io_scene_psk_psa/psk/properties.py +++ b/io_scene_psk_psa/psk/properties.py @@ -1,4 +1,6 @@ -from bpy.props import EnumProperty +import sys + +from bpy.props import EnumProperty, BoolProperty, FloatProperty, StringProperty from bpy.types import PropertyGroup mesh_triangle_types_items = ( @@ -42,6 +44,83 @@ def poly_flags_to_triangle_type_and_bit_flags(poly_flags: int) -> (str, set[str] triangle_bit_flags = {item[0] for item in mesh_triangle_bit_flags_items if item[3] & poly_flags} return triangle_type, triangle_bit_flags +empty_set = set() + + +class PskImportMixin: + should_import_vertex_colors: BoolProperty( + default=True, + options=empty_set, + name='Import Vertex Colors', + description='Import vertex colors, if available' + ) + vertex_color_space: EnumProperty( + name='Vertex Color Space', + options=empty_set, + description='The source vertex color space', + default='SRGBA', + items=( + ('LINEAR', 'Linear', ''), + ('SRGBA', 'sRGBA', ''), + ) + ) + should_import_vertex_normals: BoolProperty( + default=True, + name='Import Vertex Normals', + options=empty_set, + description='Import vertex normals, if available' + ) + should_import_extra_uvs: BoolProperty( + default=True, + name='Import Extra UVs', + options=empty_set, + description='Import extra UV maps, if available' + ) + should_import_mesh: BoolProperty( + default=True, + name='Import Mesh', + options=empty_set, + description='Import mesh' + ) + should_import_materials: BoolProperty( + default=True, + name='Import Materials', + options=empty_set, + ) + should_import_skeleton: BoolProperty( + default=True, + name='Import Skeleton', + options=empty_set, + description='Import skeleton' + ) + bone_length: FloatProperty( + default=1.0, + min=sys.float_info.epsilon, + step=100, + soft_min=1.0, + name='Bone Length', + options=empty_set, + subtype='DISTANCE', + description='Length of the bones' + ) + should_import_shape_keys: BoolProperty( + default=True, + name='Import Shape Keys', + options=empty_set, + description='Import shape keys, if available' + ) + scale: FloatProperty( + name='Scale', + default=1.0, + soft_min=0.0, + ) + bdk_repository_id: StringProperty( + name='BDK Repository ID', + default='', + options=empty_set, + description='The ID of the BDK repository to use for loading materials' + ) + classes = ( PSX_PG_material,