Major overhaul to Python operator paths

Also started using mixin classes instead of ugly class annotation
ammendments. Various other clean-up items as well including using
generators where appropriate instead of returning full lists etc.

All the Python facing operators are now within `bpy.ops.psk` and
`bpy.ops.psa`. Before, they were scattered in a bunch of different
places because I wasn't really paying attention to consistency.
This commit is contained in:
Colin Basnett
2025-01-17 14:52:31 -08:00
parent 2b41815545
commit fec1a286ef
9 changed files with 327 additions and 292 deletions

View File

@@ -1,9 +1,9 @@
from collections import Counter from collections import Counter
from typing import List, Iterable, Dict, Tuple, cast, Optional from typing import List, Iterable, Dict, Tuple, Optional
import bpy import bpy
from bpy.props import StringProperty from bpy.props import StringProperty
from bpy.types import Context, Armature, Action, Object, AnimData, TimelineMarker from bpy.types import Context, Action, Object, AnimData, TimelineMarker
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from bpy_types import Operator from bpy_types import Operator
@@ -29,15 +29,22 @@ def get_sequences_propnames_from_source(sequence_source: str) -> Optional[Tuple[
raise ValueError(f'Unhandled sequence source: {sequence_source}') raise ValueError(f'Unhandled sequence source: {sequence_source}')
def is_action_for_armature(armature: Armature, action: Action): def is_action_for_object(obj: Object, action: Action):
if len(action.fcurves) == 0: if len(action.fcurves) == 0:
return False return False
if obj.animation_data is None:
return False
if obj.type != 'ARMATURE':
return False
version = SemanticVersion(bpy.app.version) version = SemanticVersion(bpy.app.version)
if version < SemanticVersion((4, 4, 0)): if version < SemanticVersion((4, 4, 0)):
import re import re
bone_names = set([x.name for x in armature.bones]) armature_data = obj.data
bone_names = set([x.name for x in armature_data.bones])
for fcurve in action.fcurves: for fcurve in action.fcurves:
match = re.match(r'pose\.bones\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path) match = re.match(r'pose\.bones\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path)
if not match: if not match:
@@ -46,11 +53,8 @@ def is_action_for_armature(armature: Armature, action: Action):
if bone_name in bone_names: if bone_name in bone_names:
return True return True
else: else:
# Look up the armature by ID and check if its data block pointer matches the armature. # In 4.4.0 and later, we can check if the object's action slot handle matches an action slot handle in the action.
for slot in filter(lambda x: x.id_root == 'OBJECT', action.slots): if any(obj.animation_data.action_slot_handle == slot.handle for slot in action.slots):
# Lop off the 'OB' prefix from the identifier for the lookup.
object = bpy.data.objects.get(slot.identifier[2:], None)
if object and object.data == armature:
return True return True
return False return False
@@ -71,15 +75,13 @@ def update_actions_and_timeline_markers(context: Context):
if animation_data is None: if animation_data is None:
return return
active_armature = cast(Armature, context.active_object.data)
# Populate actions list. # Populate actions list.
for action in bpy.data.actions: for action in bpy.data.actions:
if not is_action_for_armature(active_armature, action): if not is_action_for_object(context.active_object, action):
continue continue
if action.name != '' and not action.name.startswith('#'):
for (name, frame_start, frame_end) in get_sequences_from_action(action): for (name, frame_start, frame_end) in get_sequences_from_action(action):
print(name)
item = pg.action_list.add() item = pg.action_list.add()
item.action = action item.action = action
item.name = name item.name = name
@@ -217,13 +219,15 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context:
return sequence_frame_ranges return sequence_frame_ranges
def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]: def get_sequences_from_action(action: Action):
if action.name == '' or action.name.startswith('#'):
return
frame_start = int(action.frame_range[0]) frame_start = int(action.frame_range[0])
frame_end = int(action.frame_range[1]) frame_end = int(action.frame_range[1])
return get_sequences_from_name_and_frame_range(action.name, frame_start, frame_end) yield from get_sequences_from_name_and_frame_range(action.name, frame_start, frame_end)
def get_sequences_from_action_pose_markers(action: Action, pose_markers: List[TimelineMarker], pose_marker: TimelineMarker, pose_marker_index: int) -> List[Tuple[str, int, int]]: def get_sequences_from_action_pose_markers(action: Action, pose_markers: List[TimelineMarker], pose_marker: TimelineMarker, pose_marker_index: int):
frame_start = pose_marker.frame frame_start = pose_marker.frame
sequence_name = pose_marker.name sequence_name = pose_marker.name
if pose_marker.name.startswith('!'): if pose_marker.name.startswith('!'):
@@ -234,7 +238,7 @@ def get_sequences_from_action_pose_markers(action: Action, pose_markers: List[Ti
frame_end = pose_markers[pose_marker_index + 1].frame frame_end = pose_markers[pose_marker_index + 1].frame
else: else:
frame_end = int(action.frame_range[1]) frame_end = int(action.frame_range[1])
return get_sequences_from_name_and_frame_range(sequence_name, frame_start, frame_end) yield from get_sequences_from_name_and_frame_range(sequence_name, frame_start, frame_end)
def get_visible_sequences(pg: PSA_PG_export, sequences) -> List[PSA_PG_export_action_list_item]: def get_visible_sequences(pg: PSA_PG_export, sequences) -> List[PSA_PG_export_action_list_item]:
@@ -246,7 +250,7 @@ def get_visible_sequences(pg: PSA_PG_export, sequences) -> List[PSA_PG_export_ac
class PSA_OT_export(Operator, ExportHelper): class PSA_OT_export(Operator, ExportHelper):
bl_idname = 'psa_export.operator' bl_idname = 'psa.export'
bl_label = 'Export' bl_label = 'Export'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
bl_description = 'Export actions to PSA' bl_description = 'Export actions to PSA'
@@ -515,7 +519,7 @@ class PSA_OT_export(Operator, ExportHelper):
class PSA_OT_export_actions_select_all(Operator): class PSA_OT_export_actions_select_all(Operator):
bl_idname = 'psa_export.sequences_select_all' bl_idname = 'psa.export_actions_select_all'
bl_label = 'Select All' bl_label = 'Select All'
bl_description = 'Select all visible sequences' bl_description = 'Select all visible sequences'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
@@ -552,7 +556,7 @@ class PSA_OT_export_actions_select_all(Operator):
class PSA_OT_export_actions_deselect_all(Operator): class PSA_OT_export_actions_deselect_all(Operator):
bl_idname = 'psa_export.sequences_deselect_all' bl_idname = 'psa.export_sequences_deselect_all'
bl_label = 'Deselect All' bl_label = 'Deselect All'
bl_description = 'Deselect all visible sequences' bl_description = 'Deselect all visible sequences'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
@@ -587,7 +591,7 @@ class PSA_OT_export_actions_deselect_all(Operator):
class PSA_OT_export_bone_collections_select_all(Operator): class PSA_OT_export_bone_collections_select_all(Operator):
bl_idname = 'psa_export.bone_collections_select_all' bl_idname = 'psa.export_bone_collections_select_all'
bl_label = 'Select All' bl_label = 'Select All'
bl_description = 'Select all bone collections' bl_description = 'Select all bone collections'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
@@ -607,7 +611,7 @@ class PSA_OT_export_bone_collections_select_all(Operator):
class PSA_OT_export_bone_collections_deselect_all(Operator): class PSA_OT_export_bone_collections_deselect_all(Operator):
bl_idname = 'psa_export.bone_collections_deselect_all' bl_idname = 'psa.export_bone_collections_deselect_all'
bl_label = 'Deselect All' bl_label = 'Deselect All'
bl_description = 'Deselect all bone collections' bl_description = 'Deselect all bone collections'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}

View File

@@ -1,7 +1,7 @@
import re import re
import sys import sys
from fnmatch import fnmatch from fnmatch import fnmatch
from typing import List, Optional, Tuple from typing import List, Optional
from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \ from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \
StringProperty StringProperty
@@ -52,18 +52,16 @@ class PSA_PG_export_nla_strip_list_item(PropertyGroup):
is_selected: BoolProperty(default=True) is_selected: BoolProperty(default=True)
def get_sequences_from_name_and_frame_range(name: str, frame_start: int, frame_end: int) -> List[Tuple[str, int, int]]: def get_sequences_from_name_and_frame_range(name: str, frame_start: int, frame_end: int):
reversed_pattern = r'(.+)/(.+)' reversed_pattern = r'(.+)/(.+)'
reversed_match = re.match(reversed_pattern, name) reversed_match = re.match(reversed_pattern, name)
if reversed_match: if reversed_match:
forward_name = reversed_match.group(1) forward_name = reversed_match.group(1)
backwards_name = reversed_match.group(2) backwards_name = reversed_match.group(2)
return [ yield forward_name, frame_start, frame_end
(forward_name, frame_start, frame_end), yield backwards_name, frame_end, frame_start
(backwards_name, frame_end, frame_start)
]
else: else:
return [(name, frame_start, frame_end)] yield name, frame_start, frame_end
def nla_track_update_cb(self: 'PSA_PG_export', context: Context) -> None: def nla_track_update_cb(self: 'PSA_PG_export', context: Context) -> None:

View File

@@ -1,19 +1,27 @@
import os import os
from pathlib import Path from pathlib import Path
from typing import List from typing import Iterable
from bpy.props import StringProperty, CollectionProperty from bpy.props import StringProperty, CollectionProperty
from bpy.types import Operator, Event, Context, FileHandler, OperatorFileListElement, Object from bpy.types import Operator, Event, Context, FileHandler, OperatorFileListElement, Object
from bpy_extras.io_utils import ImportHelper from bpy_extras.io_utils import ImportHelper
from .properties import get_visible_sequences from .properties import get_visible_sequences, PsaImportMixin
from ..config import read_psa_config from ..config import read_psa_config
from ..importer import import_psa, PsaImportOptions from ..importer import import_psa, PsaImportOptions
from ..reader import PsaReader from ..reader import PsaReader
class PSA_OT_import_sequences_from_text(Operator): def psa_import_poll(cls, context: Context):
bl_idname = 'psa_import.sequences_select_from_text' active_object = context.view_layer.objects.active
if active_object is None or active_object.type != 'ARMATURE':
cls.poll_message_set('The active object must be an armature')
return False
return True
class PSA_OT_import_sequences_select_from_text(Operator):
bl_idname = 'psa.import_sequences_select_from_text'
bl_label = 'Select By Text List' bl_label = 'Select By Text List'
bl_description = 'Select sequences by name from text list' bl_description = 'Select sequences by name from text list'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
@@ -49,7 +57,7 @@ class PSA_OT_import_sequences_from_text(Operator):
class PSA_OT_import_sequences_select_all(Operator): class PSA_OT_import_sequences_select_all(Operator):
bl_idname = 'psa_import.sequences_select_all' bl_idname = 'psa.import_sequences_select_all'
bl_label = 'All' bl_label = 'All'
bl_description = 'Select all sequences' bl_description = 'Select all sequences'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
@@ -70,7 +78,7 @@ class PSA_OT_import_sequences_select_all(Operator):
class PSA_OT_import_sequences_deselect_all(Operator): class PSA_OT_import_sequences_deselect_all(Operator):
bl_idname = 'psa_import.sequences_deselect_all' bl_idname = 'psa.import_sequences_deselect_all'
bl_label = 'None' bl_label = 'None'
bl_description = 'Deselect all visible sequences' bl_description = 'Deselect all visible sequences'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
@@ -113,8 +121,8 @@ def on_psa_file_path_updated(cls, context):
load_psa_file(context, cls.filepath) load_psa_file(context, cls.filepath)
class PSA_OT_import_multiple(Operator): class PSA_OT_import_drag_and_drop(Operator, PsaImportMixin):
bl_idname = 'psa_import.import_multiple' bl_idname = 'psa.import_drag_and_drop'
bl_label = 'Import PSA' bl_label = 'Import PSA'
bl_description = 'Import multiple PSA files' bl_description = 'Import multiple PSA files'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
@@ -122,23 +130,25 @@ class PSA_OT_import_multiple(Operator):
directory: StringProperty(subtype='FILE_PATH', options={'SKIP_SAVE', 'HIDDEN'}) directory: StringProperty(subtype='FILE_PATH', options={'SKIP_SAVE', 'HIDDEN'})
files: CollectionProperty(type=OperatorFileListElement, options={'SKIP_SAVE', 'HIDDEN'}) files: CollectionProperty(type=OperatorFileListElement, options={'SKIP_SAVE', 'HIDDEN'})
def execute(self, context): def execute(self, context):
pg = getattr(context.scene, 'psa_import')
warnings = [] warnings = []
sequence_names = []
for file in self.files: for file in self.files:
psa_path = os.path.join(self.directory, file.name) psa_path = str(os.path.join(self.directory, file.name))
psa_reader = PsaReader(psa_path) psa_reader = PsaReader(psa_path)
sequence_names = list(psa_reader.sequences.keys()) file_sequence_names = list(psa_reader.sequences.keys())
options = psa_import_options_from_property_group(self, file_sequence_names)
result = _import_psa(context, pg, psa_path, sequence_names, context.view_layer.objects.active) sequence_names.extend(file_sequence_names)
result.warnings.extend(warnings)
if len(result.warnings) > 0: result = _import_psa(context, options, psa_path, context.view_layer.objects.active)
message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n' warnings.extend(result.warnings)
if len(warnings) > 0:
message = f'Imported {len(sequence_names)} action(s) with {len(warnings)} warning(s)\n'
self.report({'INFO'}, message) self.report({'INFO'}, message)
for warning in result.warnings: for warning in warnings:
self.report({'WARNING'}, warning) self.report({'WARNING'}, warning)
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)') self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
@@ -146,7 +156,7 @@ class PSA_OT_import_multiple(Operator):
return {'FINISHED'} return {'FINISHED'}
def invoke(self, context: Context, event): def invoke(self, context: Context, event):
# Make sure the selected object is an armature. # Make sure the selected object is an obj.
active_object = context.view_layer.objects.active active_object = context.view_layer.objects.active
if active_object is None or active_object.type != 'ARMATURE': if active_object is None or active_object.type != 'ARMATURE':
self.report({'ERROR_INVALID_CONTEXT'}, 'The active object must be an armature') self.report({'ERROR_INVALID_CONTEXT'}, 'The active object must be an armature')
@@ -158,18 +168,12 @@ class PSA_OT_import_multiple(Operator):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
pg = getattr(context.scene, 'psa_import') draw_psa_import_options_no_panels(layout, self)
draw_psa_import_options_no_panels(layout, pg)
def _import_psa(context, def psa_import_options_from_property_group(pg: PsaImportMixin, sequence_names: Iterable[str]) -> PsaImportOptions:
pg,
filepath: str,
sequence_names: List[str],
armature_object: Object
):
options = PsaImportOptions() options = PsaImportOptions()
options.sequence_names = sequence_names options.sequence_names = list(sequence_names)
options.should_use_fake_user = pg.should_use_fake_user options.should_use_fake_user = pg.should_use_fake_user
options.should_stash = pg.should_stash options.should_stash = pg.should_stash
options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else '' options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else ''
@@ -181,7 +185,14 @@ def _import_psa(context,
options.fps_source = pg.fps_source options.fps_source = pg.fps_source
options.fps_custom = pg.fps_custom options.fps_custom = pg.fps_custom
options.translation_scale = pg.translation_scale options.translation_scale = pg.translation_scale
return options
def _import_psa(context,
options: PsaImportOptions,
filepath: str,
armature_object: Object
):
warnings = [] warnings = []
if options.should_use_config_file: if options.should_use_config_file:
@@ -189,7 +200,7 @@ def _import_psa(context,
config_path = Path(filepath).with_suffix('.config') config_path = Path(filepath).with_suffix('.config')
if config_path.exists(): if config_path.exists():
try: try:
options.psa_config = read_psa_config(sequence_names, str(config_path)) options.psa_config = read_psa_config(options.sequence_names, str(config_path))
except Exception as e: except Exception as e:
warnings.append(f'Failed to read PSA config file: {e}') warnings.append(f'Failed to read PSA config file: {e}')
@@ -201,8 +212,8 @@ def _import_psa(context,
return result return result
class PSA_OT_import(Operator, ImportHelper): class PSA_OT_import(Operator, ImportHelper, PsaImportMixin):
bl_idname = 'psa_import.import' bl_idname = 'psa.import'
bl_label = 'Import' bl_label = 'Import'
bl_description = 'Import the selected animations into the scene as actions' bl_description = 'Import the selected animations into the scene as actions'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
@@ -218,29 +229,25 @@ class PSA_OT_import(Operator, ImportHelper):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
active_object = context.view_layer.objects.active return psa_import_poll(cls, context)
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): def execute(self, context):
pg = getattr(context.scene, 'psa_import') pg = getattr(context.scene, 'psa_import')
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected] options = psa_import_options_from_property_group(self, [x.action_name for x in pg.sequence_list if x.is_selected])
if len(sequence_names) == 0: if len(options.sequence_names) == 0:
self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected') self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected')
return {'CANCELLED'} return {'CANCELLED'}
result = _import_psa(context, pg, self.filepath, sequence_names, context.view_layer.objects.active) result = _import_psa(context, options, self.filepath, context.view_layer.objects.active)
if len(result.warnings) > 0: if len(result.warnings) > 0:
message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n' message = f'Imported {len(options.sequence_names)} action(s) with {len(result.warnings)} warning(s)\n'
self.report({'WARNING'}, message) self.report({'WARNING'}, message)
for warning in result.warnings: for warning in result.warnings:
self.report({'WARNING'}, warning) self.report({'WARNING'}, warning)
else: else:
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)') self.report({'INFO'}, f'Imported {len(options.sequence_names)} action(s)')
return {'FINISHED'} return {'FINISHED'}
@@ -271,7 +278,7 @@ class PSA_OT_import(Operator, ImportHelper):
row2 = col.row(align=True) row2 = col.row(align=True)
row2.label(text='Select') row2.label(text='Select')
row2.operator(PSA_OT_import_sequences_from_text.bl_idname, text='', icon='TEXT') row2.operator(PSA_OT_import_sequences_select_from_text.bl_idname, text='', icon='TEXT')
row2.operator(PSA_OT_import_sequences_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') row2.operator(PSA_OT_import_sequences_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
row2.operator(PSA_OT_import_sequences_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') row2.operator(PSA_OT_import_sequences_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
@@ -281,13 +288,13 @@ class PSA_OT_import(Operator, ImportHelper):
col = sequences_panel.column(heading='') col = sequences_panel.column(heading='')
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'fps_source') col.prop(self, 'fps_source')
if pg.fps_source == 'CUSTOM': if self.fps_source == 'CUSTOM':
col.prop(pg, 'fps_custom') col.prop(self, 'fps_custom')
col.prop(pg, 'should_overwrite') col.prop(self, 'should_overwrite')
col.prop(pg, 'should_use_action_name_prefix') col.prop(self, 'should_use_action_name_prefix')
if pg.should_use_action_name_prefix: if self.should_use_action_name_prefix:
col.prop(pg, 'action_name_prefix') col.prop(self, 'action_name_prefix')
data_header, data_panel = layout.panel('data_panel_id', default_closed=False) data_header, data_panel = layout.panel('data_panel_id', default_closed=False)
data_header.label(text='Data') data_header.label(text='Data')
@@ -296,14 +303,14 @@ class PSA_OT_import(Operator, ImportHelper):
col = data_panel.column(heading='Write') col = data_panel.column(heading='Write')
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'should_write_keyframes') col.prop(self, 'should_write_keyframes')
col.prop(pg, 'should_write_metadata') col.prop(self, 'should_write_metadata')
if pg.should_write_keyframes: if self.should_write_keyframes:
col = col.column(heading='Keyframes') col = col.column(heading='Keyframes')
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'should_convert_to_samples') col.prop(self, 'should_convert_to_samples')
advanced_header, advanced_panel = layout.panel('advanced_panel_id', default_closed=True) advanced_header, advanced_panel = layout.panel('advanced_panel_id', default_closed=True)
advanced_header.label(text='Advanced') advanced_header.label(text='Advanced')
@@ -312,22 +319,22 @@ class PSA_OT_import(Operator, ImportHelper):
col = advanced_panel.column() col = advanced_panel.column()
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'bone_mapping_mode') col.prop(self, 'bone_mapping_mode')
col = advanced_panel.column() col = advanced_panel.column()
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'translation_scale', text='Translation Scale') col.prop(self, 'translation_scale', text='Translation Scale')
col = advanced_panel.column(heading='Options') col = advanced_panel.column(heading='Options')
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'should_use_fake_user') col.prop(self, 'should_use_fake_user')
col.prop(pg, 'should_stash') col.prop(self, 'should_stash')
col.prop(pg, 'should_use_config_file') col.prop(self, 'should_use_config_file')
def draw_psa_import_options_no_panels(layout, pg): def draw_psa_import_options_no_panels(layout, pg: PsaImportMixin):
col = layout.column(heading='Sequences') col = layout.column(heading='Sequences')
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
@@ -365,11 +372,11 @@ def draw_psa_import_options_no_panels(layout, pg):
col.prop(pg, 'should_use_config_file') col.prop(pg, 'should_use_config_file')
class PSA_FH_import(FileHandler): class PSA_FH_import(FileHandler): # TODO: rename and add handling for PSA export.
bl_idname = 'PSA_FH_import' bl_idname = 'PSA_FH_import'
bl_label = 'File handler for Unreal PSA import' bl_label = 'File handler for Unreal PSA import'
bl_import_operator = 'psa_import.import_multiple' bl_import_operator = PSA_OT_import_drag_and_drop.bl_idname
bl_export_operator = 'psa_export.export' # bl_export_operator = 'psa_export.export'
bl_file_extensions = '.psa' bl_file_extensions = '.psa'
@classmethod @classmethod
@@ -380,8 +387,8 @@ class PSA_FH_import(FileHandler):
classes = ( classes = (
PSA_OT_import_sequences_select_all, PSA_OT_import_sequences_select_all,
PSA_OT_import_sequences_deselect_all, PSA_OT_import_sequences_deselect_all,
PSA_OT_import_sequences_from_text, PSA_OT_import_sequences_select_from_text,
PSA_OT_import, PSA_OT_import,
PSA_OT_import_multiple, PSA_OT_import_drag_and_drop,
PSA_FH_import, PSA_FH_import,
) )

View File

@@ -23,11 +23,23 @@ class PSA_PG_data(PropertyGroup):
sequence_count: IntProperty(default=0) sequence_count: IntProperty(default=0)
class PSA_PG_import(PropertyGroup): bone_mapping_items = (
psa_error: StringProperty(default='') ('EXACT', 'Exact', 'Bone names must match exactly.', 'EXACT', 0),
psa: PointerProperty(type=PSA_PG_data) ('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),
sequence_list: CollectionProperty(type=PSA_PG_import_action_list_item) )
sequence_list_index: IntProperty(name='', default=0)
fps_source_items = (
('SEQUENCE', 'Sequence', 'The sequence frame rate matches the original frame rate', 'ACTION', 0),
('SCENE', 'Scene', 'The sequence is resampled to the frame rate of the scene', 'SCENE_DATA', 1),
('CUSTOM', 'Custom', 'The sequence is resampled to a custom frame rate', 2),
)
compression_ratio_source_items = (
('ACTION', 'Action', 'The compression ratio is sourced from the action metadata', 'ACTION', 0),
('CUSTOM', 'Custom', 'The compression ratio is set to a custom value', 1),
)
class PsaImportMixin:
should_use_fake_user: BoolProperty(default=True, name='Fake User', should_use_fake_user: BoolProperty(default=True, name='Fake User',
description='Assign each imported action a fake user so that the data block is ' description='Assign each imported action a fake user so that the data block is '
'saved even it has no users', 'saved even it has no users',
@@ -56,7 +68,7 @@ class PSA_PG_import(PropertyGroup):
sequence_use_filter_invert: BoolProperty(default=False, options=empty_set) sequence_use_filter_invert: BoolProperty(default=False, options=empty_set)
sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression', sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression',
description='Filter using regular expressions', options=empty_set) description='Filter using regular expressions', options=empty_set)
select_text: PointerProperty(type=Text)
should_convert_to_samples: BoolProperty( should_convert_to_samples: BoolProperty(
default=False, default=False,
name='Convert to Samples', name='Convert to Samples',
@@ -67,18 +79,10 @@ class PSA_PG_import(PropertyGroup):
name='Bone Mapping', name='Bone Mapping',
options=empty_set, options=empty_set,
description='The method by which bones from the incoming PSA file are mapped to the armature', description='The method by which bones from the incoming PSA file are mapped to the armature',
items=( items=bone_mapping_items,
('EXACT', 'Exact', 'Bone names must match exactly.', 'EXACT', 0),
('CASE_INSENSITIVE', 'Case Insensitive', 'Bones names must match, ignoring case (e.g., the bone PSA bone '
'\'root\' can be mapped to the armature bone \'Root\')', 'CASE_INSENSITIVE', 1),
),
default='CASE_INSENSITIVE' default='CASE_INSENSITIVE'
) )
fps_source: EnumProperty(name='FPS Source', items=( fps_source: EnumProperty(name='FPS Source', items=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( fps_custom: FloatProperty(
default=30.0, default=30.0,
name='Custom FPS', name='Custom FPS',
@@ -89,10 +93,7 @@ class PSA_PG_import(PropertyGroup):
soft_max=60.0, soft_max=60.0,
step=100, step=100,
) )
compression_ratio_source: EnumProperty(name='Compression Ratio Source', items=( compression_ratio_source: EnumProperty(name='Compression Ratio Source', items=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( compression_ratio_custom: FloatProperty(
default=1.0, default=1.0,
name='Custom Compression Ratio', name='Custom Compression Ratio',
@@ -110,6 +111,22 @@ class PSA_PG_import(PropertyGroup):
) )
# This property group lives "globally" in the scene, since Operators cannot have PointerProperty or CollectionProperty
# properties.
class PSA_PG_import(PropertyGroup):
psa_error: StringProperty(default='')
psa: PointerProperty(type=PSA_PG_data)
sequence_list: CollectionProperty(type=PSA_PG_import_action_list_item)
sequence_list_index: IntProperty(name='', default=0)
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected',
description='Only show selected sequences')
sequence_use_filter_invert: BoolProperty(default=False, options=empty_set)
sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression',
description='Filter using regular expressions', options=empty_set)
select_text: PointerProperty(type=Text)
def filter_sequences(pg: PSA_PG_import, sequences) -> List[int]: def filter_sequences(pg: PSA_PG_import, sequences) -> List[int]:
bitflag_filter_item = 1 << 30 bitflag_filter_item = 1 << 30
flt_flags = [bitflag_filter_item] * len(sequences) flt_flags = [bitflag_filter_item] * len(sequences)

View File

@@ -12,21 +12,36 @@ from .reader import PsaReader
class PsaImportOptions(object): class PsaImportOptions(object):
def __init__(self): def __init__(self,
self.should_use_fake_user = False action_name_prefix: str = '',
self.should_stash = False bone_mapping_mode: str = 'CASE_INSENSITIVE',
self.sequence_names = [] fps_custom: float = 30.0,
self.should_overwrite = False fps_source: str = 'SEQUENCE',
self.should_write_keyframes = True psa_config: PsaConfig = PsaConfig(),
self.should_write_metadata = True sequence_names: List[str] = None,
self.action_name_prefix = '' should_convert_to_samples: bool = False,
self.should_convert_to_samples = False should_overwrite: bool = False,
self.bone_mapping_mode = 'CASE_INSENSITIVE' should_stash: bool = False,
self.fps_source = 'SEQUENCE' should_use_config_file: bool = True,
self.fps_custom: float = 30.0 should_use_fake_user: bool = False,
self.translation_scale: float = 1.0 should_write_keyframes: bool = True,
self.should_use_config_file = True should_write_metadata: bool = True,
self.psa_config: PsaConfig = PsaConfig() translation_scale: float = 1.0
):
self.action_name_prefix = action_name_prefix
self.bone_mapping_mode = bone_mapping_mode
self.fps_custom = fps_custom
self.fps_source = fps_source
self.psa_config = psa_config
self.sequence_names = sequence_names if sequence_names is not None else []
self.should_convert_to_samples = should_convert_to_samples
self.should_overwrite = should_overwrite
self.should_stash = should_stash
self.should_use_config_file = should_use_config_file
self.should_use_fake_user = should_use_fake_user
self.should_write_keyframes = should_write_keyframes
self.should_write_metadata = should_write_metadata
self.translation_scale = translation_scale
class ImportBone(object): class ImportBone(object):

View File

@@ -5,7 +5,7 @@ from bpy.props import StringProperty
from bpy.types import Operator, Context, Object, Collection, SpaceProperties, Depsgraph, Material from bpy.types import Operator, Context, Object, Collection, SpaceProperties, Depsgraph, Material
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from .properties import add_psk_export_properties from .properties import PskExportMixin
from ..builder import build_psk, PskBuildOptions, get_psk_input_objects_for_context, \ from ..builder import build_psk, PskBuildOptions, get_psk_input_objects_for_context, \
get_psk_input_objects_for_collection get_psk_input_objects_for_collection
from ..writer import write_psk from ..writer import write_psk
@@ -14,16 +14,16 @@ from ...shared.ui import draw_bone_filter_mode
def get_materials_for_mesh_objects(depsgraph: Depsgraph, mesh_objects: Iterable[Object]): def get_materials_for_mesh_objects(depsgraph: Depsgraph, mesh_objects: Iterable[Object]):
materials = [] yielded_materials = set()
for mesh_object in mesh_objects: for mesh_object in mesh_objects:
evaluated_mesh_object = mesh_object.evaluated_get(depsgraph) evaluated_mesh_object = mesh_object.evaluated_get(depsgraph)
for i, material_slot in enumerate(evaluated_mesh_object.material_slots): for i, material_slot in enumerate(evaluated_mesh_object.material_slots):
material = material_slot.material material = material_slot.material
if material is None: if material is None:
raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')') raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')')
if material not in materials: if material not in yielded_materials:
materials.append(material) yielded_materials.add(material)
return materials yield material
def populate_material_name_list(depsgraph: Depsgraph, mesh_objects, material_list): def populate_material_name_list(depsgraph: Depsgraph, mesh_objects, material_list):
@@ -60,7 +60,7 @@ def get_collection_export_operator_from_context(context: Context) -> Optional[ob
class PSK_OT_populate_bone_collection_list(Operator): class PSK_OT_populate_bone_collection_list(Operator):
bl_idname = 'psk_export.populate_bone_collection_list' bl_idname = 'psk.export_populate_bone_collection_list'
bl_label = 'Populate Bone Collection List' bl_label = 'Populate Bone Collection List'
bl_description = 'Populate the bone collection list from the armature that will be used in this collection export' bl_description = 'Populate the bone collection list from the armature that will be used in this collection export'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
@@ -79,7 +79,7 @@ class PSK_OT_populate_bone_collection_list(Operator):
class PSK_OT_populate_material_name_list(Operator): class PSK_OT_populate_material_name_list(Operator):
bl_idname = 'psk_export.populate_material_name_list' bl_idname = 'psk.export_populate_material_name_list'
bl_label = 'Populate Material Name List' bl_label = 'Populate Material Name List'
bl_description = 'Populate the material name list from the objects that will be used in this export' bl_description = 'Populate the material name list from the objects that will be used in this export'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
@@ -100,7 +100,7 @@ class PSK_OT_populate_material_name_list(Operator):
class PSK_OT_material_list_move_up(Operator): class PSK_OT_material_list_move_up(Operator):
bl_idname = 'psk_export.material_list_item_move_up' bl_idname = 'psk.export_material_list_item_move_up'
bl_label = 'Move Up' bl_label = 'Move Up'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
bl_description = 'Move the selected material up one slot' bl_description = 'Move the selected material up one slot'
@@ -118,7 +118,7 @@ class PSK_OT_material_list_move_up(Operator):
class PSK_OT_material_list_move_down(Operator): class PSK_OT_material_list_move_down(Operator):
bl_idname = 'psk_export.material_list_item_move_down' bl_idname = 'psk.export_material_list_item_move_down'
bl_label = 'Move Down' bl_label = 'Move Down'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
bl_description = 'Move the selected material down one slot' bl_description = 'Move the selected material down one slot'
@@ -136,7 +136,7 @@ class PSK_OT_material_list_move_down(Operator):
class PSK_OT_material_list_name_move_up(Operator): class PSK_OT_material_list_name_move_up(Operator):
bl_idname = 'psk_export.material_name_list_item_move_up' bl_idname = 'psk.export_material_name_list_item_move_up'
bl_label = 'Move Up' bl_label = 'Move Up'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
bl_description = 'Move the selected material name up one slot' bl_description = 'Move the selected material name up one slot'
@@ -159,7 +159,7 @@ class PSK_OT_material_list_name_move_up(Operator):
class PSK_OT_material_list_name_move_down(Operator): class PSK_OT_material_list_name_move_down(Operator):
bl_idname = 'psk_export.material_name_list_item_move_down' bl_idname = 'psk.export_material_name_list_item_move_down'
bl_label = 'Move Down' bl_label = 'Move Down'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
bl_description = 'Move the selected material name down one slot' bl_description = 'Move the selected material name down one slot'
@@ -218,8 +218,8 @@ def get_psk_build_options_from_property_group(mesh_objects: Iterable[Object], p
return options return options
class PSK_OT_export_collection(Operator, ExportHelper): class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
bl_idname = 'export.psk_collection' bl_idname = 'psk.export_collection'
bl_label = 'Export' bl_label = 'Export'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
filename_ext = '.psk' filename_ext = '.psk'
@@ -312,12 +312,8 @@ class PSK_OT_export_collection(Operator, ExportHelper):
add_psk_export_properties(PSK_OT_export_collection)
class PSK_OT_export(Operator, ExportHelper): class PSK_OT_export(Operator, ExportHelper):
bl_idname = 'export.psk' bl_idname = 'psk.export'
bl_label = 'Export' bl_label = 'Export'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
bl_description = 'Export mesh and armature to PSK' bl_description = 'Export mesh and armature to PSK'

View File

@@ -60,65 +60,58 @@ def up_axis_update(self, _context):
self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X') self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X')
class PskExportMixin:
# In order to share the same properties between the PSA and PSK export properties, we need to define the properties in a object_eval_state: EnumProperty(
# separate function and then apply them to the classes. This is because the collection exporter cannot have
# PointerProperties, so we must effectively duplicate the storage of the properties.
def add_psk_export_properties(cls):
cls.__annotations__['object_eval_state'] = EnumProperty(
items=object_eval_state_items, items=object_eval_state_items,
name='Object Evaluation State', name='Object Evaluation State',
default='EVALUATED' default='EVALUATED'
) )
cls.__annotations__['should_exclude_hidden_meshes'] = BoolProperty( should_exclude_hidden_meshes: BoolProperty(
default=False, default=False,
name='Visible Only', name='Visible Only',
description='Export only visible meshes' description='Export only visible meshes'
) )
cls.__annotations__['scale'] = FloatProperty( scale: FloatProperty(
name='Scale', name='Scale',
default=1.0, default=1.0,
description='Scale factor to apply to the exported mesh and armature', description='Scale factor to apply to the exported mesh and armature',
min=0.0001, min=0.0001,
soft_max=100.0 soft_max=100.0
) )
cls.__annotations__['export_space'] = EnumProperty( export_space: EnumProperty(
name='Export Space', name='Export Space',
description='Space to export the mesh in', description='Space to export the mesh in',
items=export_space_items, items=export_space_items,
default='WORLD' default='WORLD'
) )
cls.__annotations__['bone_filter_mode'] = EnumProperty( bone_filter_mode: EnumProperty(
name='Bone Filter', name='Bone Filter',
options=empty_set, options=empty_set,
description='', description='',
items=bone_filter_mode_items, items=bone_filter_mode_items,
) )
cls.__annotations__['bone_collection_list'] = CollectionProperty(type=PSX_PG_bone_collection_list_item) bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
cls.__annotations__['bone_collection_list_index'] = IntProperty(default=0) bone_collection_list_index: IntProperty(default=0)
cls.__annotations__['forward_axis'] = EnumProperty( forward_axis: EnumProperty(
name='Forward', name='Forward',
items=forward_items, items=forward_items,
default='X', default='X',
update=forward_axis_update update=forward_axis_update
) )
cls.__annotations__['up_axis'] = EnumProperty( up_axis: EnumProperty(
name='Up', name='Up',
items=up_items, items=up_items,
default='Z', default='Z',
update=up_axis_update update=up_axis_update
) )
cls.__annotations__['material_name_list'] = CollectionProperty(type=PSK_PG_material_name_list_item) material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item)
cls.__annotations__['material_name_list_index'] = IntProperty(default=0) material_name_list_index: IntProperty(default=0)
class PSK_PG_export(PropertyGroup): class PSK_PG_export(PropertyGroup, PskExportMixin):
pass pass
add_psk_export_properties(PSK_PG_export)
classes = ( classes = (
PSK_PG_material_list_item, PSK_PG_material_list_item,
PSK_PG_material_name_list_item, PSK_PG_material_name_list_item,

View File

@@ -1,11 +1,11 @@
import os import os
import sys
from bpy.props import StringProperty, BoolProperty, EnumProperty, FloatProperty from bpy.props import StringProperty
from bpy.types import Operator, FileHandler, Context from bpy.types import Operator, FileHandler, Context
from bpy_extras.io_utils import ImportHelper from bpy_extras.io_utils import ImportHelper
from ..importer import PskImportOptions, import_psk from ..importer import PskImportOptions, import_psk
from ..properties import PskImportMixin
from ..reader import read_psk from ..reader import read_psk
empty_set = set() empty_set = set()
@@ -23,8 +23,8 @@ class PSK_FH_import(FileHandler):
return context.area and context.area.type == 'VIEW_3D' return context.area and context.area.type == 'VIEW_3D'
class PSK_OT_import(Operator, ImportHelper): class PSK_OT_import(Operator, ImportHelper, PskImportMixin):
bl_idname = 'import_scene.psk' bl_idname = 'psk.import'
bl_label = 'Import' bl_label = 'Import'
bl_options = {'INTERNAL', 'UNDO', 'PRESET'} bl_options = {'INTERNAL', 'UNDO', 'PRESET'}
bl_description = 'Import a PSK file' bl_description = 'Import a PSK file'
@@ -36,79 +36,6 @@ class PSK_OT_import(Operator, ImportHelper):
maxlen=1024, maxlen=1024,
default='') default='')
should_import_vertex_colors: BoolProperty(
default=True,
options=empty_set,
name='Import Vertex Colors',
description='Import vertex colors, if available'
)
vertex_color_space: EnumProperty(
name='Vertex Color Space',
options=empty_set,
description='The source vertex color space',
default='SRGBA',
items=(
('LINEAR', 'Linear', ''),
('SRGBA', 'sRGBA', ''),
)
)
should_import_vertex_normals: BoolProperty(
default=True,
name='Import Vertex Normals',
options=empty_set,
description='Import vertex normals, if available'
)
should_import_extra_uvs: BoolProperty(
default=True,
name='Import Extra UVs',
options=empty_set,
description='Import extra UV maps, if available'
)
should_import_mesh: BoolProperty(
default=True,
name='Import Mesh',
options=empty_set,
description='Import mesh'
)
should_import_materials: BoolProperty(
default=True,
name='Import Materials',
options=empty_set,
)
should_import_skeleton: BoolProperty(
default=True,
name='Import Skeleton',
options=empty_set,
description='Import skeleton'
)
bone_length: FloatProperty(
default=1.0,
min=sys.float_info.epsilon,
step=100,
soft_min=1.0,
name='Bone Length',
options=empty_set,
subtype='DISTANCE',
description='Length of the bones'
)
should_import_shape_keys: BoolProperty(
default=True,
name='Import Shape Keys',
options=empty_set,
description='Import shape keys, if available'
)
scale: FloatProperty(
name='Scale',
default=1.0,
soft_min=0.0,
)
bdk_repository_id: StringProperty(
name='BDK Repository ID',
default='',
options=empty_set,
description='The ID of the BDK repository to use for loading materials'
)
def execute(self, context): def execute(self, context):
psk = read_psk(self.filepath) psk = read_psk(self.filepath)
@@ -152,7 +79,6 @@ class PSK_OT_import(Operator, ImportHelper):
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(self, 'scale') col.prop(self, 'scale')
col.prop(self, 'export_space')
mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False) mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False)
mesh_header.prop(self, 'should_import_mesh') mesh_header.prop(self, 'should_import_mesh')

View File

@@ -1,4 +1,6 @@
from bpy.props import EnumProperty import sys
from bpy.props import EnumProperty, BoolProperty, FloatProperty, StringProperty
from bpy.types import PropertyGroup from bpy.types import PropertyGroup
mesh_triangle_types_items = ( mesh_triangle_types_items = (
@@ -42,6 +44,83 @@ def poly_flags_to_triangle_type_and_bit_flags(poly_flags: int) -> (str, set[str]
triangle_bit_flags = {item[0] for item in mesh_triangle_bit_flags_items if item[3] & poly_flags} triangle_bit_flags = {item[0] for item in mesh_triangle_bit_flags_items if item[3] & poly_flags}
return triangle_type, triangle_bit_flags return triangle_type, triangle_bit_flags
empty_set = set()
class PskImportMixin:
should_import_vertex_colors: BoolProperty(
default=True,
options=empty_set,
name='Import Vertex Colors',
description='Import vertex colors, if available'
)
vertex_color_space: EnumProperty(
name='Vertex Color Space',
options=empty_set,
description='The source vertex color space',
default='SRGBA',
items=(
('LINEAR', 'Linear', ''),
('SRGBA', 'sRGBA', ''),
)
)
should_import_vertex_normals: BoolProperty(
default=True,
name='Import Vertex Normals',
options=empty_set,
description='Import vertex normals, if available'
)
should_import_extra_uvs: BoolProperty(
default=True,
name='Import Extra UVs',
options=empty_set,
description='Import extra UV maps, if available'
)
should_import_mesh: BoolProperty(
default=True,
name='Import Mesh',
options=empty_set,
description='Import mesh'
)
should_import_materials: BoolProperty(
default=True,
name='Import Materials',
options=empty_set,
)
should_import_skeleton: BoolProperty(
default=True,
name='Import Skeleton',
options=empty_set,
description='Import skeleton'
)
bone_length: FloatProperty(
default=1.0,
min=sys.float_info.epsilon,
step=100,
soft_min=1.0,
name='Bone Length',
options=empty_set,
subtype='DISTANCE',
description='Length of the bones'
)
should_import_shape_keys: BoolProperty(
default=True,
name='Import Shape Keys',
options=empty_set,
description='Import shape keys, if available'
)
scale: FloatProperty(
name='Scale',
default=1.0,
soft_min=0.0,
)
bdk_repository_id: StringProperty(
name='BDK Repository ID',
default='',
options=empty_set,
description='The ID of the BDK repository to use for loading materials'
)
classes = ( classes = (
PSX_PG_material, PSX_PG_material,