From 782c210f04238b10bdcca2d80c468afeac7446ef Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 29 Jul 2023 16:00:53 -0700 Subject: [PATCH] Major refactoring pass on the PSA file structure & naming --- io_scene_psk_psa/__init__.py | 46 +- io_scene_psk_psa/bdk.py | 39 -- io_scene_psk_psa/psa/builder.py | 19 +- io_scene_psk_psa/psa/export/__init__.py | 0 .../psa/{exporter.py => export/operators.py} | 530 ++++++------------ io_scene_psk_psa/psa/export/properties.py | 157 ++++++ io_scene_psk_psa/psa/export/ui.py | 53 ++ io_scene_psk_psa/psa/import_/__init__.py | 0 io_scene_psk_psa/psa/import_/operators.py | 255 +++++++++ io_scene_psk_psa/psa/import_/properties.py | 119 ++++ io_scene_psk_psa/psa/import_/ui.py | 45 ++ io_scene_psk_psa/psa/importer.py | 405 +------------ io_scene_psk_psa/psa/writer.py | 25 + io_scene_psk_psa/psk/exporter.py | 6 +- io_scene_psk_psa/types.py | 8 +- 15 files changed, 871 insertions(+), 836 deletions(-) delete mode 100644 io_scene_psk_psa/bdk.py create mode 100644 io_scene_psk_psa/psa/export/__init__.py rename io_scene_psk_psa/psa/{exporter.py => export/operators.py} (64%) create mode 100644 io_scene_psk_psa/psa/export/properties.py create mode 100644 io_scene_psk_psa/psa/export/ui.py create mode 100644 io_scene_psk_psa/psa/import_/__init__.py create mode 100644 io_scene_psk_psa/psa/import_/operators.py create mode 100644 io_scene_psk_psa/psa/import_/properties.py create mode 100644 io_scene_psk_psa/psa/import_/ui.py create mode 100644 io_scene_psk_psa/psa/writer.py diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 09bc501..d0bd736 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, Yurii Ti", - "version": (5, 0, 0), + "version": (5, 0, 1), "blender": (3, 4, 0), "description": "PSK/PSA Import/Export (.psk/.psa)", "warning": "", @@ -16,16 +16,23 @@ if 'bpy' in locals(): importlib.reload(psx_data) importlib.reload(psx_helpers) importlib.reload(psx_types) + importlib.reload(psk_data) importlib.reload(psk_builder) importlib.reload(psk_exporter) importlib.reload(psk_importer) importlib.reload(psk_reader) + importlib.reload(psa_data) - importlib.reload(psa_builder) - importlib.reload(psa_exporter) importlib.reload(psa_reader) - importlib.reload(psa_importer) + importlib.reload(psa_writer) + importlib.reload(psa_builder) + importlib.reload(psa_export_properties) + importlib.reload(psa_export_operators) + importlib.reload(psa_export_ui) + importlib.reload(psa_import_properties) + importlib.reload(psa_import_operators) + importlib.reload(psa_import_ui) else: # if i remove this line, it can be enabled just fine from . import data as psx_data @@ -36,20 +43,31 @@ else: from .psk import exporter as psk_exporter from .psk import reader as psk_reader from .psk import importer as psk_importer + from .psa import data as psa_data - from .psa import builder as psa_builder - from .psa import exporter as psa_exporter from .psa import reader as psa_reader + from .psa import writer as psa_writer + from .psa import builder as psa_builder from .psa import importer as psa_importer + from .psa.export import properties as psa_export_properties + from .psa.export import operators as psa_export_operators + from .psa.export import ui as psa_export_ui + from .psa.import_ import properties as psa_import_properties + from .psa.import_ import operators as psa_import_operators + from .psa.import_ import ui as psa_import_ui import bpy from bpy.props import PointerProperty classes = psx_types.classes +\ - psk_importer.classes +\ - psk_exporter.classes +\ - psa_exporter.classes +\ - psa_importer.classes + psk_importer.classes +\ + psk_exporter.classes +\ + psa_export_properties.classes +\ + psa_export_operators.classes +\ + psa_export_ui.classes + \ + psa_import_properties.classes +\ + psa_import_operators.classes +\ + psa_import_ui.classes def psk_export_menu_func(self, context): @@ -61,11 +79,11 @@ def psk_import_menu_func(self, context): def psa_export_menu_func(self, context): - self.layout.operator(psa_exporter.PsaExportOperator.bl_idname, text='Unreal PSA (.psa)') + self.layout.operator(psa_export_operators.PSA_OT_export.bl_idname, text='Unreal PSA (.psa)') def psa_import_menu_func(self, context): - self.layout.operator(psa_importer.PsaImportOperator.bl_idname, text='Unreal PSA (.psa)') + self.layout.operator(psa_import_operators.PSA_OT_import.bl_idname, text='Unreal PSA (.psa)') def register(): @@ -75,8 +93,8 @@ def register(): bpy.types.TOPBAR_MT_file_import.append(psk_import_menu_func) bpy.types.TOPBAR_MT_file_export.append(psa_export_menu_func) bpy.types.TOPBAR_MT_file_import.append(psa_import_menu_func) - bpy.types.Scene.psa_import = PointerProperty(type=psa_importer.PsaImportPropertyGroup) - bpy.types.Scene.psa_export = PointerProperty(type=psa_exporter.PsaExportPropertyGroup) + bpy.types.Scene.psa_import = PointerProperty(type=psa_import_properties.PSA_PG_import) + bpy.types.Scene.psa_export = PointerProperty(type=psa_export_properties.PSA_PG_export) bpy.types.Scene.psk_export = PointerProperty(type=psk_exporter.PskExportPropertyGroup) bpy.types.Action.psa_export = PointerProperty(type=psx_types.PSX_PG_ActionExportPropertyGroup) diff --git a/io_scene_psk_psa/bdk.py b/io_scene_psk_psa/bdk.py deleted file mode 100644 index ca413ba..0000000 --- a/io_scene_psk_psa/bdk.py +++ /dev/null @@ -1,39 +0,0 @@ -import re -from typing import Optional - - -class UReference: - type_name: str - package_name: str - group_name: Optional[str] - object_name: str - - def __init__(self, type_name: str, package_name: str, object_name: str, group_name: Optional[str] = None): - self.type_name = type_name - self.package_name = package_name - self.object_name = object_name - self.group_name = group_name - - @staticmethod - def from_string(string: str) -> Optional['UReference']: - if string == 'None': - return None - pattern = r'(\w+)\'([\w\.\d\-\_]+)\'' - match = re.match(pattern, string) - if match is None: - print(f'BAD REFERENCE STRING: {string}') - return None - type_name = match.group(1) - object_name = match.group(2) - pattern = r'([\w\d\-\_]+)' - values = re.findall(pattern, object_name) - package_name = values[0] - object_name = values[-1] - return UReference(type_name, package_name, object_name, group_name=None) - - def __repr__(self): - s = f'{self.type_name}\'{self.package_name}' - if self.group_name: - s += f'.{self.group_name}' - s += f'.{self.object_name}' - return s diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 903f57e..eed8ce9 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -6,7 +6,7 @@ from .data import * from ..helpers import * -class PsaExportSequence: +class PsaBuildSequence: class NlaState: def __init__(self): self.action: Optional[Action] = None @@ -15,7 +15,7 @@ class PsaExportSequence: def __init__(self): self.name: str = '' - self.nla_state: PsaExportSequence.NlaState = PsaExportSequence.NlaState() + self.nla_state: PsaBuildSequence.NlaState = PsaBuildSequence.NlaState() self.compression_ratio: float = 1.0 self.key_quota: int = 0 self.fps: float = 30.0 @@ -24,7 +24,7 @@ class PsaExportSequence: class PsaBuildOptions: def __init__(self): self.animation_data: Optional[AnimData] = None - self.sequences: List[PsaExportSequence] = [] + self.sequences: List[PsaBuildSequence] = [] self.bone_filter_mode: str = 'ALL' self.bone_group_indices: List[int] = [] self.should_ignore_bone_name_restrictions: bool = False @@ -33,7 +33,7 @@ class PsaBuildOptions: self.root_motion: bool = False -def get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions): +def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions): if pose_bone.parent is not None: pose_bone_matrix = pose_bone.matrix pose_bone_parent_matrix = pose_bone.parent.matrix @@ -138,7 +138,9 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: # We actually alter the timeline frame and simply record the resultant pose bone matrices. frame_start_index = 0 - for export_sequence in options.sequences: + context.window_manager.progress_begin(0, len(options.sequences)) + + for export_sequence_index, export_sequence in enumerate(options.sequences): # Link the action to the animation data and update view layer. options.animation_data.action = export_sequence.nla_state.action context.view_layer.update() @@ -169,6 +171,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: psa_sequence.fps = frame_count / sequence_duration psa_sequence.bone_count = len(pose_bones) psa_sequence.track_time = frame_count + psa_sequence.key_reduction = 1.0 frame = float(frame_start) @@ -176,7 +179,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: context.scene.frame_set(frame=int(frame), subframe=frame % 1.0) for pose_bone in pose_bones: - location, rotation = get_pose_bone_location_and_rotation(pose_bone, armature_object, options) + location, rotation = _get_pose_bone_location_and_rotation(pose_bone, armature_object, options) key = Psa.Key() key.location.x = location.x @@ -195,8 +198,12 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: psa.sequences[export_sequence.name] = psa_sequence + context.window_manager.progress_update(export_sequence_index) + # Restore the previous action & frame. options.animation_data.action = saved_action context.scene.frame_set(saved_frame_current) + context.window_manager.progress_end() + return psa diff --git a/io_scene_psk_psa/psa/export/__init__.py b/io_scene_psk_psa/psa/export/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/export/operators.py similarity index 64% rename from io_scene_psk_psa/psa/exporter.py rename to io_scene_psk_psa/psa/export/operators.py index f114515..7d0c260 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -1,220 +1,17 @@ -import fnmatch -import sys -from typing import Type, Dict +import re +from collections import Counter +from typing import List, Iterable, Dict, Tuple import bpy -from bpy.props import BoolProperty, CollectionProperty, EnumProperty, FloatProperty, IntProperty, PointerProperty, \ - StringProperty -from bpy.types import Action, Operator, PropertyGroup, UIList, Context, Armature, TimelineMarker +from bpy.props import StringProperty +from bpy.types import Context, Armature, Action, Object, AnimData, TimelineMarker from bpy_extras.io_utils import ExportHelper +from bpy_types import Operator -from .builder import PsaBuildOptions, PsaExportSequence, build_psa -from .data import * -from ..helpers import * -from ..types import BoneGroupListItem - - -def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None): - section = Section() - section.name = name - if data_type is not None and data is not None: - section.data_size = sizeof(data_type) - section.data_count = len(data) - fp.write(section) - if data is not None: - for datum in data: - fp.write(datum) - - -def export_psa(psa: Psa, path: str): - with open(path, 'wb') as fp: - write_section(fp, b'ANIMHEAD') - write_section(fp, b'BONENAMES', Psa.Bone, psa.bones) - write_section(fp, b'ANIMINFO', Psa.Sequence, list(psa.sequences.values())) - write_section(fp, b'ANIMKEYS', Psa.Key, psa.keys) - - -class PsaExportActionListItem(PropertyGroup): - action: PointerProperty(type=Action) - name: StringProperty() - is_selected: BoolProperty(default=False) - frame_start: IntProperty(options={'HIDDEN'}) - frame_end: IntProperty(options={'HIDDEN'}) - is_pose_marker: BoolProperty(options={'HIDDEN'}) - - -class PsaExportTimelineMarkerListItem(PropertyGroup): - marker_index: IntProperty() - name: StringProperty() - is_selected: BoolProperty(default=True) - frame_start: IntProperty(options={'HIDDEN'}) - frame_end: IntProperty(options={'HIDDEN'}) - - -def psa_export_property_group_animation_data_override_poll(_context, obj): - return obj.animation_data is not None - - -empty_set = set() - - -class PsaExportPropertyGroup(PropertyGroup): - root_motion: BoolProperty( - name='Root Motion', - options=empty_set, - default=False, - description='When enabled, the root bone will be transformed as it appears in the scene.\n\n' - 'You might want to disable this if you are exporting an animation for an armature that is ' - 'attached to another object, such as a weapon or a shield', - ) - should_override_animation_data: BoolProperty( - name='Override Animation Data', - options=empty_set, - default=False, - description='Use the animation data from a different object instead of the selected object' - ) - animation_data_override: PointerProperty( - type=bpy.types.Object, - poll=psa_export_property_group_animation_data_override_poll - ) - sequence_source: EnumProperty( - name='Source', - options=empty_set, - 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), - ) - ) - fps_source: EnumProperty( - name='FPS Source', - options=empty_set, - description='', - items=( - ('SCENE', 'Scene', '', 'SCENE_DATA', 0), - ('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=empty_set, step=100, - soft_max=60.0) - 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', - options=empty_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_group_list: CollectionProperty(type=BoneGroupListItem) - bone_group_list_index: IntProperty(default=0, name='', description='') - should_ignore_bone_name_restrictions: BoolProperty( - default=False, - name='Ignore Bone Name Restrictions', - description='Bone names restrictions will be ignored. Note that bone names without properly formatted names ' - 'cannot be referenced in scripts' - ) - sequence_name_prefix: StringProperty(name='Prefix', options=empty_set) - sequence_name_suffix: StringProperty(name='Suffix', options=empty_set) - sequence_filter_name: StringProperty( - default='', - name='Filter by Name', - options={'TEXTEDIT_UPDATE'}, - description='Only show items matching this name (use \'*\' as wildcard)') - sequence_use_filter_invert: BoolProperty( - default=False, - name='Invert', - options=empty_set, - description='Invert filtering (show hidden items, and vice versa)') - sequence_filter_asset: BoolProperty( - default=False, - name='Show assets', - options=empty_set, - description='Show actions that belong to an asset library') - sequence_filter_pose_marker: BoolProperty( - default=False, - name='Show pose markers', - options=empty_set) - sequence_use_filter_sort_reverse: BoolProperty(default=True, options=empty_set) - - -def is_bone_filter_mode_item_available(context, identifier): - if identifier == 'BONE_GROUPS': - obj = context.active_object - if not obj.pose or not obj.pose.bone_groups: - return False - return True - - -def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context: Context, marker_names: List[str]) -> 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 marker_names: - marker = context.scene.timeline_markers[marker_name] - frame_start = 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_end = 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_end = sorted_timeline_markers[next_marker_index].frame - nla_strips = get_nla_strips_in_timeframe(animation_data, marker.frame, frame_end) - if len(nla_strips) > 0: - frame_end = min(frame_end, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips))) - frame_start = max(frame_start, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips))) - else: - # No strips in between this marker and the next, just export this as a one-frame animation. - frame_end = frame_start - 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 animation_data.nla_tracks: - if nla_track.mute: - continue - for strip in nla_track.strips: - frame_end = max(frame_end, strip.frame_end) - - if frame_start > frame_end: - continue - - sequence_frame_ranges[marker_name] = int(frame_start), int(frame_end) - - return sequence_frame_ranges - - -def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float: - if fps_source == 'SCENE': - return context.scene.render.fps - elif fps_source == 'CUSTOM': - return fps_custom - elif fps_source == 'ACTION_METADATA': - # Get the minimum value of action metadata FPS values. - 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 - else: - raise RuntimeError(f'Invalid FPS source "{fps_source}"') +from io_scene_psk_psa.helpers import populate_bone_group_list, get_nla_strips_in_timeframe +from io_scene_psk_psa.psa.builder import build_psa, PsaBuildSequence, PsaBuildOptions +from io_scene_psk_psa.psa.export.properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences +from io_scene_psk_psa.psa.writer import write_psa def is_action_for_armature(armature: Armature, action: Action): @@ -231,57 +28,6 @@ def is_action_for_armature(armature: Armature, action: Action): return False -def get_animation_data_object(context: Context) -> Object: - pg: PsaExportPropertyGroup = getattr(context.scene, 'psa_export') - - active_object = context.view_layer.objects.active - - if active_object.type != 'ARMATURE': - raise RuntimeError('Selected object must be an Armature') - - if pg.should_override_animation_data: - animation_data_object = pg.animation_data_override - else: - animation_data_object = active_object - - return animation_data_object - - -def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]: - frame_start = int(action.frame_range[0]) - frame_end = int(action.frame_range[1]) - reversed_pattern = r'(.+)/(.+)' - reversed_match = re.match(reversed_pattern, action.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) - ] - else: - return [(action.name, frame_start, frame_end)] - - -def get_sequences_from_action_pose_marker(action: Action, pose_markers: List[TimelineMarker], pose_marker: TimelineMarker, pose_marker_index: int) -> List[Tuple[str, int, int]]: - frame_start = pose_marker.frame - if pose_marker_index + 1 < len(pose_markers): - frame_end = pose_markers[pose_marker_index + 1].frame - else: - frame_end = int(action.frame_range[1]) - reversed_pattern = r'(.+)/(.+)' - reversed_match = re.match(reversed_pattern, pose_marker.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) - ] - else: - return [(pose_marker.name, frame_start, frame_end)] - - def update_actions_and_timeline_markers(context: Context, armature: Armature): pg = getattr(context.scene, 'psa_export') @@ -342,7 +88,136 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature): item.frame_end = frame_end -class PsaExportOperator(Operator, ExportHelper): +def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float: + if fps_source == 'SCENE': + return context.scene.render.fps + elif fps_source == 'CUSTOM': + return fps_custom + elif fps_source == 'ACTION_METADATA': + # Get the minimum value of action metadata FPS values. + 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 + else: + raise RuntimeError(f'Invalid FPS source "{fps_source}"') + + +def get_animation_data_object(context: Context) -> Object: + pg: PSA_PG_export = getattr(context.scene, 'psa_export') + + active_object = context.view_layer.objects.active + + if active_object.type != 'ARMATURE': + raise RuntimeError('Selected object must be an Armature') + + if pg.should_override_animation_data: + animation_data_object = pg.animation_data_override + else: + animation_data_object = active_object + + return animation_data_object + + +def is_bone_filter_mode_item_available(context, identifier): + if identifier == 'BONE_GROUPS': + obj = context.active_object + if not obj.pose or not obj.pose.bone_groups: + return False + return True + + +def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context: Context, marker_names: List[str]) -> 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 marker_names: + marker = context.scene.timeline_markers[marker_name] + frame_start = 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_end = 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_end = sorted_timeline_markers[next_marker_index].frame + nla_strips = get_nla_strips_in_timeframe(animation_data, marker.frame, frame_end) + if len(nla_strips) > 0: + frame_end = min(frame_end, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips))) + frame_start = max(frame_start, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips))) + else: + # No strips in between this marker and the next, just export this as a one-frame animation. + frame_end = frame_start + 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 animation_data.nla_tracks: + if nla_track.mute: + continue + for strip in nla_track.strips: + frame_end = max(frame_end, strip.frame_end) + + if frame_start > frame_end: + continue + + sequence_frame_ranges[marker_name] = int(frame_start), int(frame_end) + + return sequence_frame_ranges + + +def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]: + frame_start = int(action.frame_range[0]) + frame_end = int(action.frame_range[1]) + reversed_pattern = r'(.+)/(.+)' + reversed_match = re.match(reversed_pattern, action.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) + ] + else: + return [(action.name, frame_start, frame_end)] + + +def get_sequences_from_action_pose_marker(action: Action, pose_markers: List[TimelineMarker], pose_marker: TimelineMarker, pose_marker_index: int) -> List[Tuple[str, int, int]]: + frame_start = pose_marker.frame + if pose_marker_index + 1 < len(pose_markers): + frame_end = pose_markers[pose_marker_index + 1].frame + else: + frame_end = int(action.frame_range[1]) + reversed_pattern = r'(.+)/(.+)' + reversed_match = re.match(reversed_pattern, pose_marker.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) + ] + else: + return [(pose_marker.name, frame_start, frame_end)] + + +def get_visible_sequences(pg: PSA_PG_export, sequences) -> List[PSA_PG_export_action_list_item]: + 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_OT_export(Operator, ExportHelper): bl_idname = 'psa_export.operator' bl_label = 'Export' bl_options = {'INTERNAL', 'UNDO'} @@ -388,14 +263,14 @@ class PsaExportOperator(Operator, ExportHelper): # 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') + row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') + row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') # ACTIONS if pg.sequence_source == 'ACTIONS': rows = max(3, min(len(pg.action_list), 10)) - layout.template_list('PSA_UL_ExportSequenceList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) + layout.template_list('PSA_UL_export_sequences', '', pg, 'action_list', pg, 'action_list_index', rows=rows) col = layout.column() col.use_property_split = True @@ -405,7 +280,7 @@ class PsaExportOperator(Operator, ExportHelper): elif pg.sequence_source == 'TIMELINE_MARKERS': rows = max(3, min(len(pg.marker_list), 10)) - layout.template_list('PSA_UL_ExportSequenceList', '', pg, 'marker_list', pg, 'marker_list_index', + layout.template_list('PSA_UL_export_sequences', '', pg, 'marker_list', pg, 'marker_list_index', rows=rows) col = layout.column() @@ -432,10 +307,10 @@ class PsaExportOperator(Operator, ExportHelper): if pg.bone_filter_mode == 'BONE_GROUPS': row = layout.row(align=True) row.label(text='Select') - row.operator(PsaExportBoneGroupsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT') - row.operator(PsaExportBoneGroupsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT') + row.operator(PSA_OT_export_bone_groups_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') + row.operator(PSA_OT_export_bone_groups_deselect_all.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', + layout.template_list('PSX_UL_bone_group_list', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) layout.prop(pg, 'should_ignore_bone_name_restrictions') @@ -459,7 +334,7 @@ class PsaExportOperator(Operator, ExportHelper): except RuntimeError as e: self.report({'ERROR_INVALID_CONTEXT'}, str(e)) - pg: PsaExportPropertyGroup = getattr(context.scene, 'psa_export') + pg: PSA_PG_export = getattr(context.scene, 'psa_export') self.armature_object = context.view_layer.objects.active @@ -488,13 +363,13 @@ class PsaExportOperator(Operator, ExportHelper): if animation_data is None: raise RuntimeError(f'No animation data for object \'{animation_data_object.name}\'') - export_sequences: List[PsaExportSequence] = [] + export_sequences: List[PsaBuildSequence] = [] if pg.sequence_source == 'ACTIONS': for action in filter(lambda x: x.is_selected, pg.action_list): if len(action.action.fcurves) == 0: continue - export_sequence = PsaExportSequence() + export_sequence = PsaBuildSequence() export_sequence.nla_state.action = action.action export_sequence.name = action.name export_sequence.nla_state.frame_start = action.frame_start @@ -505,7 +380,7 @@ class PsaExportOperator(Operator, ExportHelper): export_sequences.append(export_sequence) elif pg.sequence_source == 'TIMELINE_MARKERS': for marker in pg.marker_list: - export_sequence = PsaExportSequence() + export_sequence = PsaBuildSequence() export_sequence.name = marker.name export_sequence.nla_state.action = None export_sequence.nla_state.frame_start = marker.frame_start @@ -534,91 +409,12 @@ class PsaExportOperator(Operator, ExportHelper): self.report({'ERROR_INVALID_CONTEXT'}, str(e)) return {'CANCELLED'} - export_psa(psa, self.filepath) + write_psa(psa, self.filepath) return {'FINISHED'} -def filter_sequences(pg: PsaExportPropertyGroup, sequences) -> List[int]: - bitflag_filter_item = 1 << 30 - flt_flags = [bitflag_filter_item] * len(sequences) - - if pg.sequence_filter_name: - # Filter name is non-empty. - for i, sequence in enumerate(sequences): - if not fnmatch.fnmatch(sequence.name, f'*{pg.sequence_filter_name}*'): - flt_flags[i] &= ~bitflag_filter_item - - # Invert filter flags for all items. - if pg.sequence_use_filter_invert: - for i, sequence in enumerate(sequences): - 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 not pg.sequence_filter_pose_marker: - for i, sequence in enumerate(sequences): - if hasattr(sequence, 'is_pose_marker') and sequence.is_pose_marker: - flt_flags[i] &= ~bitflag_filter_item - - return flt_flags - - -def get_visible_sequences(pg: PsaExportPropertyGroup, sequences) -> 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_ExportSequenceList(UIList): - - def __init__(self): - super(PSA_UL_ExportSequenceList, 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): - item = typing.cast(PsaExportActionListItem, item) - is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker - layout.prop(item, 'is_selected', icon_only=True, text=item.name) - if hasattr(item, 'action') and item.action.asset_data is not None: - layout.label(text='', icon='ASSET_MANAGER') - - row = layout.row(align=True) - row.alignment = 'RIGHT' - if item.frame_end < item.frame_start: - row.label(text='', icon='FRAME_PREV') - if is_pose_marker: - row.label(text=item.action.name, icon='PMARKER') - - def draw_filter(self, context, layout): - pg = getattr(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.prop(pg, 'sequence_use_filter_sort_reverse', text='', icon='SORT_ASC') - - if pg.sequence_source == 'ACTIONS': - subrow = row.row(align=True) - subrow.prop(pg, 'sequence_filter_asset', icon_only=True, icon='ASSET_MANAGER') - subrow.prop(pg, 'sequence_filter_pose_marker', icon_only=True, icon='PMARKER') - - def filter_items(self, context, data, prop): - pg = getattr(context.scene, 'psa_export') - actions = getattr(data, prop) - flt_flags = filter_sequences(pg, actions) - # flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name') - flt_neworder = list(range(len(actions))) - return flt_flags, flt_neworder - - -class PsaExportActionsSelectAll(Operator): +class PSA_OT_export_actions_select_all(Operator): bl_idname = 'psa_export.sequences_select_all' bl_label = 'Select All' bl_description = 'Select all visible sequences' @@ -649,7 +445,7 @@ class PsaExportActionsSelectAll(Operator): return {'FINISHED'} -class PsaExportActionsDeselectAll(Operator): +class PSA_OT_export_actions_deselect_all(Operator): bl_idname = 'psa_export.sequences_deselect_all' bl_label = 'Deselect All' bl_description = 'Deselect all visible sequences' @@ -678,7 +474,7 @@ class PsaExportActionsDeselectAll(Operator): return {'FINISHED'} -class PsaExportBoneGroupsSelectAll(Operator): +class PSA_OT_export_bone_groups_select_all(Operator): bl_idname = 'psa_export.bone_groups_select_all' bl_label = 'Select All' bl_description = 'Select all bone groups' @@ -698,7 +494,7 @@ class PsaExportBoneGroupsSelectAll(Operator): return {'FINISHED'} -class PsaExportBoneGroupsDeselectAll(Operator): +class PSA_OT_export_bone_groups_deselect_all(Operator): bl_idname = 'psa_export.bone_groups_deselect_all' bl_label = 'Deselect All' bl_description = 'Deselect all bone groups' @@ -719,13 +515,9 @@ class PsaExportBoneGroupsDeselectAll(Operator): classes = ( - PsaExportActionListItem, - PsaExportTimelineMarkerListItem, - PsaExportPropertyGroup, - PsaExportOperator, - PSA_UL_ExportSequenceList, - PsaExportActionsSelectAll, - PsaExportActionsDeselectAll, - PsaExportBoneGroupsSelectAll, - PsaExportBoneGroupsDeselectAll, + PSA_OT_export, + PSA_OT_export_actions_select_all, + PSA_OT_export_actions_deselect_all, + PSA_OT_export_bone_groups_select_all, + PSA_OT_export_bone_groups_deselect_all, ) diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py new file mode 100644 index 0000000..8e6f625 --- /dev/null +++ b/io_scene_psk_psa/psa/export/properties.py @@ -0,0 +1,157 @@ +import sys +from fnmatch import fnmatch +from typing import List + +from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \ + StringProperty +from bpy.types import PropertyGroup, Object, Action + +from ...types import PSX_PG_bone_group_list_item + + +def psa_export_property_group_animation_data_override_poll(_context, obj): + return obj.animation_data is not None + + +empty_set = set() + + +class PSA_PG_export_action_list_item(PropertyGroup): + action: PointerProperty(type=Action) + name: StringProperty() + is_selected: BoolProperty(default=False) + frame_start: IntProperty(options={'HIDDEN'}) + frame_end: IntProperty(options={'HIDDEN'}) + is_pose_marker: BoolProperty(options={'HIDDEN'}) + + +class PSA_PG_export_timeline_markers(PropertyGroup): + marker_index: IntProperty() + name: StringProperty() + is_selected: BoolProperty(default=True) + frame_start: IntProperty(options={'HIDDEN'}) + frame_end: IntProperty(options={'HIDDEN'}) + + +class PSA_PG_export(PropertyGroup): + root_motion: BoolProperty( + name='Root Motion', + options=empty_set, + default=False, + description='When enabled, the root bone will be transformed as it appears in the scene.\n\n' + 'You might want to disable this if you are exporting an animation for an armature that is ' + 'attached to another object, such as a weapon or a shield', + ) + should_override_animation_data: BoolProperty( + name='Override Animation Data', + options=empty_set, + default=False, + description='Use the animation data from a different object instead of the selected object' + ) + animation_data_override: PointerProperty( + type=Object, + poll=psa_export_property_group_animation_data_override_poll + ) + sequence_source: EnumProperty( + name='Source', + options=empty_set, + 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), + ) + ) + fps_source: EnumProperty( + name='FPS Source', + options=empty_set, + description='', + items=( + ('SCENE', 'Scene', '', 'SCENE_DATA', 0), + ('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=empty_set, step=100, + soft_max=60.0) + action_list: CollectionProperty(type=PSA_PG_export_action_list_item) + action_list_index: IntProperty(default=0) + marker_list: CollectionProperty(type=PSA_PG_export_timeline_markers) + marker_list_index: IntProperty(default=0) + bone_filter_mode: EnumProperty( + name='Bone Filter', + options=empty_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_group_list: CollectionProperty(type=PSX_PG_bone_group_list_item) + bone_group_list_index: IntProperty(default=0, name='', description='') + should_ignore_bone_name_restrictions: BoolProperty( + default=False, + name='Ignore Bone Name Restrictions', + description='Bone names restrictions will be ignored. Note that bone names without properly formatted names ' + 'cannot be referenced in scripts' + ) + sequence_name_prefix: StringProperty(name='Prefix', options=empty_set) + sequence_name_suffix: StringProperty(name='Suffix', options=empty_set) + sequence_filter_name: StringProperty( + default='', + name='Filter by Name', + options={'TEXTEDIT_UPDATE'}, + description='Only show items matching this name (use \'*\' as wildcard)') + sequence_use_filter_invert: BoolProperty( + default=False, + name='Invert', + options=empty_set, + description='Invert filtering (show hidden items, and vice versa)') + sequence_filter_asset: BoolProperty( + default=False, + name='Show assets', + options=empty_set, + description='Show actions that belong to an asset library') + sequence_filter_pose_marker: BoolProperty( + default=False, + name='Show pose markers', + options=empty_set) + sequence_use_filter_sort_reverse: BoolProperty(default=True, options=empty_set) + + +def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]: + bitflag_filter_item = 1 << 30 + flt_flags = [bitflag_filter_item] * len(sequences) + + if pg.sequence_filter_name: + # Filter name is non-empty. + for i, sequence in enumerate(sequences): + if not fnmatch(sequence.name, f'*{pg.sequence_filter_name}*'): + flt_flags[i] &= ~bitflag_filter_item + + # Invert filter flags for all items. + if pg.sequence_use_filter_invert: + for i, sequence in enumerate(sequences): + 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 not pg.sequence_filter_pose_marker: + for i, sequence in enumerate(sequences): + if hasattr(sequence, 'is_pose_marker') and sequence.is_pose_marker: + flt_flags[i] &= ~bitflag_filter_item + + return flt_flags + + +classes = ( + PSA_PG_export_action_list_item, + PSA_PG_export_timeline_markers, + PSA_PG_export, +) diff --git a/io_scene_psk_psa/psa/export/ui.py b/io_scene_psk_psa/psa/export/ui.py new file mode 100644 index 0000000..9dfb221 --- /dev/null +++ b/io_scene_psk_psa/psa/export/ui.py @@ -0,0 +1,53 @@ +from typing import cast + +from bpy.types import UIList + +from .properties import PSA_PG_export_action_list_item, filter_sequences + + +class PSA_UL_export_sequences(UIList): + + def __init__(self): + super(PSA_UL_export_sequences, 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): + item = cast(PSA_PG_export_action_list_item, item) + is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker + layout.prop(item, 'is_selected', icon_only=True, text=item.name) + if hasattr(item, 'action') and item.action.asset_data is not None: + layout.label(text='', icon='ASSET_MANAGER') + + row = layout.row(align=True) + row.alignment = 'RIGHT' + if item.frame_end < item.frame_start: + row.label(text='', icon='FRAME_PREV') + if is_pose_marker: + row.label(text=item.action.name, icon='PMARKER') + + def draw_filter(self, context, layout): + pg = getattr(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.prop(pg, 'sequence_use_filter_sort_reverse', text='', icon='SORT_ASC') + + if pg.sequence_source == 'ACTIONS': + subrow = row.row(align=True) + subrow.prop(pg, 'sequence_filter_asset', icon_only=True, icon='ASSET_MANAGER') + subrow.prop(pg, 'sequence_filter_pose_marker', icon_only=True, icon='PMARKER') + + def filter_items(self, context, data, prop): + pg = getattr(context.scene, 'psa_export') + actions = getattr(data, prop) + flt_flags = filter_sequences(pg, actions) + # flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name') + flt_neworder = list(range(len(actions))) + return flt_flags, flt_neworder + + +classes = ( + PSA_UL_export_sequences, +) diff --git a/io_scene_psk_psa/psa/import_/__init__.py b/io_scene_psk_psa/psa/import_/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py new file mode 100644 index 0000000..328ca46 --- /dev/null +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -0,0 +1,255 @@ +import os + +from bpy.props import StringProperty +from bpy.types import Operator, Event, Context +from bpy_extras.io_utils import ImportHelper + +from .properties import get_visible_sequences +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' + 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 = getattr(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 = getattr(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 = getattr(context.scene, 'psa_import') + if pg.select_text is None: + self.report({'ERROR_INVALID_CONTEXT'}, 'No text block selected') + return {'CANCELLED'} + 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 PSA_OT_import_sequences_select_all(Operator): + bl_idname = 'psa_import.sequences_select_all' + bl_label = 'All' + bl_description = 'Select all sequences' + bl_options = {'INTERNAL'} + + @classmethod + def poll(cls, context): + pg = getattr(context.scene, 'psa_import') + 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 = getattr(context.scene, 'psa_import') + visible_sequences = get_visible_sequences(pg, pg.sequence_list) + for sequence in visible_sequences: + sequence.is_selected = True + return {'FINISHED'} + + +class PSA_OT_import_sequences_deselect_all(Operator): + bl_idname = 'psa_import.sequences_deselect_all' + bl_label = 'None' + bl_description = 'Deselect all visible sequences' + bl_options = {'INTERNAL'} + + @classmethod + def poll(cls, context): + pg = getattr(context.scene, 'psa_import') + 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 = getattr(context.scene, 'psa_import') + visible_sequences = get_visible_sequences(pg, pg.sequence_list) + for sequence in visible_sequences: + sequence.is_selected = False + return {'FINISHED'} + + +class PSA_OT_import_select_file(Operator): + bl_idname = 'psa_import.select_file' + bl_label = 'Select' + bl_options = {'INTERNAL'} + bl_description = 'Select a PSA file from which to import animations' + filepath: StringProperty(subtype='FILE_PATH') + filter_glob: StringProperty(default="*.psa", options={'HIDDEN'}) + + def execute(self, context): + getattr(context.scene, 'psa_import').psa_file_path = self.filepath + return {"FINISHED"} + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + +def load_psa_file(context, filepath: str): + pg = context.scene.psa_import + pg.sequence_list.clear() + pg.psa.bones.clear() + pg.psa_error = '' + try: + # Read the file and populate the action list. + p = os.path.abspath(filepath) + psa_reader = PsaReader(p) + for sequence in psa_reader.sequences.values(): + item = pg.sequence_list.add() + item.action_name = sequence.name.decode('windows-1252') + for psa_bone in psa_reader.bones: + item = pg.psa.bones.add() + item.bone_name = psa_bone.name.decode('windows-1252') + except Exception as e: + pg.psa_error = str(e) + + + +def on_psa_file_path_updated(cls, context): + load_psa_file(context, cls.filepath) + + +class PSA_OT_import(Operator, ImportHelper): + bl_idname = 'psa_import.import' + bl_label = 'Import' + bl_description = 'Import the selected animations into the scene as actions' + bl_options = {'INTERNAL', 'UNDO'} + + filename_ext = '.psa' + filter_glob: StringProperty(default='*.psa', options={'HIDDEN'}) + filepath: StringProperty( + name='File Path', + description='File path used for importing the PSA file', + maxlen=1024, + default='', + update=on_psa_file_path_updated) + + @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 + + def execute(self, context): + pg = getattr(context.scene, 'psa_import') + psa_reader = PsaReader(self.filepath) + sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected] + + options = PsaImportOptions() + options.sequence_names = 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 '' + options.should_overwrite = pg.should_overwrite + options.should_write_metadata = pg.should_write_metadata + options.should_write_keyframes = pg.should_write_keyframes + options.should_convert_to_samples = pg.should_convert_to_samples + options.bone_mapping_mode = pg.bone_mapping_mode + + result = import_psa(psa_reader, context.view_layer.objects.active, options) + + if len(result.warnings) > 0: + message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n' + message += '\n'.join(result.warnings) + self.report({'WARNING'}, message) + else: + self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)') + + return {'FINISHED'} + + def invoke(self, context: Context, event: Event): + # Attempt to load the PSA file for the pre-selected file. + load_psa_file(context, self.filepath) + + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + + def draw(self, context: Context): + layout = self.layout + pg = getattr(context.scene, 'psa_import') + + if pg.psa_error: + row = layout.row() + row.label(text='Select a PSA file', icon='ERROR') + else: + box = layout.box() + + box.label(text=f'Sequences ({len(pg.sequence_list)})', icon='ARMATURE_DATA') + + # Select buttons. + rows = max(3, min(len(pg.sequence_list), 10)) + + row = box.row() + col = row.column() + + 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_all.bl_idname, text='All', icon='CHECKBOX_HLT') + row2.operator(PSA_OT_import_sequences_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') + + col = col.row() + col.template_list('PSA_UL_import_sequences', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows) + + 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') + + col = layout.column() + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'bone_mapping_mode') + + if pg.should_write_keyframes: + col = layout.column(heading='Keyframes') + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'should_convert_to_samples') + col.separator() + + col = layout.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_action_name_prefix') + + if pg.should_use_action_name_prefix: + col.prop(pg, 'action_name_prefix') + + +classes = ( + PSA_OT_import_sequences_select_all, + PSA_OT_import_sequences_deselect_all, + PSA_OT_import_sequences_from_text, + PSA_OT_import, + PSA_OT_import_select_file, +) diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py new file mode 100644 index 0000000..b90ade6 --- /dev/null +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -0,0 +1,119 @@ +import re +from fnmatch import fnmatch +from typing import List + +from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty, PointerProperty, EnumProperty +from bpy.types import PropertyGroup, Text + +empty_set = set() + + +class PSA_PG_import_action_list_item(PropertyGroup): + action_name: StringProperty(options=empty_set) + is_selected: BoolProperty(default=False, options=empty_set) + + +class PSA_PG_bone(PropertyGroup): + bone_name: StringProperty(options=empty_set) + + +class PSA_PG_data(PropertyGroup): + bones: CollectionProperty(type=PSA_PG_bone) + 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) + 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_stash: BoolProperty(default=False, name='Stash', + description='Stash each imported action as a strip on a new non-contributing NLA track', + options=empty_set) + should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=empty_set) + action_name_prefix: StringProperty(default='', name='Prefix', options=empty_set) + should_overwrite: BoolProperty(default=False, name='Overwrite', options=empty_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=empty_set) + should_write_metadata: BoolProperty(default=True, name='Metadata', options=empty_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=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) + should_convert_to_samples: BoolProperty( + default=False, + name='Convert to Samples', + description='Convert keyframes to read-only samples. ' + 'Recommended if you do not plan on editing the actions directly' + ) + bone_mapping_mode: EnumProperty( + 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), + ) + ) + + +def filter_sequences(pg: PSA_PG_import, sequences) -> 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. + if pg.sequence_use_filter_regex: + # 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): + if not regex.match(sequence.action_name): + flt_flags[i] &= ~bitflag_filter_item + except re.error: + pass + else: + # User regular text matching. + for i, sequence in enumerate(sequences): + if not 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): + flt_flags[i] ^= bitflag_filter_item + + return flt_flags + + +def get_visible_sequences(pg: PSA_PG_import, sequences) -> List[PSA_PG_import_action_list_item]: + bitflag_filter_item = 1 << 30 + visible_sequences = [] + for i, flag in enumerate(filter_sequences(pg, sequences)): + if bool(flag & bitflag_filter_item): + visible_sequences.append(sequences[i]) + return visible_sequences + + +classes = ( + PSA_PG_import_action_list_item, + PSA_PG_bone, + PSA_PG_data, + PSA_PG_import, +) diff --git a/io_scene_psk_psa/psa/import_/ui.py b/io_scene_psk_psa/psa/import_/ui.py new file mode 100644 index 0000000..597c134 --- /dev/null +++ b/io_scene_psk_psa/psa/import_/ui.py @@ -0,0 +1,45 @@ +import bpy +from bpy.types import UIList + +from .properties import filter_sequences + + +class PSA_UL_sequences(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_property, index, flt_flag): + row = layout.row(align=True) + split = row.split(align=True, factor=0.75) + column = split.row(align=True) + column.alignment = 'LEFT' + column.prop(item, 'is_selected', icon_only=True) + column.label(text=getattr(item, 'action_name')) + + def draw_filter(self, context, layout): + pg = getattr(context.scene, 'psa_import') + row = layout.row() + sub_row = row.row(align=True) + sub_row.prop(pg, 'sequence_filter_name', text="") + sub_row.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT') + sub_row.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT') + sub_row.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT') + + def filter_items(self, context, data, property_): + pg = getattr(context.scene, 'psa_import') + sequences = getattr(data, property_) + 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 + + +class PSA_UL_import_sequences(PSA_UL_sequences, UIList): + pass + + +class PSA_UL_import_actions(PSA_UL_sequences, UIList): + pass + + +classes = ( + PSA_UL_sequences, + PSA_UL_import_sequences, + PSA_UL_import_actions, +) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 64ae5ad..10b31e5 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -1,14 +1,9 @@ -import fnmatch -import os -import re import typing from typing import List, Optional import bpy import numpy -from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty -from bpy.types import Operator, UIList, PropertyGroup, FCurve -from bpy_extras.io_utils import ImportHelper +from bpy.types import FCurve, Object from mathutils import Vector, Quaternion from .data import Psa @@ -40,7 +35,7 @@ class ImportBone(object): self.fcurves: List[FCurve] = [] -def calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[float]): +def _calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[float]): # Convert world-space transforms to local-space transforms. key_rotation = Quaternion(key_data[0:4]) key_location = Vector(key_data[4:]) @@ -80,7 +75,7 @@ def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_name return None -def import_psa(psa_reader: PsaReader, armature_object: bpy.types.Object, options: PsaImportOptions) -> PsaImportResult: +def import_psa(psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult: result = PsaImportResult() sequences = map(lambda x: psa_reader.sequences[x], options.sequence_names) armature_data = typing.cast(bpy.types.Armature, armature_object.data) @@ -207,7 +202,7 @@ def import_psa(psa_reader: PsaReader, armature_object: bpy.types.Object, options # 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) + sequence_data_matrix[frame_index, bone_index] = _calculate_fcurve_data(import_bone, key_data) # Write the keyframes out. fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float) @@ -244,395 +239,3 @@ def import_psa(psa_reader: PsaReader, armature_object: bpy.types.Object, options nla_track.strips.new(name=action.name, start=0, action=action) return result - - -empty_set = set() - - -class PsaImportActionListItem(PropertyGroup): - action_name: StringProperty(options=empty_set) - is_selected: BoolProperty(default=False, options=empty_set) - - -def load_psa_file(context, filepath: str): - pg = context.scene.psa_import - pg.sequence_list.clear() - pg.psa.bones.clear() - pg.psa_error = '' - try: - # Read the file and populate the action list. - p = os.path.abspath(filepath) - psa_reader = PsaReader(p) - for sequence in psa_reader.sequences.values(): - item = pg.sequence_list.add() - item.action_name = sequence.name.decode('windows-1252') - for psa_bone in psa_reader.bones: - item = pg.psa.bones.add() - item.bone_name = psa_bone.name.decode('windows-1252') - except Exception as e: - pg.psa_error = str(e) - - -def on_psa_file_path_updated(cls, context): - load_psa_file(context, cls.filepath) - - -class PsaBonePropertyGroup(PropertyGroup): - bone_name: StringProperty(options=empty_set) - - -class PsaDataPropertyGroup(PropertyGroup): - bones: CollectionProperty(type=PsaBonePropertyGroup) - sequence_count: IntProperty(default=0) - - -class PsaImportPropertyGroup(PropertyGroup): - psa_error: StringProperty(default='') - psa: PointerProperty(type=PsaDataPropertyGroup) - sequence_list: CollectionProperty(type=PsaImportActionListItem) - sequence_list_index: IntProperty(name='', default=0) - 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_stash: BoolProperty(default=False, name='Stash', - description='Stash each imported action as a strip on a new non-contributing NLA track', - options=empty_set) - should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=empty_set) - action_name_prefix: StringProperty(default='', name='Prefix', options=empty_set) - should_overwrite: BoolProperty(default=False, name='Overwrite', options=empty_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=empty_set) - should_write_metadata: BoolProperty(default=True, name='Metadata', options=empty_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=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=bpy.types.Text) - should_convert_to_samples: BoolProperty( - default=False, - name='Convert to Samples', - description='Convert keyframes to read-only samples. ' - 'Recommended if you do not plan on editing the actions directly' - ) - bone_mapping_mode: EnumProperty( - 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), - ) - ) - - -def filter_sequences(pg: PsaImportPropertyGroup, sequences) -> 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. - if pg.sequence_use_filter_regex: - # 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): - if not regex.match(sequence.action_name): - flt_flags[i] &= ~bitflag_filter_item - except re.error: - pass - else: - # 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 - - 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): - flt_flags[i] ^= bitflag_filter_item - - return flt_flags - - -def get_visible_sequences(pg: PsaImportPropertyGroup, sequences) -> List[PsaImportActionListItem]: - bitflag_filter_item = 1 << 30 - visible_sequences = [] - for i, flag in enumerate(filter_sequences(pg, sequences)): - if bool(flag & bitflag_filter_item): - 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_property, index, flt_flag): - row = layout.row(align=True) - split = row.split(align=True, factor=0.75) - column = split.row(align=True) - column.alignment = 'LEFT' - column.prop(item, 'is_selected', icon_only=True) - column.label(text=getattr(item, 'action_name')) - - def draw_filter(self, context, layout): - pg = getattr(context.scene, 'psa_import') - row = layout.row() - sub_row = row.row(align=True) - sub_row.prop(pg, 'sequence_filter_name', text="") - sub_row.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT') - sub_row.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT') - sub_row.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT') - - def filter_items(self, context, data, property_): - pg = getattr(context.scene, 'psa_import') - sequences = getattr(data, property_) - 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 - - -class PSA_UL_ImportSequenceList(PSA_UL_SequenceList, UIList): - pass - - -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 = getattr(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 = getattr(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 = getattr(context.scene, 'psa_import') - if pg.select_text is None: - self.report({'ERROR_INVALID_CONTEXT'}, 'No text block selected') - return {'CANCELLED'} - 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' - bl_description = 'Select all sequences' - bl_options = {'INTERNAL'} - - @classmethod - def poll(cls, context): - pg = getattr(context.scene, 'psa_import') - 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 = getattr(context.scene, 'psa_import') - visible_sequences = get_visible_sequences(pg, pg.sequence_list) - for sequence in visible_sequences: - sequence.is_selected = True - return {'FINISHED'} - - -class PsaImportSequencesDeselectAll(Operator): - bl_idname = 'psa_import.sequences_deselect_all' - bl_label = 'None' - bl_description = 'Deselect all visible sequences' - bl_options = {'INTERNAL'} - - @classmethod - def poll(cls, context): - pg = getattr(context.scene, 'psa_import') - 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 = getattr(context.scene, 'psa_import') - visible_sequences = get_visible_sequences(pg, pg.sequence_list) - for sequence in visible_sequences: - sequence.is_selected = False - return {'FINISHED'} - - -class PsaImportSelectFile(Operator): - bl_idname = 'psa_import.select_file' - bl_label = 'Select' - 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'}) - - def execute(self, context): - getattr(context.scene, 'psa_import').psa_file_path = self.filepath - return {"FINISHED"} - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - - -class PsaImportOperator(Operator, ImportHelper): - bl_idname = 'psa_import.import' - bl_label = 'Import' - bl_description = 'Import the selected animations into the scene as actions' - bl_options = {'INTERNAL', 'UNDO'} - - filename_ext = '.psa' - filter_glob: StringProperty(default='*.psa', options={'HIDDEN'}) - filepath: StringProperty( - name='File Path', - description='File path used for importing the PSA file', - maxlen=1024, - default='', - update=on_psa_file_path_updated) - - @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 - - def execute(self, context): - pg = getattr(context.scene, 'psa_import') - psa_reader = PsaReader(self.filepath) - sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected] - - options = PsaImportOptions() - options.sequence_names = 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 '' - options.should_overwrite = pg.should_overwrite - options.should_write_metadata = pg.should_write_metadata - options.should_write_keyframes = pg.should_write_keyframes - options.should_convert_to_samples = pg.should_convert_to_samples - options.bone_mapping_mode = pg.bone_mapping_mode - - result = import_psa(psa_reader, context.view_layer.objects.active, options) - - if len(result.warnings) > 0: - message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n' - message += '\n'.join(result.warnings) - self.report({'WARNING'}, message) - else: - self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)') - - return {'FINISHED'} - - def invoke(self, context: bpy.types.Context, event: bpy.types.Event): - # Attempt to load the PSA file for the pre-selected file. - load_psa_file(context, self.filepath) - - context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} - - def draw(self, context: bpy.types.Context): - layout = self.layout - pg = getattr(context.scene, 'psa_import') - - if pg.psa_error: - row = layout.row() - row.label(text='Select a PSA file', icon='ERROR') - else: - box = layout.box() - - box.label(text=f'Sequences ({len(pg.sequence_list)})', icon='ARMATURE_DATA') - - # Select buttons. - rows = max(3, min(len(pg.sequence_list), 10)) - - row = box.row() - col = row.column() - - 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') - - col = col.row() - col.template_list('PSA_UL_ImportSequenceList', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows) - - 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') - - col = layout.column() - col.use_property_split = True - col.use_property_decorate = False - col.prop(pg, 'bone_mapping_mode') - - if pg.should_write_keyframes: - col = layout.column(heading='Keyframes') - col.use_property_split = True - col.use_property_decorate = False - col.prop(pg, 'should_convert_to_samples') - col.separator() - - col = layout.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_action_name_prefix') - - if pg.should_use_action_name_prefix: - col.prop(pg, 'action_name_prefix') - - -classes = ( - PsaImportActionListItem, - PsaBonePropertyGroup, - PsaDataPropertyGroup, - PsaImportPropertyGroup, - PSA_UL_SequenceList, - PSA_UL_ImportSequenceList, - PSA_UL_ImportActionList, - PsaImportSequencesSelectAll, - PsaImportSequencesDeselectAll, - PsaImportSequencesFromText, - PsaImportOperator, - PsaImportSelectFile, -) diff --git a/io_scene_psk_psa/psa/writer.py b/io_scene_psk_psa/psa/writer.py new file mode 100644 index 0000000..64fea60 --- /dev/null +++ b/io_scene_psk_psa/psa/writer.py @@ -0,0 +1,25 @@ +from ctypes import Structure, sizeof +from typing import Type + +from .data import Psa +from ..data import Section + + +def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None): + section = Section() + section.name = name + if data_type is not None and data is not None: + section.data_size = sizeof(data_type) + section.data_count = len(data) + fp.write(section) + if data is not None: + for datum in data: + fp.write(datum) + + +def write_psa(psa: Psa, path: str): + with open(path, 'wb') as fp: + write_section(fp, b'ANIMHEAD') + write_section(fp, b'BONENAMES', Psa.Bone, psa.bones) + write_section(fp, b'ANIMINFO', Psa.Sequence, list(psa.sequences.values())) + write_section(fp, b'ANIMKEYS', Psa.Key, psa.keys) diff --git a/io_scene_psk_psa/psk/exporter.py b/io_scene_psk_psa/psk/exporter.py index 25499c6..0386b4c 100644 --- a/io_scene_psk_psa/psk/exporter.py +++ b/io_scene_psk_psa/psk/exporter.py @@ -7,7 +7,7 @@ from bpy_extras.io_utils import ExportHelper from .builder import build_psk, PskBuildOptions, get_psk_input_objects from .data import * from ..helpers import populate_bone_group_list -from ..types import BoneGroupListItem +from ..types import PSX_PG_bone_group_list_item MAX_WEDGE_COUNT = 65536 MAX_POINT_COUNT = 4294967296 @@ -202,7 +202,7 @@ class PskExportOperator(Operator, ExportHelper): if pg.bone_filter_mode == 'BONE_GROUPS': row = layout.row() 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) + row.template_list('PSX_UL_bone_group_list', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) layout.separator() @@ -249,7 +249,7 @@ class PskExportPropertyGroup(PropertyGroup): 'Only bones belonging to the selected bone groups and their ancestors will be exported.') ) ) - bone_group_list: CollectionProperty(type=BoneGroupListItem) + bone_group_list: CollectionProperty(type=PSX_PG_bone_group_list_item) bone_group_list_index: IntProperty(default=0) use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh') material_list: CollectionProperty(type=MaterialListItem) diff --git a/io_scene_psk_psa/types.py b/io_scene_psk_psa/types.py index c6e28c8..e1cac91 100644 --- a/io_scene_psk_psa/types.py +++ b/io_scene_psk_psa/types.py @@ -2,7 +2,7 @@ from bpy.props import StringProperty, IntProperty, BoolProperty, FloatProperty from bpy.types import PropertyGroup, UIList, UILayout, Context, AnyType, Panel -class PSX_UL_BoneGroupList(UIList): +class PSX_UL_bone_group_list(UIList): def draw_item(self, context: Context, layout: UILayout, data: AnyType, item: AnyType, icon: int, active_data: AnyType, active_property: str, index: int = 0, flt_flag: int = 0): @@ -11,7 +11,7 @@ class PSX_UL_BoneGroupList(UIList): row.label(text=str(getattr(item, 'count')), icon='BONE_DATA') -class BoneGroupListItem(PropertyGroup): +class PSX_PG_bone_group_list_item(PropertyGroup): name: StringProperty() index: IntProperty() count: IntProperty() @@ -44,7 +44,7 @@ class PSX_PT_ActionPropertyPanel(Panel): classes = ( PSX_PG_ActionExportPropertyGroup, - BoneGroupListItem, - PSX_UL_BoneGroupList, + PSX_PG_bone_group_list_item, + PSX_UL_bone_group_list, PSX_PT_ActionPropertyPanel )