diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index dfd0b76..b192bdc 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -1,4 +1,3 @@ -import datetime import re import typing from typing import List, Iterable @@ -8,24 +7,6 @@ import bpy.types from bpy.types import NlaStrip, Object, AnimData -class Timer: - def __enter__(self): - self.start = datetime.datetime.now() - self.interval = None - return self - - def __exit__(self, *args): - self.end = datetime.datetime.now() - self.interval = self.end - self.start - - @property - def duration(self): - if self.interval is not None: - return self.interval - else: - return datetime.datetime.now() - self.start - - def rgb_to_srgb(c: float): if c > 0.0031308: return 1.055 * (pow(c, (1.0 / 2.4))) - 0.055 @@ -33,7 +14,7 @@ def rgb_to_srgb(c: float): return 12.92 * c -def get_nla_strips_in_timeframe(animation_data: AnimData, frame_min: float, frame_max: float) -> List[NlaStrip]: +def get_nla_strips_in_frame_range(animation_data: AnimData, frame_min: float, frame_max: float) -> List[NlaStrip]: if animation_data is None: return [] strips = [] @@ -150,7 +131,7 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c # Split out the bone indices and the instigator bone names into separate lists. # We use the bone names for the return values because the bone name is a more universal way of referencing them. - # For example, users of this function may modify bone lists, which would invalidate the indices and require a + # For example, users of this function may modify bone lists, which would invalidate the indices and require an # index mapping scheme to resolve it. Using strings is more comfy and results in less code downstream. instigator_bone_names = [bones[x[1]].name if x[1] is not None else None for x in bone_indices] bone_names = [bones[x[0]].name for x in bone_indices] diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 97ef328..f46d517 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -91,7 +91,11 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: # Build list of PSA bones. for bone in bones: psa_bone = Psa.Bone() - psa_bone.name = bytes(bone.name, encoding='windows-1252') + + try: + psa_bone.name = bytes(bone.name, encoding='windows-1252') + except UnicodeEncodeError: + raise RuntimeError(f'Bone name "{bone.name}" contains characters that cannot be encoded in the Windows-1252 codepage') try: parent_index = bones.index(bone.parent) @@ -165,7 +169,10 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: frame_step = -frame_step psa_sequence = Psa.Sequence() - psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252') + try: + psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252') + except UnicodeEncodeError: + raise RuntimeError(f'Sequence name "{export_sequence.name}" contains characters that cannot be encoded in the Windows-1252 codepage') psa_sequence.frame_count = frame_count psa_sequence.frame_start_index = frame_start_index psa_sequence.fps = frame_count / sequence_duration diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index 02ffe88..d692f36 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -8,10 +8,10 @@ from bpy.types import Context, Armature, Action, Object, AnimData, TimelineMarke from bpy_extras.io_utils import ExportHelper from bpy_types import Operator +from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences from ..builder import build_psa, PsaBuildSequence, PsaBuildOptions -from ..export.properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences from ..writer import write_psa -from ...helpers import populate_bone_collection_list, get_nla_strips_in_timeframe +from ...helpers import populate_bone_collection_list, get_nla_strips_in_frame_range def is_action_for_armature(armature: Armature, action: Action): @@ -80,12 +80,14 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature): continue if marker_name.startswith('#'): continue - item = pg.marker_list.add() - item.name = marker_name - item.is_selected = False frame_start, frame_end = sequence_frame_ranges[marker_name] - item.frame_start = frame_start - item.frame_end = frame_end + sequences = get_sequences_from_name_and_frame_range(marker_name, frame_start, frame_end) + for (sequence_name, frame_start, frame_end) in sequences: + item = pg.marker_list.add() + item.name = sequence_name + item.is_selected = False + item.frame_start = frame_start + item.frame_end = frame_end def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float: @@ -150,7 +152,7 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context: 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) + nla_strips = get_nla_strips_in_frame_range(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))) @@ -174,11 +176,9 @@ 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]]: - frame_start = int(action.frame_range[0]) - frame_end = int(action.frame_range[1]) +def get_sequences_from_name_and_frame_range(name: str, frame_start: int, frame_end: int) -> List[Tuple[str, int, int]]: reversed_pattern = r'(.+)/(.+)' - reversed_match = re.match(reversed_pattern, action.name) + reversed_match = re.match(reversed_pattern, name) if reversed_match: forward_name = reversed_match.group(1) backwards_name = reversed_match.group(2) @@ -187,7 +187,13 @@ def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]: (backwards_name, frame_end, frame_start) ] else: - return [(action.name, frame_start, frame_end)] + return [(name, frame_start, frame_end)] + + +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]) + return get_sequences_from_name_and_frame_range(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]]: @@ -196,17 +202,7 @@ def get_sequences_from_action_pose_marker(action: Action, pose_markers: List[Tim 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)] + return get_sequences_from_name_and_frame_range(pose_marker.name, frame_start, frame_end) def get_visible_sequences(pg: PSA_PG_export, sequences) -> List[PSA_PG_export_action_list_item]: @@ -254,12 +250,18 @@ class PSA_OT_export(Operator, ExportHelper): # SOURCE layout.prop(pg, 'sequence_source', text='Source') - if pg.sequence_source == 'TIMELINE_MARKERS': + if pg.sequence_source in {'TIMELINE_MARKERS', 'NLA_TRACK_STRIPS'}: # ANIMDATA SOURCE layout.prop(pg, 'should_override_animation_data') if pg.should_override_animation_data: layout.prop(pg, 'animation_data_override', text='') + if pg.sequence_source == 'NLA_TRACK_STRIPS': + flow = layout.grid_flow() + flow.use_property_split = True + flow.use_property_decorate = False + flow.prop(pg, 'nla_track') + # SELECT ALL/NONE row = layout.row(align=True) row.label(text='Select') @@ -269,25 +271,19 @@ class PSA_OT_export(Operator, ExportHelper): # ACTIONS if pg.sequence_source == 'ACTIONS': rows = max(3, min(len(pg.action_list), 10)) - layout.template_list('PSA_UL_export_sequences', '', pg, 'action_list', pg, 'action_list_index', rows=rows) - - col = layout.column() - col.use_property_split = True - col.use_property_decorate = False - col.prop(pg, 'sequence_name_prefix') - col.prop(pg, 'sequence_name_suffix') - elif pg.sequence_source == 'TIMELINE_MARKERS': rows = max(3, min(len(pg.marker_list), 10)) - layout.template_list('PSA_UL_export_sequences', '', pg, 'marker_list', pg, 'marker_list_index', - rows=rows) + layout.template_list('PSA_UL_export_sequences', '', pg, 'marker_list', pg, 'marker_list_index', rows=rows) + elif pg.sequence_source == 'NLA_TRACK_STRIPS': + rows = max(3, min(len(pg.nla_strip_list), 10)) + layout.template_list('PSA_UL_export_sequences', '', pg, 'nla_strip_list', pg, 'nla_strip_list_index', rows=rows) - col = layout.column() - col.use_property_split = True - col.use_property_decorate = False - col.prop(pg, 'sequence_name_prefix') - col.prop(pg, 'sequence_name_suffix') + col = layout.column() + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'sequence_name_prefix') + col.prop(pg, 'sequence_name_suffix') # Determine if there is going to be a naming conflict and display an error, if so. selected_items = [x for x in pg.action_list if x.is_selected] @@ -359,6 +355,8 @@ class PSA_OT_export(Operator, ExportHelper): raise RuntimeError('No actions were selected for export') elif pg.sequence_source == 'TIMELINE_MARKERS' and len(pg.marker_list) == 0: raise RuntimeError('No timeline markers were selected for export') + elif pg.sequence_source == 'NLA_TRACK_STRIPS' and len(pg.nla_strip_list) == 0: + raise RuntimeError('No NLA track strips were selected for export') # Populate the export sequence list. animation_data_object = get_animation_data_object(context) @@ -370,29 +368,38 @@ class PSA_OT_export(Operator, ExportHelper): 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: + for action_item in filter(lambda x: x.is_selected, pg.action_list): + if len(action_item.action.fcurves) == 0: continue export_sequence = PsaBuildSequence() - export_sequence.nla_state.action = action.action - export_sequence.name = action.name - export_sequence.nla_state.frame_start = action.frame_start - export_sequence.nla_state.frame_end = action.frame_end - export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action.action]) - export_sequence.compression_ratio = action.action.psa_export.compression_ratio - export_sequence.key_quota = action.action.psa_export.key_quota + export_sequence.nla_state.action = action_item.action + export_sequence.name = action_item.name + export_sequence.nla_state.frame_start = action_item.frame_start + export_sequence.nla_state.frame_end = action_item.frame_end + export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action_item.action]) + export_sequence.compression_ratio = action_item.action.psa_export.compression_ratio + export_sequence.key_quota = action_item.action.psa_export.key_quota export_sequences.append(export_sequence) elif pg.sequence_source == 'TIMELINE_MARKERS': - for marker in pg.marker_list: + for marker_item in filter(lambda x: x.is_selected, pg.marker_list): export_sequence = PsaBuildSequence() - export_sequence.name = marker.name + export_sequence.name = marker_item.name export_sequence.nla_state.action = None - export_sequence.nla_state.frame_start = marker.frame_start - export_sequence.nla_state.frame_end = marker.frame_end + export_sequence.nla_state.frame_start = marker_item.frame_start + export_sequence.nla_state.frame_end = marker_item.frame_end nla_strips_actions = set( - map(lambda x: x.action, get_nla_strips_in_timeframe(animation_data, marker.frame_start, marker.frame_end))) + map(lambda x: x.action, get_nla_strips_in_frame_range(animation_data, marker_item.frame_start, marker_item.frame_end))) export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, nla_strips_actions) export_sequences.append(export_sequence) + elif pg.sequence_source == 'NLA_TRACK_STRIPS': + for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list): + export_sequence = PsaBuildSequence() + export_sequence.name = nla_strip_item.name + export_sequence.nla_state.action = None + export_sequence.nla_state.frame_start = nla_strip_item.frame_start + export_sequence.nla_state.frame_end = nla_strip_item.frame_end + export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [nla_strip_item.action]) + export_sequences.append(export_sequence) else: raise ValueError(f'Unhandled sequence source: {pg.sequence_source}') @@ -431,6 +438,8 @@ class PSA_OT_export_actions_select_all(Operator): return pg.action_list elif pg.sequence_source == 'TIMELINE_MARKERS': return pg.marker_list + elif pg.sequence_source == 'NLA_TRACK_STRIPS': + return pg.nla_strip_list return None @classmethod @@ -462,6 +471,8 @@ class PSA_OT_export_actions_deselect_all(Operator): return pg.action_list elif pg.sequence_source == 'TIMELINE_MARKERS': return pg.marker_list + elif pg.sequence_source == 'NLA_TRACK_STRIPS': + return pg.nla_strip_list return None @classmethod diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index 6534cbb..91e6480 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -1,10 +1,11 @@ +import re import sys from fnmatch import fnmatch -from typing import List +from typing import List, Optional from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \ StringProperty -from bpy.types import PropertyGroup, Object, Action +from bpy.types import PropertyGroup, Object, Action, AnimData, Context from ...types import PSX_PG_bone_collection_list_item @@ -25,7 +26,7 @@ class PSA_PG_export_action_list_item(PropertyGroup): is_pose_marker: BoolProperty(options={'HIDDEN'}) -class PSA_PG_export_timeline_markers(PropertyGroup): +class PSA_PG_export_timeline_markers(PropertyGroup): # TODO: rename this to singular marker_index: IntProperty() name: StringProperty() is_selected: BoolProperty(default=True) @@ -33,6 +34,51 @@ class PSA_PG_export_timeline_markers(PropertyGroup): frame_end: IntProperty(options={'HIDDEN'}) +class PSA_PG_export_nla_strip_list_item(PropertyGroup): + name: StringProperty() + action: PointerProperty(type=Action) + frame_start: FloatProperty() + frame_end: FloatProperty() + is_selected: BoolProperty(default=True) + + +def nla_track_update_cb(self: 'PSA_PG_export', context: Context) -> None: + self.nla_strip_list.clear() + if context.object is None or context.object.animation_data is None: + return + match = re.match(r'^(\d+).+$', self.nla_track) + self.nla_track_index = int(match.group(1)) if match else -1 + if self.nla_track_index >= 0: + nla_track = context.object.animation_data.nla_tracks[self.nla_track_index] + for nla_strip in nla_track.strips: + strip: PSA_PG_export_nla_strip_list_item = self.nla_strip_list.add() + strip.action = nla_strip.action + strip.name = nla_strip.name + strip.frame_start = nla_strip.frame_start + strip.frame_end = nla_strip.frame_end + + +def get_animation_data(pg: 'PSA_PG_export', context: Context) -> Optional[AnimData]: + animation_data_object = context.object + if pg.should_override_animation_data: + animation_data_object = pg.animation_data_override + return animation_data_object.animation_data if animation_data_object else None + + +def nla_track_search_cb(self, context: Context, edit_text: str): + pg = getattr(context.scene, 'psa_export') + animation_data = get_animation_data(pg, context) + if animation_data is None: + return + for index, nla_track in enumerate(animation_data.nla_tracks): + yield f'{index} - {nla_track.name}' + + +def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context): + # Reset NLA track selection + self.nla_track = '' + + class PSA_PG_export(PropertyGroup): root_motion: BoolProperty( name='Root Motion', @@ -46,10 +92,12 @@ class PSA_PG_export(PropertyGroup): name='Override Animation Data', options=empty_set, default=False, - description='Use the animation data from a different object instead of the selected object' + description='Use the animation data from a different object instead of the selected object', + update=animation_data_override_update_cb, ) animation_data_override: PointerProperty( type=Object, + update=animation_data_override_update_cb, poll=psa_export_property_group_animation_data_override_poll ) sequence_source: EnumProperty( @@ -58,10 +106,18 @@ class PSA_PG_export(PropertyGroup): description='', items=( ('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0), - ('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers', 'MARKER_HLT', - 1), + ('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences are delineated by scene timeline markers', 'MARKER_HLT', 1), + ('NLA_TRACK_STRIPS', 'NLA Track Strips', 'Sequences are delineated by the start & end times of strips on the selected NLA track', 'NLA', 2) ) ) + nla_track: StringProperty( + name='NLA Track', + options=empty_set, + description='', + search=nla_track_search_cb, + update=nla_track_update_cb + ) + nla_track_index: IntProperty(name='NLA Track Index', default=-1) fps_source: EnumProperty( name='FPS Source', options=empty_set, @@ -80,6 +136,8 @@ class PSA_PG_export(PropertyGroup): action_list_index: IntProperty(default=0) marker_list: CollectionProperty(type=PSA_PG_export_timeline_markers) marker_list_index: IntProperty(default=0) + nla_strip_list: CollectionProperty(type=PSA_PG_export_nla_strip_list_item) + nla_strip_list_index: IntProperty(default=0) bone_filter_mode: EnumProperty( name='Bone Filter', options=empty_set, @@ -145,7 +203,7 @@ def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]: if not pg.sequence_filter_asset: for i, sequence in enumerate(sequences): - if hasattr(sequence, 'action') and sequence.action.asset_data is not None: + if hasattr(sequence, 'action') and sequence.action is not None and sequence.action.asset_data is not None: flt_flags[i] &= ~bitflag_filter_item if not pg.sequence_filter_pose_marker: @@ -164,5 +222,6 @@ def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]: classes = ( PSA_PG_export_action_list_item, PSA_PG_export_timeline_markers, + PSA_PG_export_nla_strip_list_item, PSA_PG_export, ) diff --git a/io_scene_psk_psa/psa/export/ui.py b/io_scene_psk_psa/psa/export/ui.py index ad9d29a..d302265 100644 --- a/io_scene_psk_psa/psa/export/ui.py +++ b/io_scene_psk_psa/psa/export/ui.py @@ -16,7 +16,7 @@ class PSA_UL_export_sequences(UIList): 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: + if hasattr(item, 'action') and item.action is not None and item.action.asset_data is not None: layout.label(text='', icon='ASSET_MANAGER') row = layout.row(align=True) diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 1f14779..366410a 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -88,7 +88,11 @@ def build_psk(context, options: PskBuildOptions) -> Psk: for bone in bones: psk_bone = Psk.Bone() - psk_bone.name = bytes(bone.name, encoding='windows-1252') + try: + psk_bone.name = bytes(bone.name, encoding='windows-1252') + except UnicodeEncodeError: + raise RuntimeError( + f'Bone name "{bone.name}" contains characters that cannot be encoded in the Windows-1252 codepage') psk_bone.flags = 0 psk_bone.children_count = 0 @@ -129,14 +133,17 @@ def build_psk(context, options: PskBuildOptions) -> Psk: for material_name in material_names: psk_material = Psk.Material() - psk_material.name = bytes(material_name, encoding='windows-1252') + try: + psk_material.name = bytes(material_name, encoding='windows-1252') + except UnicodeEncodeError: + raise RuntimeError(f'Material name "{material_name}" contains characters that cannot be encoded in the Windows-1252 codepage') psk_material.texture_index = len(psk.materials) psk.materials.append(psk_material) for input_mesh_object in input_objects.mesh_objects: # MATERIALS - material_indices = [material_names.index(material.name) for material in input_mesh_object.data.materials] + material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots] # MESH DATA if options.use_raw_mesh_data: diff --git a/io_scene_psk_psa/psk/export/operators.py b/io_scene_psk_psa/psk/export/operators.py index 9605b70..d2ad36c 100644 --- a/io_scene_psk_psa/psk/export/operators.py +++ b/io_scene_psk_psa/psk/export/operators.py @@ -22,10 +22,11 @@ def populate_material_list(mesh_objects, material_list): material_names = [] for mesh_object in mesh_objects: - for i, material in enumerate(mesh_object.data.materials): + for i, material_slot in enumerate(mesh_object.material_slots): + material = material_slot.material # TODO: put this in the poll arg? if material is None: - raise RuntimeError('Material cannot be empty (index ' + str(i) + ')') + raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')') if material.name not in material_names: material_names.append(material.name) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 88d518b..0696edd 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -131,7 +131,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: # Material already exists, just re-use it. material = bpy.data.materials[material_name] elif is_bdk_addon_loaded() and psk.has_material_references: - # Material does not yet exist and we have the BDK addon installed. + # Material does not yet exist, and we have the BDK addon installed. # Attempt to load it using BDK addon's operator. material_reference = psk.material_references[material_index] if material_reference and bpy.ops.bdk.link_material(reference=material_reference) == {'FINISHED'}: