Moved things around for packaging on Blender extensions
This commit is contained in:
0
psa/import_/__init__.py
Normal file
0
psa/import_/__init__.py
Normal file
277
psa/import_/operators.py
Normal file
277
psa/import_/operators.py
Normal file
@@ -0,0 +1,277 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Operator, Event, Context, FileHandler
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
|
||||
from .properties import get_visible_sequences
|
||||
from ..config import read_psa_config
|
||||
from ..importer import import_psa, PsaImportOptions
|
||||
from ..reader import PsaReader
|
||||
|
||||
|
||||
class PSA_OT_import_sequences_from_text(Operator):
|
||||
bl_idname = 'psa_import.sequences_select_from_text'
|
||||
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'}
|
||||
|
||||
|
||||
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]
|
||||
|
||||
if len(sequence_names) == 0:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected')
|
||||
return {'CANCELLED'}
|
||||
|
||||
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
|
||||
options.fps_source = pg.fps_source
|
||||
options.fps_custom = pg.fps_custom
|
||||
|
||||
if options.should_use_config_file:
|
||||
# Read the PSA config file if it exists.
|
||||
config_path = Path(self.filepath).with_suffix('.config')
|
||||
if config_path.exists():
|
||||
try:
|
||||
options.psa_config = read_psa_config(psa_reader, str(config_path))
|
||||
except Exception as e:
|
||||
self.report({'WARNING'}, f'Failed to read PSA config file: {e}')
|
||||
|
||||
result = import_psa(context, 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'
|
||||
self.report({'WARNING'}, message)
|
||||
for warning in result.warnings:
|
||||
self.report({'WARNING'}, warning)
|
||||
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')
|
||||
|
||||
sequences_header, sequences_panel = layout.panel('sequences_panel_id', default_closed=False)
|
||||
sequences_header.label(text='Sequences')
|
||||
|
||||
if sequences_panel:
|
||||
if pg.psa_error:
|
||||
row = sequences_panel.row()
|
||||
row.label(text='Select a PSA file', icon='ERROR')
|
||||
else:
|
||||
# Select buttons.
|
||||
rows = max(3, min(len(pg.sequence_list), 10))
|
||||
|
||||
row = sequences_panel.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 = sequences_panel.column(heading='')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'fps_source')
|
||||
if pg.fps_source == 'CUSTOM':
|
||||
col.prop(pg, 'fps_custom')
|
||||
col.prop(pg, 'should_overwrite')
|
||||
col.prop(pg, 'should_use_action_name_prefix')
|
||||
if pg.should_use_action_name_prefix:
|
||||
col.prop(pg, 'action_name_prefix')
|
||||
|
||||
data_header, data_panel = layout.panel('data_panel_id', default_closed=False)
|
||||
data_header.label(text='Data')
|
||||
|
||||
if data_panel:
|
||||
col = data_panel.column(heading='Write')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_write_keyframes')
|
||||
col.prop(pg, 'should_write_metadata')
|
||||
|
||||
if pg.should_write_keyframes:
|
||||
col = col.column(heading='Keyframes')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_convert_to_samples')
|
||||
|
||||
advanced_header, advanced_panel = layout.panel('advanced_panel_id', default_closed=True)
|
||||
advanced_header.label(text='Advanced')
|
||||
|
||||
if advanced_panel:
|
||||
col = advanced_panel.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'bone_mapping_mode')
|
||||
|
||||
col = advanced_panel.column(heading='Options')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_use_fake_user')
|
||||
col.prop(pg, 'should_stash')
|
||||
col.prop(pg, 'should_use_config_file')
|
||||
|
||||
|
||||
class PSA_FH_import(FileHandler):
|
||||
bl_idname = 'PSA_FH_import'
|
||||
bl_label = 'File handler for Unreal PSA import'
|
||||
bl_import_operator = 'psa_import.import'
|
||||
bl_file_extensions = '.psa'
|
||||
|
||||
@classmethod
|
||||
def poll_drop(cls, context: Context):
|
||||
return context.area and context.area.type == 'VIEW_3D'
|
||||
|
||||
|
||||
classes = (
|
||||
PSA_OT_import_sequences_select_all,
|
||||
PSA_OT_import_sequences_deselect_all,
|
||||
PSA_OT_import_sequences_from_text,
|
||||
PSA_OT_import,
|
||||
PSA_FH_import,
|
||||
)
|
||||
156
psa/import_/properties.py
Normal file
156
psa/import_/properties.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import re
|
||||
from fnmatch import fnmatch
|
||||
from typing import List
|
||||
|
||||
from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty, PointerProperty, EnumProperty, \
|
||||
FloatProperty
|
||||
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=True, 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_use_config_file: BoolProperty(default=True, name='Use Config File',
|
||||
description='Use the .config file that is sometimes generated when the PSA '
|
||||
'file is exported from UEViewer. This file contains '
|
||||
'options that can be used to filter out certain bones tracks '
|
||||
'from the imported actions',
|
||||
options=empty_set)
|
||||
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),
|
||||
),
|
||||
default='CASE_INSENSITIVE'
|
||||
)
|
||||
fps_source: EnumProperty(name='FPS Source', items=(
|
||||
('SEQUENCE', 'Sequence', 'The sequence frame rate matches the original frame rate', 'ACTION', 0),
|
||||
('SCENE', 'Scene', 'The sequence is resampled to the frame rate of the scene', 'SCENE_DATA', 1),
|
||||
('CUSTOM', 'Custom', 'The sequence is resampled to a custom frame rate', 2),
|
||||
))
|
||||
fps_custom: FloatProperty(
|
||||
default=30.0,
|
||||
name='Custom FPS',
|
||||
description='The frame rate to which the imported sequences will be resampled to',
|
||||
options=empty_set,
|
||||
min=1.0,
|
||||
soft_min=1.0,
|
||||
soft_max=60.0,
|
||||
step=100,
|
||||
)
|
||||
compression_ratio_source: EnumProperty(name='Compression Ratio Source', items=(
|
||||
('ACTION', 'Action', 'The compression ratio is sourced from the action metadata', 'ACTION', 0),
|
||||
('CUSTOM', 'Custom', 'The compression ratio is set to a custom value', 1),
|
||||
))
|
||||
compression_ratio_custom: FloatProperty(
|
||||
default=1.0,
|
||||
name='Custom Compression Ratio',
|
||||
description='The compression ratio to apply to the imported sequences',
|
||||
options=empty_set,
|
||||
min=0.0,
|
||||
soft_min=0.0,
|
||||
soft_max=1.0,
|
||||
step=0.0625,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
45
psa/import_/ui.py
Normal file
45
psa/import_/ui.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user