Bone group filtering is now working for both PSK and PSA exports.

This commit is contained in:
Colin Basnett
2022-01-22 18:11:41 -08:00
parent 41e14d5e19
commit abef2a7f45
8 changed files with 353 additions and 124 deletions

View File

@@ -13,6 +13,9 @@ bl_info = {
if 'bpy' in locals(): if 'bpy' in locals():
import importlib import importlib
importlib.reload(psx_data)
importlib.reload(psx_helpers)
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)
@@ -25,6 +28,9 @@ if 'bpy' in locals():
importlib.reload(psa_importer) importlib.reload(psa_importer)
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 helpers as psx_helpers
from . import types as psx_types
from .psk import data as psk_data from .psk import data as psk_data
from .psk import builder as psk_builder from .psk import builder as psk_builder
from .psk import exporter as psk_exporter from .psk import exporter as psk_exporter
@@ -40,15 +46,19 @@ import bpy
from bpy.props import PointerProperty from bpy.props import PointerProperty
classes = [ # TODO: have the individual files emit a __classes__ field or something we can update it locally instead of explicitly declaring it here.
psk_exporter.PskExportOperator, classes = []
classes.extend(psx_types.__classes__)
classes.extend(psk_exporter.__classes__)
classes.extend([
psk_importer.PskImportOperator, psk_importer.PskImportOperator,
psa_importer.PsaImportOperator, psa_importer.PsaImportOperator,
psa_importer.PsaImportFileSelectOperator, psa_importer.PsaImportFileSelectOperator,
psa_exporter.PSA_UL_ExportActionList, psa_exporter.PSA_UL_ExportActionList,
psa_exporter.PSA_UL_ExportBoneGroupList, # psa_exporter.PSA_UL_ExportBoneGroupList,
psa_importer.PSA_UL_ImportActionList, psa_importer.PSA_UL_ImportActionList,
psa_importer.PsaImportActionListItem, psa_importer.PsaImportActionListItem,
psa_importer.PsaImportPsaBoneItem,
psa_importer.PsaImportSelectAll, psa_importer.PsaImportSelectAll,
psa_importer.PsaImportDeselectAll, psa_importer.PsaImportDeselectAll,
psa_importer.PSA_PT_ImportPanel, psa_importer.PSA_PT_ImportPanel,
@@ -57,9 +67,8 @@ classes = [
psa_exporter.PsaExportSelectAll, psa_exporter.PsaExportSelectAll,
psa_exporter.PsaExportDeselectAll, psa_exporter.PsaExportDeselectAll,
psa_exporter.PsaExportActionListItem, psa_exporter.PsaExportActionListItem,
psa_exporter.PsaExportBoneGroupListItem,
psa_exporter.PsaExportPropertyGroup, psa_exporter.PsaExportPropertyGroup,
] ])
def psk_export_menu_func(self, context): def psk_export_menu_func(self, context):
@@ -87,6 +96,7 @@ def register():
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_importer.PsaImportPropertyGroup)
bpy.types.Scene.psa_export = PointerProperty(type=psa_exporter.PsaExportPropertyGroup) bpy.types.Scene.psa_export = PointerProperty(type=psa_exporter.PsaExportPropertyGroup)
bpy.types.Scene.psk_export = PointerProperty(type=psk_exporter.PskExportPropertyGroup)
def unregister(): def unregister():

View File

@@ -0,0 +1,60 @@
from typing import List
def populate_bone_groups_list(armature_object, bone_group_list):
bone_group_list.clear()
item = bone_group_list.add()
item.name = '(unassigned)'
item.index = -1
item.is_selected = True
for bone_group_index, bone_group in enumerate(armature_object.pose.bone_groups):
item = bone_group_list.add()
item.name = bone_group.name
item.index = bone_group_index
item.is_selected = True
def add_bone_groups_to_layout(layout):
pass
def get_export_bone_indices_for_bone_groups(armature_object, bone_group_indices: List[int]) -> List[int]:
"""
Returns a sorted list of bone indices that should be exported for the given bone groups.
Note that the ancestors of bones within the bone groups will also be present in the returned list.
:param armature_object: Blender object with type 'ARMATURE'
:param bone_group_indices: List of bone group indices to be exported.
:return: A sorted list of bone indices that should be exported.
"""
if armature_object is None or armature_object.type != 'ARMATURE':
raise ValueError('An armature object must be supplied')
bones = armature_object.data.bones
pose_bones = armature_object.pose.bones
bone_names = [x.name for x in bones]
# Get a list of the bone indices that are explicitly part of the bone groups we are including.
bone_index_stack = []
is_exporting_none_bone_groups = -1 in bone_group_indices
for bone_index, pose_bone in enumerate(pose_bones):
if (pose_bone.bone_group is None and is_exporting_none_bone_groups) or \
(pose_bone.bone_group is not None and pose_bone.bone_group_index in bone_group_indices):
bone_index_stack.append(bone_index)
# For each bone that is explicitly being added, recursively walk up the hierarchy and ensure that all of
# those ancestor bone indices are also in the list.
bone_indices = set()
while len(bone_index_stack) > 0:
bone_index = bone_index_stack.pop()
bone = bones[bone_index]
if bone.parent is not None:
parent_bone_index = bone_names.index(bone.parent.name)
if parent_bone_index not in bone_indices:
bone_index_stack.append(parent_bone_index)
bone_indices.add(bone_index)
return list(sorted(list(bone_indices)))

View File

@@ -1,10 +1,11 @@
from .data import * from .data import *
from ..helpers import *
class PsaBuilderOptions(object): class PsaBuilderOptions(object):
def __init__(self): def __init__(self):
self.actions = [] self.actions = []
self.bone_filter_mode = 'NONE' self.bone_filter_mode = 'ALL'
self.bone_group_indices = [] self.bone_group_indices = []
@@ -34,39 +35,31 @@ class PsaBuilder(object):
# armature bones. # armature bones.
bone_names = [x.name for x in bones] bone_names = [x.name for x in bones]
pose_bones = [(bone_names.index(bone.name), bone) for bone in armature.pose.bones] pose_bones = [(bone_names.index(bone.name), bone) for bone in armature.pose.bones]
del bone_names
pose_bones.sort(key=lambda x: x[0]) pose_bones.sort(key=lambda x: x[0])
pose_bones = [x[1] for x in pose_bones] pose_bones = [x[1] for x in pose_bones]
bone_indices = list(range(len(bones))) bone_indices = list(range(len(bones)))
# If bone groups are specified, get only the bones that are in that specified bone groups and their ancestors.
if options.bone_filter_mode == 'BONE_GROUPS': if options.bone_filter_mode == 'BONE_GROUPS':
# Get a list of the bone indices that are explicitly part of the bone groups we are including. bone_indices = get_export_bone_indices_for_bone_groups(armature, options.bone_group_indices)
bone_index_stack = []
for bone_index, pose_bone in enumerate(pose_bones):
if pose_bone.bone_group_index in options.bone_group_indices:
bone_index_stack.append(bone_index)
# For each bone that is explicitly being added, recursively walk up the hierarchy and ensure that all of # Make the bone lists contain only the bones that are going to be exported.
# those bone indices are also in the list.
bone_indices = set()
while len(bone_index_stack) > 0:
bone_index = bone_index_stack.pop()
bone = bones[bone_index]
if bone.parent is not None:
parent_bone_index = bone_names.index(bone.parent.name)
if parent_bone_index not in bone_indices:
bone_index_stack.append(parent_bone_index)
bone_indices.add(bone_index)
del bone_names
# Sort out list of bone indices to be exported.
bone_indices = sorted(list(bone_indices))
# The bone lists now contains only the bones that are going to be exported.
bones = [bones[bone_index] for bone_index in bone_indices] bones = [bones[bone_index] for bone_index in bone_indices]
pose_bones = [pose_bones[bone_index] for bone_index in bone_indices] pose_bones = [pose_bones[bone_index] for bone_index in bone_indices]
if len(bones) == 0:
# No bones are going to be exported.
raise RuntimeError('No bones available for export')
# Ensure that the exported hierarchy has a single root bone.
root_bones = [x for x in bones if x.parent is None]
if len(root_bones) > 1:
root_bone_names = [x.name for x in bones]
raise RuntimeError('Exported bone hierarchy must have a single root bone.'
f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}')
for pose_bone in bones: for pose_bone in bones:
psa_bone = Psa.Bone() psa_bone = Psa.Bone()
psa_bone.name = bytes(pose_bone.name, encoding='utf-8') psa_bone.name = bytes(pose_bone.name, encoding='utf-8')

View File

@@ -5,6 +5,8 @@ from bpy_extras.io_utils import ExportHelper
from typing import Type from typing import Type
from .builder import PsaBuilder, PsaBuilderOptions from .builder import PsaBuilder, PsaBuilderOptions
from .data import * from .data import *
from ..types import BoneGroupListItem
from ..helpers import *
import re import re
@@ -43,30 +45,29 @@ class PsaExportActionListItem(PropertyGroup):
return self.action.name return self.action.name
class PsaExportBoneGroupListItem(PropertyGroup):
name: StringProperty()
index: IntProperty()
is_selected: BoolProperty(default=False)
@property
def name(self):
return self.bone_group.name
class PsaExportPropertyGroup(PropertyGroup): class PsaExportPropertyGroup(PropertyGroup):
action_list: CollectionProperty(type=PsaExportActionListItem) action_list: CollectionProperty(type=PsaExportActionListItem)
action_list_index: IntProperty(default=0) action_list_index: IntProperty(default=0)
bone_filter_mode: EnumProperty( bone_filter_mode: EnumProperty(
name='Bone Filter', name='Bone Filter',
items={ description='',
('NONE', 'None', 'All bones will be exported.'), items=(
('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups will be exported.'), ('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=PsaExportBoneGroupListItem) )
bone_group_list: CollectionProperty(type=BoneGroupListItem)
bone_group_list_index: IntProperty(default=0) bone_group_list_index: IntProperty(default=0)
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
class PsaExportOperator(Operator, ExportHelper): class PsaExportOperator(Operator, ExportHelper):
bl_idname = 'export.psa' bl_idname = 'export.psa'
bl_label = 'Export' bl_label = 'Export'
@@ -84,29 +85,34 @@ class PsaExportOperator(Operator, ExportHelper):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
scene = context.scene property_group = context.scene.psa_export
# ACTIONS
box = layout.box() box = layout.box()
box.label(text='Actions', icon='ACTION') box.label(text='Actions', icon='ACTION')
row = box.row() row = box.row()
row.template_list('PSA_UL_ExportActionList', 'asd', scene.psa_export, 'action_list', scene.psa_export, 'action_list_index', rows=10) row.template_list('PSA_UL_ExportActionList', 'asd', property_group, 'action_list', property_group, 'action_list_index', rows=10)
row = box.row(align=True)
row = box.row() row.label(text='Select')
row.operator('psa_export.actions_select_all', text='Select All') row.operator('psa_export.actions_select_all', text='All')
row.operator('psa_export.actions_deselect_all', text='Deselect All') row.operator('psa_export.actions_deselect_all', text='None')
# BONES
box = layout.box() box = layout.box()
box.label(text='Bone Filter', icon='FILTER') box.label(text='Bones', icon='BONE_DATA')
bone_filter_mode_items = property_group.bl_rna.properties['bone_filter_mode'].enum_items_static
row = box.row(align=True)
for item in bone_filter_mode_items:
identifier = item.identifier
item_layout = row.row(align=True)
item_layout.prop_enum(property_group, 'bone_filter_mode', item.identifier)
item_layout.enabled = is_bone_filter_mode_item_available(context, identifier)
if property_group.bone_filter_mode == 'BONE_GROUPS':
box = layout.box()
row = box.row() row = box.row()
row.alignment = 'EXPAND' rows = max(3, min(len(property_group.bone_group_list), 10))
row.prop(scene.psa_export, 'bone_filter_mode', expand=True, text='Bone Filter') row.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, 'bone_group_list_index', rows=rows)
if scene.psa_export.bone_filter_mode == 'BONE_GROUPS':
row = box.row()
rows = max(3, min(len(scene.psa_export.bone_group_list), 10))
row.template_list('PSA_UL_ExportBoneGroupList', 'asd', scene.psa_export, 'bone_group_list', scene.psa_export, 'bone_group_list_index', rows=rows)
def is_action_for_armature(self, action): def is_action_for_armature(self, action):
if len(action.fcurves) == 0: if len(action.fcurves) == 0:
@@ -122,6 +128,8 @@ class PsaExportOperator(Operator, ExportHelper):
return False return False
def invoke(self, context, event): def invoke(self, context, event):
property_group = context.scene.psa_export
if context.view_layer.objects.active is None: if context.view_layer.objects.active is None:
self.report({'ERROR_INVALID_CONTEXT'}, 'An armature must be selected') self.report({'ERROR_INVALID_CONTEXT'}, 'An armature must be selected')
return {'CANCELLED'} return {'CANCELLED'}
@@ -133,33 +141,29 @@ class PsaExportOperator(Operator, ExportHelper):
self.armature = context.view_layer.objects.active self.armature = context.view_layer.objects.active
# Populate actions list. # Populate actions list.
context.scene.psa_export.action_list.clear() property_group.action_list.clear()
for action in bpy.data.actions: for action in bpy.data.actions:
item = context.scene.psa_export.action_list.add() item = property_group.action_list.add()
item.action = action item.action = action
item.action_name = action.name item.action_name = action.name
if self.is_action_for_armature(action): if self.is_action_for_armature(action):
item.is_selected = True item.is_selected = True
if len(context.scene.psa_export.action_list) == 0: if len(property_group.action_list) == 0:
# If there are no actions at all, we have nothing to export, so just cancel the operation. # If there are no actions at all, we have nothing to export, so just cancel the operation.
self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions to export.') self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions to export.')
return {'CANCELLED'} return {'CANCELLED'}
# Populate bone groups list. # Populate bone groups list.
context.scene.psa_export.bone_group_list.clear() populate_bone_groups_list(self.armature, property_group.bone_group_list)
for bone_group_index, bone_group in enumerate(self.armature.pose.bone_groups):
item = context.scene.psa_export.bone_group_list.add()
item.name = bone_group.name
item.index = bone_group_index
item.is_selected = False
context.window_manager.fileselect_add(self) context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
def execute(self, context): def execute(self, context):
actions = [x.action for x in context.scene.psa_export.action_list if x.is_selected] property_group = context.scene.psa_export
actions = [x.action for x in property_group.action_list if x.is_selected]
if len(actions) == 0: if len(actions) == 0:
self.report({'ERROR_INVALID_CONTEXT'}, 'No actions were selected for export.') self.report({'ERROR_INVALID_CONTEXT'}, 'No actions were selected for export.')
@@ -167,8 +171,8 @@ class PsaExportOperator(Operator, ExportHelper):
options = PsaBuilderOptions() options = PsaBuilderOptions()
options.actions = actions options.actions = actions
options.bone_filter_mode = context.scene.psa_export.bone_filter_mode options.bone_filter_mode = property_group.bone_filter_mode
options.bone_group_indices = [x.index for x in context.scene.psa_export.bone_group_list if x.is_selected] options.bone_group_indices = [x.index for x in property_group.bone_group_list if x.is_selected]
builder = PsaBuilder() builder = PsaBuilder()
psa = builder.build(context, options) psa = builder.build(context, options)
exporter = PsaExporter(psa) exporter = PsaExporter(psa)
@@ -176,13 +180,6 @@ class PsaExportOperator(Operator, ExportHelper):
return {'FINISHED'} return {'FINISHED'}
class PSA_UL_ExportBoneGroupList(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
layout.alignment = 'LEFT'
layout.prop(item, 'is_selected', icon_only=True)
layout.label(text=item.name, icon='GROUP_BONE')
class PSA_UL_ExportActionList(UIList): class PSA_UL_ExportActionList(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
layout.alignment = 'LEFT' layout.alignment = 'LEFT'
@@ -210,12 +207,14 @@ class PsaExportSelectAll(bpy.types.Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
action_list = context.scene.psa_export.action_list property_group = context.scene.psa_export
action_list = property_group.action_list
has_unselected_actions = any(map(lambda action: not action.is_selected, action_list)) has_unselected_actions = any(map(lambda action: not action.is_selected, action_list))
return len(action_list) > 0 and has_unselected_actions return len(action_list) > 0 and has_unselected_actions
def execute(self, context): def execute(self, context):
for action in context.scene.psa_export.action_list: property_group = context.scene.psa_export
for action in property_group.action_list:
action.is_selected = True action.is_selected = True
return {'FINISHED'} return {'FINISHED'}
@@ -226,11 +225,13 @@ class PsaExportDeselectAll(bpy.types.Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
action_list = context.scene.psa_export.action_list property_group = context.scene.psa_export
action_list = property_group.action_list
has_selected_actions = any(map(lambda action: action.is_selected, action_list)) has_selected_actions = any(map(lambda action: action.is_selected, action_list))
return len(action_list) > 0 and has_selected_actions return len(action_list) > 0 and has_selected_actions
def execute(self, context): def execute(self, context):
for action in context.scene.psa_export.action_list: property_group = context.scene.psa_export
for action in property_group.action_list:
action.is_selected = False action.is_selected = False
return {'FINISHED'} return {'FINISHED'}

View File

@@ -17,9 +17,9 @@ class PsaImporter(object):
pass pass
def import_psa(self, psa_reader: PsaReader, sequence_names: List[AnyStr], context): def import_psa(self, psa_reader: PsaReader, sequence_names: List[AnyStr], context):
properties = context.scene.psa_import property_group = context.scene.psa_import
sequences = map(lambda x: psa_reader.sequences[x], sequence_names) sequences = map(lambda x: psa_reader.sequences[x], sequence_names)
armature_object = properties.armature_object armature_object = property_group.armature_object
armature_data = armature_object.data armature_data = armature_object.data
class ImportBone(object): class ImportBone(object):
@@ -190,6 +190,14 @@ class PsaImporter(object):
print(f'total_time: {total_time}') print(f'total_time: {total_time}')
class PsaImportPsaBoneItem(PropertyGroup):
bone_name: StringProperty()
@property
def name(self):
return self.bone_name
class PsaImportActionListItem(PropertyGroup): class PsaImportActionListItem(PropertyGroup):
action_name: StringProperty() action_name: StringProperty()
frame_count: IntProperty() frame_count: IntProperty()
@@ -201,16 +209,22 @@ class PsaImportActionListItem(PropertyGroup):
def on_psa_file_path_updated(property, context): def on_psa_file_path_updated(property, context):
context.scene.psa_import.action_list.clear() property_group = context.scene.psa_import
property_group.action_list.clear()
property_group.psa_bones.clear()
try: try:
# Read the file and populate the action list. # Read the file and populate the action list.
p = os.path.abspath(context.scene.psa_import.psa_file_path) p = os.path.abspath(property_group.psa_file_path)
psa_reader = PsaReader(p) psa_reader = PsaReader(p)
for sequence in psa_reader.sequences.values(): for sequence in psa_reader.sequences.values():
item = context.scene.psa_import.action_list.add() item = property_group.action_list.add()
item.action_name = sequence.name.decode('windows-1252') item.action_name = sequence.name.decode('windows-1252')
item.frame_count = sequence.frame_count item.frame_count = sequence.frame_count
item.is_selected = True item.is_selected = True
for psa_bone in psa_reader.bones:
item = property_group.psa_bones.add()
item.bone_name = psa_bone.name
except IOError as e: except IOError as e:
print('ERROR READING FILE') print('ERROR READING FILE')
print(e) print(e)
@@ -218,9 +232,19 @@ def on_psa_file_path_updated(property, context):
pass pass
def on_armature_object_updated(property, context):
# TODO: ensure that there are matching bones between the two rigs.
property_group = context.scene.psa_import
armature_object = property_group.armature_object
if armature_object is not None:
armature_bone_names = set(map(lambda bone: bone.name, armature_object.data.bones))
psa_bone_names = set(map(lambda psa_bone: psa_bone.name, property_group.psa_bones))
class PsaImportPropertyGroup(bpy.types.PropertyGroup): class PsaImportPropertyGroup(bpy.types.PropertyGroup):
psa_file_path: StringProperty(default='', subtype='FILE_PATH', update=on_psa_file_path_updated) psa_file_path: StringProperty(default='', subtype='FILE_PATH', update=on_psa_file_path_updated)
armature_object: PointerProperty(name='Armature', type=bpy.types.Object) psa_bones: CollectionProperty(type=PsaImportPsaBoneItem)
armature_object: PointerProperty(name='Object', type=bpy.types.Object, update=on_armature_object_updated)
action_list: CollectionProperty(type=PsaImportActionListItem) action_list: CollectionProperty(type=PsaImportActionListItem)
action_list_index: IntProperty(name='', default=0) action_list_index: IntProperty(name='', default=0)
action_filter_name: StringProperty(default='') action_filter_name: StringProperty(default='')
@@ -265,12 +289,14 @@ class PsaImportSelectAll(bpy.types.Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
action_list = context.scene.psa_import.action_list property_group = context.scene.psa_import
action_list = property_group.action_list
has_unselected_actions = any(map(lambda action: not action.is_selected, action_list)) has_unselected_actions = any(map(lambda action: not action.is_selected, action_list))
return len(action_list) > 0 and has_unselected_actions return len(action_list) > 0 and has_unselected_actions
def execute(self, context): def execute(self, context):
for action in context.scene.psa_import.action_list: property_group = context.scene.psa_import
for action in property_group.action_list:
action.is_selected = True action.is_selected = True
return {'FINISHED'} return {'FINISHED'}
@@ -281,35 +307,41 @@ class PsaImportDeselectAll(bpy.types.Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
action_list = context.scene.psa_import.action_list property_group = context.scene.psa_import
action_list = property_group.action_list
has_selected_actions = any(map(lambda action: action.is_selected, action_list)) has_selected_actions = any(map(lambda action: action.is_selected, action_list))
return len(action_list) > 0 and has_selected_actions return len(action_list) > 0 and has_selected_actions
def execute(self, context): def execute(self, context):
for action in context.scene.psa_import.action_list: property_group = context.scene.psa_import
for action in property_group.action_list:
action.is_selected = False action.is_selected = False
return {'FINISHED'} return {'FINISHED'}
class PSA_PT_ImportPanel(Panel): class PSA_PT_ImportPanel(Panel):
bl_space_type = 'VIEW_3D' bl_space_type = 'NLA_EDITOR'
bl_region_type = 'UI' bl_region_type = 'UI'
bl_label = 'PSA Import' bl_label = 'PSA Import'
bl_context = 'objectmode' bl_context = 'object'
bl_category = 'PSA Import' bl_category = 'PSA Import'
@classmethod
def poll(cls, context):
return context.view_layer.objects.active is not None
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
scene = context.scene property_group = context.scene.psa_import
row = layout.row() row = layout.row()
row.prop(scene.psa_import, 'psa_file_path', text='PSA File') row.prop(property_group, 'psa_file_path', text='PSA File')
row = layout.row() row = layout.row()
row.prop_search(scene.psa_import, 'armature_object', bpy.data, 'objects') row.prop_search(property_group, 'armature_object', bpy.data, 'objects')
box = layout.box() box = layout.box()
box.label(text=f'Actions ({len(scene.psa_import.action_list)})', icon='ACTION') box.label(text=f'Actions ({len(property_group.action_list)})', icon='ACTION')
row = box.row() row = box.row()
rows = max(3, min(len(scene.psa_import.action_list), 10)) rows = max(3, min(len(property_group.action_list), 10))
row.template_list('PSA_UL_ImportActionList', 'asd', scene.psa_import, 'action_list', scene.psa_import, 'action_list_index', rows=rows) row.template_list('PSA_UL_ImportActionList', '', property_group, 'action_list', property_group, 'action_list_index', rows=rows)
row = box.row(align=True) row = box.row(align=True)
row.label(text='Select') row.label(text='Select')
row.operator('psa_import.actions_select_all', text='All') row.operator('psa_import.actions_select_all', text='All')
@@ -323,14 +355,16 @@ class PsaImportOperator(Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
action_list = context.scene.psa_import.action_list property_group = context.scene.psa_import
action_list = property_group.action_list
has_selected_actions = any(map(lambda action: action.is_selected, action_list)) has_selected_actions = any(map(lambda action: action.is_selected, action_list))
armature_object = context.scene.psa_import.armature_object armature_object = property_group.armature_object
return has_selected_actions and armature_object is not None return has_selected_actions and armature_object is not None
def execute(self, context): def execute(self, context):
psa_reader = PsaReader(context.scene.psa_import.psa_file_path) property_group = context.scene.psa_import
sequence_names = [x.action_name for x in context.scene.psa_import.action_list if x.is_selected] psa_reader = PsaReader(property_group.psa_file_path)
sequence_names = [x.action_name for x in property_group.action_list if x.is_selected]
PsaImporter().import_psa(psa_reader, sequence_names, context) PsaImporter().import_psa(psa_reader, sequence_names, context)
return {'FINISHED'} return {'FINISHED'}
@@ -351,6 +385,7 @@ class PsaImportFileSelectOperator(Operator, ImportHelper):
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
def execute(self, context): def execute(self, context):
context.scene.psa_import.psa_file_path = self.filepath property_group = context.scene.psa_import
property_group.psa_file_path = self.filepath
# Load the sequence names from the selected file # Load the sequence names from the selected file
return {'FINISHED'} return {'FINISHED'}

View File

@@ -2,8 +2,8 @@ import bpy
import bmesh import bmesh
from collections import OrderedDict from collections import OrderedDict
from .data import * from .data import *
from ..helpers import *
# https://github.com/bwrsandman/blender-addons/blob/master/io_export_unreal_psk_psa.py
class PskInputObjects(object): class PskInputObjects(object):
def __init__(self): def __init__(self):
@@ -11,6 +11,12 @@ class PskInputObjects(object):
self.armature_object = None self.armature_object = None
class PskBuilderOptions(object):
def __init__(self):
self.bone_filter_mode = 'ALL'
self.bone_group_indices = []
class PskBuilder(object): class PskBuilder(object):
def __init__(self): def __init__(self):
pass pass
@@ -51,13 +57,16 @@ class PskBuilder(object):
return input_objects return input_objects
def build(self, context) -> Psk: def build(self, context, options: PskBuilderOptions) -> Psk:
input_objects = PskBuilder.get_input_objects(context) input_objects = PskBuilder.get_input_objects(context)
armature_object = input_objects.armature_object
psk = Psk() psk = Psk()
bones = []
materials = OrderedDict() materials = OrderedDict()
if input_objects.armature_object is None: if armature_object is None:
# Static mesh (no armature) # Static mesh (no armature)
psk_bone = Psk.Bone() psk_bone = Psk.Bone()
psk_bone.name = bytes('static', encoding='utf-8') psk_bone.name = bytes('static', encoding='utf-8')
@@ -68,15 +77,30 @@ class PskBuilder(object):
psk_bone.rotation = Quaternion(0, 0, 0, 1) psk_bone.rotation = Quaternion(0, 0, 0, 1)
psk.bones.append(psk_bone) psk.bones.append(psk_bone)
else: else:
bones = list(input_objects.armature_object.data.bones) bones = list(armature_object.data.bones)
# If bone groups are specified, get only the bones that are in the specified bone groups and their ancestors.
if len(options.bone_group_indices) > 0:
bone_indices = get_export_bone_indices_for_bone_groups(armature_object, options.bone_group_indices)
bones = [bones[bone_index] for bone_index in bone_indices]
# Ensure that the exported hierarchy has a single root bone.
root_bones = [x for x in bones if x.parent is None]
if len(root_bones) > 1:
root_bone_names = [x.name for x in bones]
raise RuntimeError('Exported bone hierarchy must have a single root bone.'
f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}')
for bone in bones: for bone in bones:
psk_bone = Psk.Bone() psk_bone = Psk.Bone()
psk_bone.name = bytes(bone.name, encoding='utf-8') psk_bone.name = bytes(bone.name, encoding='utf-8')
psk_bone.flags = 0 psk_bone.flags = 0
psk_bone.children_count = len(bone.children) psk_bone.children_count = 0
try: try:
psk_bone.parent_index = bones.index(bone.parent) parent_index = bones.index(bone.parent)
psk_bone.parent_index = parent_index
psk.bones[parent_index].children_count += 1
except ValueError: except ValueError:
psk_bone.parent_index = 0 psk_bone.parent_index = 0
@@ -90,8 +114,8 @@ class PskBuilder(object):
parent_tail = quat_parent @ bone.parent.tail parent_tail = quat_parent @ bone.parent.tail
location = (parent_tail - parent_head) + bone.head location = (parent_tail - parent_head) + bone.head
else: else:
location = input_objects.armature_object.matrix_local @ bone.head location = armature_object.matrix_local @ bone.head
rot_matrix = bone.matrix @ input_objects.armature_object.matrix_local.to_3x3() rot_matrix = bone.matrix @ armature_object.matrix_local.to_3x3()
rotation = rot_matrix.to_quaternion() rotation = rot_matrix.to_quaternion()
psk_bone.location.x = location.x psk_bone.location.x = location.x
@@ -177,14 +201,34 @@ class PskBuilder(object):
psk.faces.append(face) psk.faces.append(face)
# WEIGHTS # WEIGHTS
# TODO: bone ~> vg might not be 1:1, provide a nice error message if this is the case if armature_object is not None:
if input_objects.armature_object is not None: # Because the vertex groups may contain entries for which there is no matching bone in the armature,
armature = input_objects.armature_object.data # we must filter them out and not export any weights for these vertex groups.
bone_names = [x.name for x in armature.bones] bone_names = [x.name for x in bones]
vertex_group_names = [x.name for x in object.vertex_groups] vertex_group_names = [x.name for x in object.vertex_groups]
bone_indices = [bone_names.index(name) for name in vertex_group_names] vertex_group_bone_indices = dict()
for vertex_group_index, vertex_group_name in enumerate(vertex_group_names):
try:
vertex_group_bone_indices[vertex_group_index] = bone_names.index(vertex_group_name)
except ValueError:
# The vertex group does not have a matching bone in the list of bones to be exported.
# Check to see if there is an associated bone for this vertex group that exists in the armature.
# If there is, we can traverse the ancestors of that bone to find an alternate bone to use for
# weighting the vertices belonging to this vertex group.
if vertex_group_name in armature_object.data.bones:
bone = armature_object.data.bones[vertex_group_name]
while bone is not None:
try:
bone_index = bone_names.index(bone.name)
vertex_group_bone_indices[vertex_group_index] = bone_index
break
except ValueError:
bone = bone.parent
for vertex_group_index, vertex_group in enumerate(object.vertex_groups): for vertex_group_index, vertex_group in enumerate(object.vertex_groups):
bone_index = bone_indices[vertex_group_index] if vertex_group_index not in vertex_group_bone_indices:
continue
bone_index = vertex_group_bone_indices[vertex_group_index]
# TODO: exclude vertex group if it doesn't match to a bone we are exporting
for vertex_index in range(len(object.data.vertices)): for vertex_index in range(len(object.data.vertices)):
try: try:
weight = vertex_group.weight(vertex_index) weight = vertex_group.weight(vertex_index)

View File

@@ -1,9 +1,11 @@
from .data import * from .data import *
from .builder import PskBuilder from ..types import BoneGroupListItem
from ..helpers import populate_bone_group_list
from .builder import PskBuilder, PskBuilderOptions
from typing import Type from typing import Type
from bpy.types import Operator from bpy.types import Operator, PropertyGroup
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from bpy.props import StringProperty from bpy.props import StringProperty, CollectionProperty, IntProperty, BoolProperty, EnumProperty
MAX_WEDGE_COUNT = 65536 MAX_WEDGE_COUNT = 65536
MAX_POINT_COUNT = 4294967296 MAX_POINT_COUNT = 4294967296
@@ -58,6 +60,16 @@ class PskExporter(object):
self.write_section(fp, b'RAWWEIGHTS', Psk.Weight, self.psk.weights) self.write_section(fp, b'RAWWEIGHTS', Psk.Weight, self.psk.weights)
def is_bone_filter_mode_item_available(context, identifier):
input_objects = PskBuilder.get_input_objects(context)
armature_object = input_objects.armature_object
if identifier == 'BONE_GROUPS':
if not armature_object.pose or not armature_object.pose.bone_groups:
return False
# else if... you can set up other conditions if you add more options
return True
class PskExportOperator(Operator, ExportHelper): class PskExportOperator(Operator, ExportHelper):
bl_idname = 'export.psk' bl_idname = 'export.psk'
bl_label = 'Export' bl_label = 'Export'
@@ -73,17 +85,66 @@ class PskExportOperator(Operator, ExportHelper):
def invoke(self, context, event): def invoke(self, context, event):
try: try:
PskBuilder.get_input_objects(context) input_objects = PskBuilder.get_input_objects(context)
except RuntimeError as e: except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
property_group = context.scene.psk_export
# Populate bone groups list.
populate_bone_group_list(input_objects.armature_object, property_group.bone_group_list)
context.window_manager.fileselect_add(self) context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
scene = context.scene
property_group = scene.psk_export
# BONES
box = layout.box()
box.label(text='Bones', icon='BONE_DATA')
bone_filter_mode_items = property_group.bl_rna.properties['bone_filter_mode'].enum_items_static
row = box.row(align=True)
for item in bone_filter_mode_items:
identifier = item.identifier
item_layout = row.row(align=True)
item_layout.prop_enum(property_group, 'bone_filter_mode', item.identifier)
item_layout.enabled = is_bone_filter_mode_item_available(context, identifier)
if property_group.bone_filter_mode == 'BONE_GROUPS':
row = box.row()
rows = max(3, min(len(property_group.bone_group_list), 10))
row.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, 'bone_group_list_index', rows=rows)
def execute(self, context): def execute(self, context):
property_group = context.scene.psk_export
builder = PskBuilder() builder = PskBuilder()
psk = builder.build(context) options = PskBuilderOptions()
options.bone_group_indices = [x.index for x in property_group.bone_group_list if x.is_selected]
psk = builder.build(context, options)
exporter = PskExporter(psk) exporter = PskExporter(psk)
exporter.export(self.filepath) exporter.export(self.filepath)
return {'FINISHED'} return {'FINISHED'}
class PskExportPropertyGroup(PropertyGroup):
bone_filter_mode: EnumProperty(
name='Bone Filter',
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)
__classes__ = [
PskExportOperator,
PskExportPropertyGroup
]

View File

@@ -0,0 +1,25 @@
from bpy.types import PropertyGroup, UIList
from bpy.props import StringProperty, IntProperty, BoolProperty
class PSX_UL_BoneGroupList(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
layout.alignment = 'LEFT'
layout.prop(item, 'is_selected', icon_only=True)
layout.label(text=item.name, icon='GROUP_BONE' if item.index >= 0 else 'NONE')
class BoneGroupListItem(PropertyGroup):
name: StringProperty()
index: IntProperty()
is_selected: BoolProperty(default=False)
@property
def name(self):
return self.name
__classes__ = [
BoneGroupListItem,
PSX_UL_BoneGroupList
]