Major refactoring pass on the PSA file structure & naming

This commit is contained in:
Colin Basnett
2023-07-29 16:00:53 -07:00
parent 25bf8f2087
commit 782c210f04
15 changed files with 871 additions and 836 deletions

View File

@@ -1,7 +1,7 @@
bl_info = { bl_info = {
"name": "PSK/PSA Importer/Exporter", "name": "PSK/PSA Importer/Exporter",
"author": "Colin Basnett, Yurii Ti", "author": "Colin Basnett, Yurii Ti",
"version": (5, 0, 0), "version": (5, 0, 1),
"blender": (3, 4, 0), "blender": (3, 4, 0),
"description": "PSK/PSA Import/Export (.psk/.psa)", "description": "PSK/PSA Import/Export (.psk/.psa)",
"warning": "", "warning": "",
@@ -16,16 +16,23 @@ if 'bpy' in locals():
importlib.reload(psx_data) importlib.reload(psx_data)
importlib.reload(psx_helpers) importlib.reload(psx_helpers)
importlib.reload(psx_types) importlib.reload(psx_types)
importlib.reload(psk_data) importlib.reload(psk_data)
importlib.reload(psk_builder) importlib.reload(psk_builder)
importlib.reload(psk_exporter) importlib.reload(psk_exporter)
importlib.reload(psk_importer) importlib.reload(psk_importer)
importlib.reload(psk_reader) importlib.reload(psk_reader)
importlib.reload(psa_data) importlib.reload(psa_data)
importlib.reload(psa_builder)
importlib.reload(psa_exporter)
importlib.reload(psa_reader) 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: else:
# if i remove this line, it can be enabled just fine # if i remove this line, it can be enabled just fine
from . import data as psx_data from . import data as psx_data
@@ -36,20 +43,31 @@ else:
from .psk import exporter as psk_exporter from .psk import exporter as psk_exporter
from .psk import reader as psk_reader from .psk import reader as psk_reader
from .psk import importer as psk_importer from .psk import importer as psk_importer
from .psa import data as psa_data 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 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 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 import bpy
from bpy.props import PointerProperty from bpy.props import PointerProperty
classes = psx_types.classes +\ classes = psx_types.classes +\
psk_importer.classes +\ psk_importer.classes +\
psk_exporter.classes +\ psk_exporter.classes +\
psa_exporter.classes +\ psa_export_properties.classes +\
psa_importer.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): 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): 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): 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(): def register():
@@ -75,8 +93,8 @@ def register():
bpy.types.TOPBAR_MT_file_import.append(psk_import_menu_func) 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_export.append(psa_export_menu_func)
bpy.types.TOPBAR_MT_file_import.append(psa_import_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_import = PointerProperty(type=psa_import_properties.PSA_PG_import)
bpy.types.Scene.psa_export = PointerProperty(type=psa_exporter.PsaExportPropertyGroup) 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.Scene.psk_export = PointerProperty(type=psk_exporter.PskExportPropertyGroup)
bpy.types.Action.psa_export = PointerProperty(type=psx_types.PSX_PG_ActionExportPropertyGroup) bpy.types.Action.psa_export = PointerProperty(type=psx_types.PSX_PG_ActionExportPropertyGroup)

View File

@@ -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

View File

@@ -6,7 +6,7 @@ from .data import *
from ..helpers import * from ..helpers import *
class PsaExportSequence: class PsaBuildSequence:
class NlaState: class NlaState:
def __init__(self): def __init__(self):
self.action: Optional[Action] = None self.action: Optional[Action] = None
@@ -15,7 +15,7 @@ class PsaExportSequence:
def __init__(self): def __init__(self):
self.name: str = '' self.name: str = ''
self.nla_state: PsaExportSequence.NlaState = PsaExportSequence.NlaState() self.nla_state: PsaBuildSequence.NlaState = PsaBuildSequence.NlaState()
self.compression_ratio: float = 1.0 self.compression_ratio: float = 1.0
self.key_quota: int = 0 self.key_quota: int = 0
self.fps: float = 30.0 self.fps: float = 30.0
@@ -24,7 +24,7 @@ class PsaExportSequence:
class PsaBuildOptions: class PsaBuildOptions:
def __init__(self): def __init__(self):
self.animation_data: Optional[AnimData] = None self.animation_data: Optional[AnimData] = None
self.sequences: List[PsaExportSequence] = [] self.sequences: List[PsaBuildSequence] = []
self.bone_filter_mode: str = 'ALL' self.bone_filter_mode: str = 'ALL'
self.bone_group_indices: List[int] = [] self.bone_group_indices: List[int] = []
self.should_ignore_bone_name_restrictions: bool = False self.should_ignore_bone_name_restrictions: bool = False
@@ -33,7 +33,7 @@ class PsaBuildOptions:
self.root_motion: bool = False 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: if pose_bone.parent is not None:
pose_bone_matrix = pose_bone.matrix pose_bone_matrix = pose_bone.matrix
pose_bone_parent_matrix = pose_bone.parent.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. # We actually alter the timeline frame and simply record the resultant pose bone matrices.
frame_start_index = 0 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. # Link the action to the animation data and update view layer.
options.animation_data.action = export_sequence.nla_state.action options.animation_data.action = export_sequence.nla_state.action
context.view_layer.update() 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.fps = frame_count / sequence_duration
psa_sequence.bone_count = len(pose_bones) psa_sequence.bone_count = len(pose_bones)
psa_sequence.track_time = frame_count psa_sequence.track_time = frame_count
psa_sequence.key_reduction = 1.0
frame = float(frame_start) 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) context.scene.frame_set(frame=int(frame), subframe=frame % 1.0)
for pose_bone in pose_bones: 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 = Psa.Key()
key.location.x = location.x 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 psa.sequences[export_sequence.name] = psa_sequence
context.window_manager.progress_update(export_sequence_index)
# Restore the previous action & frame. # Restore the previous action & frame.
options.animation_data.action = saved_action options.animation_data.action = saved_action
context.scene.frame_set(saved_frame_current) context.scene.frame_set(saved_frame_current)
context.window_manager.progress_end()
return psa return psa

View File

View File

@@ -1,220 +1,17 @@
import fnmatch import re
import sys from collections import Counter
from typing import Type, Dict from typing import List, Iterable, Dict, Tuple
import bpy import bpy
from bpy.props import BoolProperty, CollectionProperty, EnumProperty, FloatProperty, IntProperty, PointerProperty, \ from bpy.props import StringProperty
StringProperty from bpy.types import Context, Armature, Action, Object, AnimData, TimelineMarker
from bpy.types import Action, Operator, PropertyGroup, UIList, Context, Armature, TimelineMarker
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from bpy_types import Operator
from .builder import PsaBuildOptions, PsaExportSequence, build_psa from io_scene_psk_psa.helpers import populate_bone_group_list, get_nla_strips_in_timeframe
from .data import * from io_scene_psk_psa.psa.builder import build_psa, PsaBuildSequence, PsaBuildOptions
from ..helpers import * from io_scene_psk_psa.psa.export.properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences
from ..types import BoneGroupListItem from io_scene_psk_psa.psa.writer import write_psa
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}"')
def is_action_for_armature(armature: Armature, action: Action): def is_action_for_armature(armature: Armature, action: Action):
@@ -231,57 +28,6 @@ def is_action_for_armature(armature: Armature, action: Action):
return False 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): def update_actions_and_timeline_markers(context: Context, armature: Armature):
pg = getattr(context.scene, 'psa_export') 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 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_idname = 'psa_export.operator'
bl_label = 'Export' bl_label = 'Export'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
@@ -388,14 +263,14 @@ class PsaExportOperator(Operator, ExportHelper):
# SELECT ALL/NONE # SELECT ALL/NONE
row = layout.row(align=True) row = layout.row(align=True)
row.label(text='Select') row.label(text='Select')
row.operator(PsaExportActionsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT') row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
row.operator(PsaExportActionsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT') row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
# ACTIONS # ACTIONS
if pg.sequence_source == 'ACTIONS': if pg.sequence_source == 'ACTIONS':
rows = max(3, min(len(pg.action_list), 10)) 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 = layout.column()
col.use_property_split = True col.use_property_split = True
@@ -405,7 +280,7 @@ class PsaExportOperator(Operator, ExportHelper):
elif pg.sequence_source == 'TIMELINE_MARKERS': elif pg.sequence_source == 'TIMELINE_MARKERS':
rows = max(3, min(len(pg.marker_list), 10)) 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) rows=rows)
col = layout.column() col = layout.column()
@@ -432,10 +307,10 @@ class PsaExportOperator(Operator, ExportHelper):
if pg.bone_filter_mode == 'BONE_GROUPS': if pg.bone_filter_mode == 'BONE_GROUPS':
row = layout.row(align=True) row = layout.row(align=True)
row.label(text='Select') row.label(text='Select')
row.operator(PsaExportBoneGroupsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT') row.operator(PSA_OT_export_bone_groups_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
row.operator(PsaExportBoneGroupsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT') 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)) 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) rows=rows)
layout.prop(pg, 'should_ignore_bone_name_restrictions') layout.prop(pg, 'should_ignore_bone_name_restrictions')
@@ -459,7 +334,7 @@ class PsaExportOperator(Operator, ExportHelper):
except RuntimeError as e: except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(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 self.armature_object = context.view_layer.objects.active
@@ -488,13 +363,13 @@ class PsaExportOperator(Operator, ExportHelper):
if animation_data is None: if animation_data is None:
raise RuntimeError(f'No animation data for object \'{animation_data_object.name}\'') 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': if pg.sequence_source == 'ACTIONS':
for action in filter(lambda x: x.is_selected, pg.action_list): for action in filter(lambda x: x.is_selected, pg.action_list):
if len(action.action.fcurves) == 0: if len(action.action.fcurves) == 0:
continue continue
export_sequence = PsaExportSequence() export_sequence = PsaBuildSequence()
export_sequence.nla_state.action = action.action export_sequence.nla_state.action = action.action
export_sequence.name = action.name export_sequence.name = action.name
export_sequence.nla_state.frame_start = action.frame_start export_sequence.nla_state.frame_start = action.frame_start
@@ -505,7 +380,7 @@ class PsaExportOperator(Operator, ExportHelper):
export_sequences.append(export_sequence) export_sequences.append(export_sequence)
elif pg.sequence_source == 'TIMELINE_MARKERS': elif pg.sequence_source == 'TIMELINE_MARKERS':
for marker in pg.marker_list: for marker in pg.marker_list:
export_sequence = PsaExportSequence() export_sequence = PsaBuildSequence()
export_sequence.name = marker.name export_sequence.name = marker.name
export_sequence.nla_state.action = None export_sequence.nla_state.action = None
export_sequence.nla_state.frame_start = marker.frame_start export_sequence.nla_state.frame_start = marker.frame_start
@@ -534,91 +409,12 @@ class PsaExportOperator(Operator, ExportHelper):
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
export_psa(psa, self.filepath) write_psa(psa, self.filepath)
return {'FINISHED'} return {'FINISHED'}
def filter_sequences(pg: PsaExportPropertyGroup, sequences) -> List[int]: class PSA_OT_export_actions_select_all(Operator):
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):
bl_idname = 'psa_export.sequences_select_all' bl_idname = 'psa_export.sequences_select_all'
bl_label = 'Select All' bl_label = 'Select All'
bl_description = 'Select all visible sequences' bl_description = 'Select all visible sequences'
@@ -649,7 +445,7 @@ class PsaExportActionsSelectAll(Operator):
return {'FINISHED'} return {'FINISHED'}
class PsaExportActionsDeselectAll(Operator): class PSA_OT_export_actions_deselect_all(Operator):
bl_idname = 'psa_export.sequences_deselect_all' bl_idname = 'psa_export.sequences_deselect_all'
bl_label = 'Deselect All' bl_label = 'Deselect All'
bl_description = 'Deselect all visible sequences' bl_description = 'Deselect all visible sequences'
@@ -678,7 +474,7 @@ class PsaExportActionsDeselectAll(Operator):
return {'FINISHED'} return {'FINISHED'}
class PsaExportBoneGroupsSelectAll(Operator): class PSA_OT_export_bone_groups_select_all(Operator):
bl_idname = 'psa_export.bone_groups_select_all' bl_idname = 'psa_export.bone_groups_select_all'
bl_label = 'Select All' bl_label = 'Select All'
bl_description = 'Select all bone groups' bl_description = 'Select all bone groups'
@@ -698,7 +494,7 @@ class PsaExportBoneGroupsSelectAll(Operator):
return {'FINISHED'} return {'FINISHED'}
class PsaExportBoneGroupsDeselectAll(Operator): class PSA_OT_export_bone_groups_deselect_all(Operator):
bl_idname = 'psa_export.bone_groups_deselect_all' bl_idname = 'psa_export.bone_groups_deselect_all'
bl_label = 'Deselect All' bl_label = 'Deselect All'
bl_description = 'Deselect all bone groups' bl_description = 'Deselect all bone groups'
@@ -719,13 +515,9 @@ class PsaExportBoneGroupsDeselectAll(Operator):
classes = ( classes = (
PsaExportActionListItem, PSA_OT_export,
PsaExportTimelineMarkerListItem, PSA_OT_export_actions_select_all,
PsaExportPropertyGroup, PSA_OT_export_actions_deselect_all,
PsaExportOperator, PSA_OT_export_bone_groups_select_all,
PSA_UL_ExportSequenceList, PSA_OT_export_bone_groups_deselect_all,
PsaExportActionsSelectAll,
PsaExportActionsDeselectAll,
PsaExportBoneGroupsSelectAll,
PsaExportBoneGroupsDeselectAll,
) )

View File

@@ -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,
)

View File

@@ -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,
)

View File

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -1,14 +1,9 @@
import fnmatch
import os
import re
import typing import typing
from typing import List, Optional from typing import List, Optional
import bpy import bpy
import numpy import numpy
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty from bpy.types import FCurve, Object
from bpy.types import Operator, UIList, PropertyGroup, FCurve
from bpy_extras.io_utils import ImportHelper
from mathutils import Vector, Quaternion from mathutils import Vector, Quaternion
from .data import Psa from .data import Psa
@@ -40,7 +35,7 @@ class ImportBone(object):
self.fcurves: List[FCurve] = [] 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. # Convert world-space transforms to local-space transforms.
key_rotation = Quaternion(key_data[0:4]) key_rotation = Quaternion(key_data[0:4])
key_location = Vector(key_data[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 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() result = PsaImportResult()
sequences = map(lambda x: psa_reader.sequences[x], options.sequence_names) sequences = map(lambda x: psa_reader.sequences[x], options.sequence_names)
armature_data = typing.cast(bpy.types.Armature, armature_object.data) 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. # This bone has writeable keyframes for this frame.
key_data = sequence_data_matrix[frame_index, bone_index] key_data = sequence_data_matrix[frame_index, bone_index]
# Calculate the local-space key data for the bone. # 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. # Write the keyframes out.
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float) 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) nla_track.strips.new(name=action.name, start=0, action=action)
return result 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,
)

View File

@@ -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)

View File

@@ -7,7 +7,7 @@ from bpy_extras.io_utils import ExportHelper
from .builder import build_psk, PskBuildOptions, get_psk_input_objects from .builder import build_psk, PskBuildOptions, get_psk_input_objects
from .data import * from .data import *
from ..helpers import populate_bone_group_list 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_WEDGE_COUNT = 65536
MAX_POINT_COUNT = 4294967296 MAX_POINT_COUNT = 4294967296
@@ -202,7 +202,7 @@ class PskExportOperator(Operator, ExportHelper):
if pg.bone_filter_mode == 'BONE_GROUPS': if pg.bone_filter_mode == 'BONE_GROUPS':
row = layout.row() row = layout.row()
rows = max(3, min(len(pg.bone_group_list), 10)) 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() layout.separator()
@@ -249,7 +249,7 @@ class PskExportPropertyGroup(PropertyGroup):
'Only bones belonging to the selected bone groups and their ancestors will be exported.') '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) 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') 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) material_list: CollectionProperty(type=MaterialListItem)

View File

@@ -2,7 +2,7 @@ from bpy.props import StringProperty, IntProperty, BoolProperty, FloatProperty
from bpy.types import PropertyGroup, UIList, UILayout, Context, AnyType, Panel 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, 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): 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') row.label(text=str(getattr(item, 'count')), icon='BONE_DATA')
class BoneGroupListItem(PropertyGroup): class PSX_PG_bone_group_list_item(PropertyGroup):
name: StringProperty() name: StringProperty()
index: IntProperty() index: IntProperty()
count: IntProperty() count: IntProperty()
@@ -44,7 +44,7 @@ class PSX_PT_ActionPropertyPanel(Panel):
classes = ( classes = (
PSX_PG_ActionExportPropertyGroup, PSX_PG_ActionExportPropertyGroup,
BoneGroupListItem, PSX_PG_bone_group_list_item,
PSX_UL_BoneGroupList, PSX_UL_bone_group_list,
PSX_PT_ActionPropertyPanel PSX_PT_ActionPropertyPanel
) )