Loads of cleanup

This commit is contained in:
Colin Basnett
2025-03-30 23:36:12 -07:00
parent 322844b88c
commit 15614c6d37
23 changed files with 317 additions and 295 deletions

View File

@@ -36,28 +36,40 @@ if 'bpy' in locals():
else: else:
from .shared import data as shared_data, types as shared_types, helpers as shared_helpers from .shared import data as shared_data, types as shared_types, helpers as shared_helpers
from .shared import dfs as shared_dfs, ui as shared_ui from .shared import dfs as shared_dfs, ui as shared_ui
from .psk import data as psk_data, builder as psk_builder, writer as psk_writer, \ from .psk import (
importer as psk_importer, properties as psk_properties builder as psk_builder,
data as psk_data,
importer as psk_importer,
properties as psk_properties,
writer as psk_writer,
)
from .psk import reader as psk_reader, ui as psk_ui from .psk import reader as psk_reader, ui as psk_ui
from .psk.export import properties as psk_export_properties, ui as psk_export_ui, \ from .psk.export import (
operators as psk_export_operators operators as psk_export_operators,
properties as psk_export_properties,
ui as psk_export_ui,
)
from .psk.import_ import operators as psk_import_operators from .psk.import_ import operators as psk_import_operators
from .psa import config as psa_config, data as psa_data, writer as psa_writer, reader as psa_reader, \ from .psa import (
builder as psa_builder, importer as psa_importer config as psa_config,
from .psa.export import properties as psa_export_properties, ui as psa_export_ui, \ data as psa_data,
operators as psa_export_operators writer as psa_writer,
reader as psa_reader,
builder as psa_builder,
importer as psa_importer,
)
from .psa.export import (
properties as psa_export_properties,
ui as psa_export_ui,
operators as psa_export_operators,
)
from .psa.import_ import operators as psa_import_operators from .psa.import_ import operators as psa_import_operators
from .psa.import_ import ui as psa_import_ui, properties as psa_import_properties from .psa.import_ import ui as psa_import_ui, properties as psa_import_properties
import bpy import bpy
from bpy.props import PointerProperty from bpy.props import PointerProperty
# TODO: just here so that it's not unreferenced and removed on save.
if [shared_data, shared_helpers, psk_data, psk_reader, psk_writer, psk_builder, psk_importer, psa_data, psa_config,
psa_reader, psa_writer, psa_builder, psa_importer]:
pass
classes = shared_types.classes +\ classes = shared_types.classes +\
psk_properties.classes +\ psk_properties.classes +\
psk_ui.classes +\ psk_ui.classes +\

View File

@@ -24,4 +24,4 @@ paths_exclude_pattern = [
] ]
[permissions] [permissions]
files = "Import/export PSK and PSA files from/to disk" files = "Read and write PSK and PSA files from and to disk"

View File

@@ -124,9 +124,6 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
if len(psa.bones) == 0: if len(psa.bones) == 0:
raise RuntimeError('No bones available for export') raise RuntimeError('No bones available for export')
# We invert the export space matrix so that we neutralize the transform of the armature object.
# export_space_matrix_inverse = get_export_space_matrix(options.export_space, armature_object)
# Add prefixes and suffices to the names of the export sequences and strip whitespace. # Add prefixes and suffices to the names of the export sequences and strip whitespace.
for export_sequence in options.sequences: for export_sequence in options.sequences:
export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}' export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}'

View File

@@ -1,16 +1,15 @@
import typing
from collections import OrderedDict from collections import OrderedDict
from typing import List from typing import List, OrderedDict as OrderedDictType
from ctypes import Structure, c_char, c_int32, c_float from ctypes import Structure, c_char, c_int32, c_float
from ..shared.data import PsxBone, Quaternion, Vector3 from ..shared.data import PsxBone, Quaternion, Vector3
class Psa: class Psa:
''' """
Note that keys are not stored within the Psa object. Note that keys are not stored within the Psa object.
Use the PsaReader::get_sequence_keys to get the keys for a sequence. Use the `PsaReader.get_sequence_keys` to get the keys for a sequence.
''' """
class Sequence(Structure): class Sequence(Structure):
_fields_ = [ _fields_ = [
@@ -50,5 +49,5 @@ class Psa:
def __init__(self): def __init__(self):
self.bones: List[PsxBone] = [] self.bones: List[PsxBone] = []
self.sequences: typing.OrderedDict[str, Psa.Sequence] = OrderedDict() self.sequences: OrderedDictType[str, Psa.Sequence] = OrderedDict()
self.keys: List[Psa.Key] = [] self.keys: List[Psa.Key] = []

View File

@@ -6,8 +6,12 @@ from bpy.props import StringProperty
from bpy.types import Context, Action, Object, AnimData, TimelineMarker, Operator from bpy.types import Context, Action, Object, AnimData, TimelineMarker, Operator
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences, \ from .properties import (
get_sequences_from_name_and_frame_range PSA_PG_export,
PSA_PG_export_action_list_item,
filter_sequences,
get_sequences_from_name_and_frame_range,
)
from ..builder import build_psa, PsaBuildSequence, PsaBuildOptions from ..builder import build_psa, PsaBuildSequence, PsaBuildOptions
from ..writer import write_psa from ..writer import write_psa
from ...shared.helpers import populate_bone_collection_list, get_nla_strips_in_frame_range from ...shared.helpers import populate_bone_collection_list, get_nla_strips_in_frame_range
@@ -98,7 +102,8 @@ def update_actions_and_timeline_markers(context: Context, armature_objects: Iter
for pose_marker_index, pose_marker in enumerate(pose_markers): for pose_marker_index, pose_marker in enumerate(pose_markers):
if pose_marker.name.strip() == '' or pose_marker.name.startswith('#'): if pose_marker.name.strip() == '' or pose_marker.name.startswith('#'):
continue continue
for (name, frame_start, frame_end) in get_sequences_from_action_pose_markers(action, pose_markers, pose_marker, pose_marker_index): sequences = get_sequences_from_action_pose_markers(action, pose_markers, pose_marker, pose_marker_index)
for (name, frame_start, frame_end) in sequences:
item = pg.action_list.add() item = pg.action_list.add()
item.action = action item.action = action
item.name = name item.name = name
@@ -154,7 +159,11 @@ def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actio
assert False, f'Invalid FPS source: {fps_source}' assert False, f'Invalid FPS source: {fps_source}'
def get_sequence_compression_ratio(compression_ratio_source: str, compression_ratio_custom: float, actions: Iterable[Action]) -> float: def get_sequence_compression_ratio(
compression_ratio_source: str,
compression_ratio_custom: float,
actions: Iterable[Action],
) -> float:
match compression_ratio_source: match compression_ratio_source:
case 'ACTION_METADATA': case 'ACTION_METADATA':
# Get the minimum value of action metadata compression ratio values. # Get the minimum value of action metadata compression ratio values.
@@ -181,7 +190,11 @@ def get_animation_data_object(context: Context) -> Object:
return animation_data_object return animation_data_object
def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context: Context, marker_names: List[str]) -> Dict: def get_timeline_marker_sequence_frame_ranges(
animation_data: AnimData,
context: Context,
marker_names: List[str],
) -> Dict:
# Timeline markers need to be sorted so that we can determine the sequence start and end positions. # Timeline markers need to be sorted so that we can determine the sequence start and end positions.
sequence_frame_ranges = dict() sequence_frame_ranges = dict()
sorted_timeline_markers = list(sorted(context.scene.timeline_markers, key=lambda x: x.frame)) sorted_timeline_markers = list(sorted(context.scene.timeline_markers, key=lambda x: x.frame))
@@ -239,7 +252,12 @@ def get_sequences_from_action(action: Action):
yield from 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): 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('!'):
@@ -378,8 +396,10 @@ class PSA_OT_export(Operator, ExportHelper):
row.operator(PSA_OT_export_bone_collections_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') row.operator(PSA_OT_export_bone_collections_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
row.operator(PSA_OT_export_bone_collections_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') row.operator(PSA_OT_export_bone_collections_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
rows = max(3, min(len(pg.bone_collection_list), 10)) rows = max(3, min(len(pg.bone_collection_list), 10))
bones_panel.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', bones_panel.template_list(
rows=rows) 'PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index',
rows=rows
)
bones_advanced_header, bones_advanced_panel = layout.panel('Advanced', default_closed=False) bones_advanced_header, bones_advanced_panel = layout.panel('Advanced', default_closed=False)
bones_advanced_header.label(text='Advanced') bones_advanced_header.label(text='Advanced')
@@ -559,7 +579,7 @@ class PSA_OT_export_actions_select_all(Operator):
case 'ACTIVE_ACTION': case 'ACTIVE_ACTION':
return pg.active_action_list return pg.active_action_list
case _: case _:
return None assert False, f'Invalid sequence source: {pg.sequence_source}'
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):

View File

@@ -2,22 +2,23 @@ import re
import sys import sys
from fnmatch import fnmatch from fnmatch import fnmatch
from typing import List, Optional from typing import List, Optional
from bpy.props import (
from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \ BoolProperty,
StringProperty PointerProperty,
EnumProperty,
FloatProperty,
CollectionProperty,
IntProperty,
StringProperty,
)
from bpy.types import PropertyGroup, Object, Action, AnimData, Context from bpy.types import PropertyGroup, Object, Action, AnimData, Context
from ...shared.types import ForwardUpAxisMixin, ExportSpaceMixin, PsxBoneExportMixin
from ...shared.data import bone_filter_mode_items, ForwardUpAxisMixin, ExportSpaceMixin
from ...shared.types import PSX_PG_bone_collection_list_item
def psa_export_property_group_animation_data_override_poll(_context, obj): def psa_export_property_group_animation_data_override_poll(_context, obj):
return obj.animation_data is not None return obj.animation_data is not None
empty_set = set()
class PSA_PG_export_action_list_item(PropertyGroup): class PSA_PG_export_action_list_item(PropertyGroup):
action: PointerProperty(type=Action) action: PointerProperty(type=Action)
name: StringProperty() name: StringProperty()
@@ -102,10 +103,10 @@ def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context):
self.nla_track = '' self.nla_track = ''
class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin): class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin, PsxBoneExportMixin):
should_override_animation_data: BoolProperty( should_override_animation_data: BoolProperty(
name='Override Animation Data', name='Override Animation Data',
options=empty_set, options=set(),
default=False, default=False,
description='Use the animation data from a different object instead of the selected object', description='Use the animation data from a different object instead of the selected object',
update=animation_data_override_update_cb, update=animation_data_override_update_cb,
@@ -117,7 +118,7 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
) )
sequence_source: EnumProperty( sequence_source: EnumProperty(
name='Source', name='Source',
options=empty_set, options=set(),
description='', description='',
items=( items=(
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0), ('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
@@ -128,7 +129,7 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
) )
nla_track: StringProperty( nla_track: StringProperty(
name='NLA Track', name='NLA Track',
options=empty_set, options=set(),
description='', description='',
search=nla_track_search_cb, search=nla_track_search_cb,
update=nla_track_update_cb update=nla_track_update_cb
@@ -136,7 +137,7 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
nla_track_index: IntProperty(name='NLA Track Index', default=-1) nla_track_index: IntProperty(name='NLA Track Index', default=-1)
fps_source: EnumProperty( fps_source: EnumProperty(
name='FPS Source', name='FPS Source',
options=empty_set, options=set(),
description='', description='',
items=( items=(
('SCENE', 'Scene', '', 'SCENE_DATA', 0), ('SCENE', 'Scene', '', 'SCENE_DATA', 0),
@@ -144,11 +145,11 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
('CUSTOM', 'Custom', '', 2) ('CUSTOM', 'Custom', '', 2)
) )
) )
fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=empty_set, step=100, fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=set(), step=100,
soft_max=60.0) soft_max=60.0)
compression_ratio_source: EnumProperty( compression_ratio_source: EnumProperty(
name='Compression Ratio Source', name='Compression Ratio Source',
options=empty_set, options=set(),
description='', description='',
items=( items=(
('ACTION_METADATA', 'Action Metadata', 'The compression ratio will be determined by action\'s Compression Ratio property found in the PSA Export panel.\n\nIf the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used', 'ACTION', 1), ('ACTION_METADATA', 'Action Metadata', 'The compression ratio will be determined by action\'s Compression Ratio property found in the PSA Export panel.\n\nIf the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used', 'ACTION', 1),
@@ -164,16 +165,8 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
nla_strip_list_index: IntProperty(default=0) nla_strip_list_index: IntProperty(default=0)
active_action_list: CollectionProperty(type=PSA_PG_export_active_action_list_item) active_action_list: CollectionProperty(type=PSA_PG_export_active_action_list_item)
active_action_list_index: IntProperty(default=0) active_action_list_index: IntProperty(default=0)
bone_filter_mode: EnumProperty( sequence_name_prefix: StringProperty(name='Prefix', options=set())
name='Bone Filter', sequence_name_suffix: StringProperty(name='Suffix', options=set())
options=empty_set,
description='',
items=bone_filter_mode_items,
)
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
bone_collection_list_index: IntProperty(default=0, name='', description='')
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)
sequence_filter_name: StringProperty( sequence_filter_name: StringProperty(
default='', default='',
name='Filter by Name', name='Filter by Name',
@@ -182,21 +175,21 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
sequence_use_filter_invert: BoolProperty( sequence_use_filter_invert: BoolProperty(
default=False, default=False,
name='Invert', name='Invert',
options=empty_set, options=set(),
description='Invert filtering (show hidden items, and vice versa)') description='Invert filtering (show hidden items, and vice versa)')
sequence_filter_asset: BoolProperty( sequence_filter_asset: BoolProperty(
default=False, default=False,
name='Show assets', name='Show assets',
options=empty_set, options=set(),
description='Show actions that belong to an asset library') description='Show actions that belong to an asset library')
sequence_filter_pose_marker: BoolProperty( sequence_filter_pose_marker: BoolProperty(
default=True, default=True,
name='Show pose markers', name='Show pose markers',
options=empty_set) options=set())
sequence_use_filter_sort_reverse: BoolProperty(default=True, options=empty_set) sequence_use_filter_sort_reverse: BoolProperty(default=True, options=set())
sequence_filter_reversed: BoolProperty( sequence_filter_reversed: BoolProperty(
default=True, default=True,
options=empty_set, options=set(),
name='Show Reversed', name='Show Reversed',
description='Show reversed sequences' description='Show reversed sequences'
) )
@@ -209,7 +202,7 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
) )
sampling_mode: EnumProperty( sampling_mode: EnumProperty(
name='Sampling Mode', name='Sampling Mode',
options=empty_set, options=set(),
description='The method by which frames are sampled', description='The method by which frames are sampled',
items=( items=(
('INTERPOLATED', 'Interpolated', 'Sampling is performed by interpolating the evaluated bone poses from the adjacent whole frames.', 'INTERPOLATED', 0), ('INTERPOLATED', 'Interpolated', 'Sampling is performed by interpolating the evaluated bone poses from the adjacent whole frames.', 'INTERPOLATED', 0),
@@ -217,11 +210,6 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
), ),
default='INTERPOLATED' default='INTERPOLATED'
) )
root_bone_name: StringProperty(
name='Root Bone Name',
description='The name of the generated root bone when exporting multiple armatures',
default='ROOT',
)
def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]: def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:

View File

@@ -1,4 +1,4 @@
import typing from typing import cast as typing_cast
from bpy.types import UIList from bpy.types import UIList
@@ -14,7 +14,7 @@ class PSA_UL_export_sequences(UIList):
self.use_filter_show = True self.use_filter_show = True
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):
item = typing.cast(PSA_PG_export_action_list_item, item) item = typing_cast(PSA_PG_export_action_list_item, item)
is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker
layout.prop(item, 'is_selected', icon_only=True, text=item.name) layout.prop(item, 'is_selected', icon_only=True, text=item.name)

View File

@@ -2,20 +2,25 @@ import re
from fnmatch import fnmatch from fnmatch import fnmatch
from typing import List from typing import List
from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty, PointerProperty, EnumProperty, \ from bpy.props import (
FloatProperty BoolProperty,
CollectionProperty,
EnumProperty,
FloatProperty,
IntProperty,
PointerProperty,
StringProperty,
)
from bpy.types import PropertyGroup, Text from bpy.types import PropertyGroup, Text
empty_set = set()
class PSA_PG_import_action_list_item(PropertyGroup): class PSA_PG_import_action_list_item(PropertyGroup):
action_name: StringProperty(options=empty_set) action_name: StringProperty(options=set())
is_selected: BoolProperty(default=True, options=empty_set) is_selected: BoolProperty(default=True, options=set())
class PSA_PG_bone(PropertyGroup): class PSA_PG_bone(PropertyGroup):
bone_name: StringProperty(options=empty_set) bone_name: StringProperty(options=set())
class PSA_PG_data(PropertyGroup): class PSA_PG_data(PropertyGroup):
@@ -43,31 +48,31 @@ 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',
options=empty_set) options=set())
should_use_config_file: BoolProperty(default=True, name='Use Config File', should_use_config_file: BoolProperty(default=True, name='Use Config File',
description='Use the .config file that is sometimes generated when the PSA ' description='Use the .config file that is sometimes generated when the PSA '
'file is exported from UEViewer. This file contains ' 'file is exported from UEViewer. This file contains '
'options that can be used to filter out certain bones tracks ' 'options that can be used to filter out certain bones tracks '
'from the imported actions', 'from the imported actions',
options=empty_set) options=set())
should_stash: BoolProperty(default=False, name='Stash', should_stash: BoolProperty(default=False, name='Stash',
description='Stash each imported action as a strip on a new non-contributing NLA track', description='Stash each imported action as a strip on a new non-contributing NLA track',
options=empty_set) options=set())
should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=empty_set) should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=set())
action_name_prefix: StringProperty(default='', name='Prefix', options=empty_set) action_name_prefix: StringProperty(default='', name='Prefix', options=set())
should_overwrite: BoolProperty(default=False, name='Overwrite', options=empty_set, should_overwrite: BoolProperty(default=False, name='Overwrite', options=set(),
description='If an action with a matching name already exists, the existing action ' 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') '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_keyframes: BoolProperty(default=True, name='Keyframes', options=set())
should_write_metadata: BoolProperty(default=True, name='Metadata', options=empty_set, should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(),
description='Additional data will be written to the custom properties of the ' description='Additional data will be written to the custom properties of the '
'Action (e.g., frame rate)') 'Action (e.g., frame rate)')
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected', sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected',
description='Only show selected sequences') description='Only show selected sequences')
sequence_use_filter_invert: BoolProperty(default=False, options=empty_set) sequence_use_filter_invert: BoolProperty(default=False, options=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=set())
should_convert_to_samples: BoolProperty( should_convert_to_samples: BoolProperty(
default=False, default=False,
@@ -77,7 +82,7 @@ class PsaImportMixin:
) )
bone_mapping_mode: EnumProperty( bone_mapping_mode: EnumProperty(
name='Bone Mapping', name='Bone Mapping',
options=empty_set, options=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=bone_mapping_items, items=bone_mapping_items,
default='CASE_INSENSITIVE' default='CASE_INSENSITIVE'
@@ -87,7 +92,7 @@ class PsaImportMixin:
default=30.0, default=30.0,
name='Custom FPS', name='Custom FPS',
description='The frame rate to which the imported sequences will be resampled to', description='The frame rate to which the imported sequences will be resampled to',
options=empty_set, options=set(),
min=1.0, min=1.0,
soft_min=1.0, soft_min=1.0,
soft_max=60.0, soft_max=60.0,
@@ -98,7 +103,7 @@ class PsaImportMixin:
default=1.0, default=1.0,
name='Custom Compression Ratio', name='Custom Compression Ratio',
description='The compression ratio to apply to the imported sequences', description='The compression ratio to apply to the imported sequences',
options=empty_set, options=set(),
min=0.0, min=0.0,
soft_min=0.0, soft_min=0.0,
soft_max=1.0, soft_max=1.0,
@@ -119,11 +124,11 @@ class PSA_PG_import(PropertyGroup):
sequence_list: CollectionProperty(type=PSA_PG_import_action_list_item) sequence_list: CollectionProperty(type=PSA_PG_import_action_list_item)
sequence_list_index: IntProperty(name='', default=0) sequence_list_index: IntProperty(name='', default=0)
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected', sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected',
description='Only show selected sequences') description='Only show selected sequences')
sequence_use_filter_invert: BoolProperty(default=False, options=empty_set) sequence_use_filter_invert: BoolProperty(default=False, options=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=set())
select_text: PointerProperty(type=Text) select_text: PointerProperty(type=Text)

View File

@@ -1,9 +1,9 @@
import typing import typing
from typing import List, Optional from typing import Iterable, List, Optional, cast as typing_cast
import bpy import bpy
import numpy as np import numpy as np
from bpy.types import Context, FCurve, Object from bpy.types import Armature, Context, FCurve, Object
from mathutils import Vector, Quaternion from mathutils import Vector, Quaternion
from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION
@@ -56,7 +56,7 @@ class ImportBone(object):
self.fcurves: List[FCurve] = [] self.fcurves: List[FCurve] = []
def _calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[float]): def _calculate_fcurve_data(import_bone: ImportBone, key_data: Iterable[float]):
# Convert world-space transforms to local-space transforms. # Convert world-space transforms to local-space transforms.
key_rotation = Quaternion(key_data[0:4]) key_rotation = Quaternion(key_data[0:4])
key_location = Vector(key_data[4:]) key_location = Vector(key_data[4:])
@@ -83,7 +83,7 @@ def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_name
""" """
@param psa_bone_name: The name of the PSA bone. @param psa_bone_name: The name of the PSA bone.
@param armature_bone_names: The names of the bones in the armature. @param armature_bone_names: The names of the bones in the armature.
@param bone_mapping_mode: One of 'EXACT' or 'CASE_INSENSITIVE'. @param bone_mapping_mode: One of `['EXACT', 'CASE_INSENSITIVE']`.
@return: The index of the armature bone that corresponds to the given PSA bone, or None if no such bone exists. @return: The index of the armature bone that corresponds to the given PSA bone, or None if no such bone exists.
""" """
for armature_bone_index, armature_bone_name in enumerate(armature_bone_names): for armature_bone_index, armature_bone_name in enumerate(armature_bone_names):
@@ -95,24 +95,27 @@ def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_name
return armature_bone_index return armature_bone_index
return None return None
def _get_sample_frame_times(source_frame_count: int, frame_step: float) -> typing.Iterable[float]:
def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step: float = 1.0) -> np.ndarray:
"""
Resamples the sequence data matrix to the target frame count.
@param sequence_data_matrix: FxBx7 matrix where F is the number of frames, B is the number of bones, and X is the
number of data elements per bone.
@param frame_step: The step between frames in the resampled sequence.
@return: The resampled sequence data matrix, or sequence_data_matrix if no resampling is necessary.
"""
def _get_sample_frame_times(source_frame_count: int, frame_step: float) -> Iterable[float]:
# TODO: for correctness, we should also emit the target frame time as well (because the last frame can be a # TODO: for correctness, we should also emit the target frame time as well (because the last frame can be a
# fractional frame). # fractional frame).
assert frame_step > 0.0, 'Frame step must be greater than 0'
time = 0.0 time = 0.0
while time < source_frame_count - 1: while time < source_frame_count - 1:
yield time yield time
time += frame_step time += frame_step
yield source_frame_count - 1 yield source_frame_count - 1
def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step: float = 1.0) -> np.ndarray:
"""
Resamples the sequence data matrix to the target frame count.
@param sequence_data_matrix: FxBx7 matrix where F is the number of frames, B is the number of bones, and X is the
number of data elements per bone.
@param frame_step: The step between frames in the resampled sequence.
@return: The resampled sequence data matrix, or sequence_data_matrix if no resampling is necessary.
"""
if frame_step == 1.0: if frame_step == 1.0:
# No resampling is necessary. # No resampling is necessary.
return sequence_data_matrix return sequence_data_matrix
@@ -145,7 +148,7 @@ def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step:
def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult: def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult:
result = PsaImportResult() result = PsaImportResult()
sequences = [psa_reader.sequences[x] for x in options.sequence_names] sequences = [psa_reader.sequences[x] for x in options.sequence_names]
armature_data = typing.cast(bpy.types.Armature, armature_object.data) armature_data = typing_cast(Armature, armature_object.data)
# Create an index mapping from bones in the PSA to bones in the target armature. # Create an index mapping from bones in the PSA to bones in the target armature.
psa_to_armature_bone_indices = {} psa_to_armature_bone_indices = {}

View File

@@ -53,6 +53,7 @@ class PsaReader(object):
def read_sequence_data_matrix(self, sequence_name: str) -> np.ndarray: def read_sequence_data_matrix(self, sequence_name: str) -> np.ndarray:
""" """
Reads and returns the data matrix for the given sequence. Reads and returns the data matrix for the given sequence.
@param sequence_name: The name of the sequence. @param sequence_name: The name of the sequence.
@return: An FxBx7 matrix where F is the number of frames, B is the number of bones. @return: An FxBx7 matrix where F is the number of frames, B is the number of bones.
""" """

View File

@@ -1,18 +1,18 @@
import typing
from typing import Dict, Generator, Set, Iterable, Optional, cast, Tuple, List
import bmesh import bmesh
import bpy import bpy
import numpy as np import numpy as np
from bpy.types import Collection, Context, Object, Armature, Depsgraph from bpy.types import Armature, Collection, Context, Depsgraph, Object
from mathutils import Matrix from mathutils import Matrix
from typing import Dict, Generator, Iterable, List, Optional, Set, Tuple, cast as typing_cast
from .data import Psk from .data import Psk
from .properties import triangle_type_and_bit_flags_to_poly_flags from .properties import triangle_type_and_bit_flags_to_poly_flags
from ..shared.data import Vector3 from ..shared.data import Vector3
from ..shared.dfs import dfs_collection_objects, dfs_view_layer_objects, DfsObject from ..shared.dfs import DfsObject, dfs_collection_objects, dfs_view_layer_objects
from ..shared.helpers import convert_string_to_cp1252_bytes, \ from ..shared.helpers import (
create_psx_bones, get_coordinate_system_transform convert_string_to_cp1252_bytes,
create_psx_bones,
get_coordinate_system_transform,
)
class PskInputObjects(object): class PskInputObjects(object):
@@ -108,11 +108,11 @@ class PskBuildResult(object):
self.warnings: List[str] = [] self.warnings: List[str] = []
def _get_mesh_export_space_matrix(armature_object: Object, export_space: str) -> Matrix: def _get_mesh_export_space_matrix(armature_object: Optional[Object], export_space: str) -> Matrix:
if armature_object is None: if armature_object is None:
return Matrix.Identity(4) return Matrix.Identity(4)
def get_object_space_space_matrix(obj: Object) -> Matrix: def get_object_space_matrix(obj: Object) -> Matrix:
translation, rotation, _ = obj.matrix_world.decompose() translation, rotation, _ = obj.matrix_world.decompose()
# We neutralize the scale here because the scale is already applied to the mesh objects implicitly. # We neutralize the scale here because the scale is already applied to the mesh objects implicitly.
return Matrix.Translation(translation) @ rotation.to_matrix().to_4x4() return Matrix.Translation(translation) @ rotation.to_matrix().to_4x4()
@@ -121,10 +121,10 @@ def _get_mesh_export_space_matrix(armature_object: Object, export_space: str) ->
case 'WORLD': case 'WORLD':
return Matrix.Identity(4) return Matrix.Identity(4)
case 'ARMATURE': case 'ARMATURE':
return get_object_space_space_matrix(armature_object).inverted() return get_object_space_matrix(armature_object).inverted()
case 'ROOT': case 'ROOT':
armature_data = cast(armature_object.data, Armature) armature_data = typing_cast(Armature, armature_object.data)
armature_space_matrix = get_object_space_space_matrix(armature_object) @ armature_data.bones[0].matrix_local armature_space_matrix = get_object_space_matrix(armature_object) @ armature_data.bones[0].matrix_local
return armature_space_matrix.inverted() return armature_space_matrix.inverted()
case _: case _:
assert False, f'Invalid export space: {export_space}' assert False, f'Invalid export space: {export_space}'
@@ -200,15 +200,15 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
coordinate_system_matrix = get_coordinate_system_transform(options.forward_axis, options.up_axis) coordinate_system_matrix = get_coordinate_system_transform(options.forward_axis, options.up_axis)
# TODO: we need to store this per armature # Calculate the export spaces for the armature objects.
armature_mesh_export_space_matrices: Dict[Optional[Object], Matrix] = dict() # This is used later to transform the mesh object geometry into the export space.
armature_mesh_export_space_matrices: Dict[Optional[Object], Matrix] = {None: Matrix.Identity(4)}
for armature_object in armature_objects: for armature_object in armature_objects:
armature_mesh_export_space_matrices[armature_object] = _get_mesh_export_space_matrix(armature_object, options.export_space) armature_mesh_export_space_matrices[armature_object] = _get_mesh_export_space_matrix(armature_object, options.export_space)
scale_matrix = Matrix.Scale(options.scale, 4) scale_matrix = Matrix.Scale(options.scale, 4)
original_armature_object_pose_positions = {armature_object: armature_object.data.pose_position for armature_object in armature_objects} original_armature_object_pose_positions = {a: a.data.pose_position for a in armature_objects}
# Temporarily force the armature into the rest position. # Temporarily force the armature into the rest position.
# We will undo this later. # We will undo this later.
@@ -218,7 +218,7 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
material_names = [m.name for m in materials] material_names = [m.name for m in materials]
for object_index, input_mesh_object in enumerate(input_objects.mesh_dfs_objects): for object_index, input_mesh_object in enumerate(input_objects.mesh_dfs_objects):
obj, instance_objects, matrix_world = input_mesh_object.obj, input_mesh_object.instance_objects, input_mesh_object.matrix_world obj, matrix_world = input_mesh_object.obj, input_mesh_object.matrix_world
armature_object = get_armature_for_mesh_object(obj) armature_object = get_armature_for_mesh_object(obj)
@@ -272,6 +272,7 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
# the normals will be correct. We can detect this by checking if the number of negative scaling axes is # the normals will be correct. We can detect this by checking if the number of negative scaling axes is
# odd. If it is, we need to invert the normals of the mesh by swapping the order of the vertices in each # odd. If it is, we need to invert the normals of the mesh by swapping the order of the vertices in each
# face. # face.
if not should_flip_normals:
should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1 should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1
# Copy the vertex groups # Copy the vertex groups
@@ -285,6 +286,7 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
point_transform_matrix = vertex_transform_matrix @ mesh_object.matrix_world point_transform_matrix = vertex_transform_matrix @ mesh_object.matrix_world
# Vertices # Vertices
vertex_offset = len(psk.points)
for vertex in mesh_data.vertices: for vertex in mesh_data.vertices:
point = Vector3() point = Vector3()
v = point_transform_matrix @ vertex.co v = point_transform_matrix @ vertex.co
@@ -298,8 +300,6 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
# Wedges # Wedges
mesh_data.calc_loop_triangles() mesh_data.calc_loop_triangles()
vertex_offset = len(psk.points)
# Build a list of non-unique wedges. # Build a list of non-unique wedges.
wedges = [] wedges = []
for loop_index, loop in enumerate(mesh_data.loops): for loop_index, loop in enumerate(mesh_data.loops):
@@ -346,7 +346,7 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
# Weights # Weights
if armature_object is not None: if armature_object is not None:
armature_data = typing.cast(Armature, armature_object.data) armature_data = typing_cast(Armature, armature_object.data)
bone_index_offset = psx_bone_create_result.armature_object_root_bone_indices[armature_object] bone_index_offset = psx_bone_create_result.armature_object_root_bone_indices[armature_object]
# Because the vertex groups may contain entries for which there is no matching bone in the armature, # Because the vertex groups may contain entries for which there is no matching bone in the armature,
# we must filter them out and not export any weights for these vertex groups. # we must filter them out and not export any weights for these vertex groups.

View File

@@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from typing import Iterable, List, Optional, cast from typing import Iterable, List, Optional, cast as typing_cast
import bpy import bpy
from bpy.props import BoolProperty, StringProperty from bpy.props import BoolProperty, StringProperty
@@ -7,8 +7,13 @@ from bpy.types import Collection, Context, Depsgraph, Material, Object, Operator
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from .properties import PskExportMixin from .properties import PskExportMixin
from ..builder import PskBuildOptions, build_psk, get_psk_input_objects_for_context, \ from ..builder import (
get_psk_input_objects_for_collection, get_materials_for_mesh_objects PskBuildOptions,
build_psk,
get_materials_for_mesh_objects,
get_psk_input_objects_for_collection,
get_psk_input_objects_for_context,
)
from ..writer import write_psk from ..writer import write_psk
from ...shared.helpers import populate_bone_collection_list from ...shared.helpers import populate_bone_collection_list
from ...shared.ui import draw_bone_filter_mode from ...shared.ui import draw_bone_filter_mode
@@ -34,10 +39,10 @@ def get_collection_from_context(context: Context) -> Optional[Collection]:
if context.space_data.type != 'PROPERTIES': if context.space_data.type != 'PROPERTIES':
return None return None
space_data = cast(SpaceProperties, context.space_data) space_data = typing_cast(SpaceProperties, context.space_data)
if space_data.use_pin_id: if space_data.use_pin_id:
return cast(Collection, space_data.pin_id) return typing_cast(Collection, space_data.pin_id)
else: else:
return context.collection return context.collection
@@ -226,13 +231,11 @@ class PSK_OT_material_list_name_move_down(Operator):
return {'FINISHED'} return {'FINISHED'}
empty_set = set()
def get_sorted_materials_by_names(materials: Iterable[Material], material_names: List[str]) -> List[Material]: def get_sorted_materials_by_names(materials: Iterable[Material], material_names: List[str]) -> List[Material]:
""" """
Sorts the materials by the order of the material names list. Any materials not in the list will be appended to the Sorts the materials by the order of the material names list. Any materials not in the list will be appended to the
end of the list in the order they are found. end of the list in the order they are found.
@param materials: A list of materials to sort @param materials: A list of materials to sort
@param material_names: A list of material names to sort by @param material_names: A list of material names to sort by
@return: A sorted list of materials @return: A sorted list of materials

View File

@@ -1,11 +1,14 @@
from bpy.props import EnumProperty, CollectionProperty, IntProperty, PointerProperty, FloatProperty, StringProperty, \ from bpy.props import (
BoolProperty BoolProperty,
from bpy.types import PropertyGroup, Material CollectionProperty,
EnumProperty,
from ...shared.data import bone_filter_mode_items, ExportSpaceMixin, ForwardUpAxisMixin FloatProperty,
from ...shared.types import PSX_PG_bone_collection_list_item IntProperty,
PointerProperty,
empty_set = set() StringProperty,
)
from bpy.types import Material, PropertyGroup
from ...shared.types import ExportSpaceMixin, ForwardUpAxisMixin, PsxBoneExportMixin
object_eval_state_items = ( object_eval_state_items = (
('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'), ('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'),
@@ -26,7 +29,7 @@ class PSK_PG_material_name_list_item(PropertyGroup):
index: IntProperty() index: IntProperty()
class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin): class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin, PsxBoneExportMixin):
object_eval_state: EnumProperty( object_eval_state: EnumProperty(
items=object_eval_state_items, items=object_eval_state_items,
name='Object Evaluation State', name='Object Evaluation State',
@@ -44,14 +47,6 @@ class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin):
min=0.0001, min=0.0001,
soft_max=100.0 soft_max=100.0
) )
bone_filter_mode: EnumProperty(
name='Bone Filter',
options=empty_set,
description='',
items=bone_filter_mode_items,
)
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
bone_collection_list_index: IntProperty(default=0)
material_order_mode: EnumProperty( material_order_mode: EnumProperty(
name='Material Order', name='Material Order',
description='The order in which to export the materials', description='The order in which to export the materials',
@@ -60,11 +55,6 @@ class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin):
) )
material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item) material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item)
material_name_list_index: IntProperty(default=0) material_name_list_index: IntProperty(default=0)
root_bone_name: StringProperty(
name='Root Bone Name',
description='The name of the generated root bone when exporting multiple armatures',
default='ROOT',
)
class PSK_PG_export(PropertyGroup, PskExportMixin): class PSK_PG_export(PropertyGroup, PskExportMixin):

View File

@@ -1,16 +1,14 @@
import os import os
from pathlib import Path from pathlib import Path
from bpy.props import StringProperty, CollectionProperty from bpy.props import CollectionProperty, StringProperty
from bpy.types import Operator, FileHandler, Context, OperatorFileListElement, UILayout from bpy.types import Context, FileHandler, Operator, OperatorFileListElement, UILayout
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 ..properties import PskImportMixin
from ..reader import read_psk from ..reader import read_psk
empty_set = set()
def get_psk_import_options_from_properties(property_group: PskImportMixin): def get_psk_import_options_from_properties(property_group: PskImportMixin):
options = PskImportOptions() options = PskImportOptions()
options.should_import_mesh = property_group.should_import_mesh options.should_import_mesh = property_group.should_import_mesh

View File

@@ -1,15 +1,15 @@
from typing import Optional, List
import bmesh import bmesh
import bpy import bpy
import numpy as np import numpy as np
from bpy.types import VertexGroup, Context, Object
from mathutils import Quaternion, Vector, Matrix from bpy.types import Context, Object, VertexGroup
from mathutils import Matrix, Quaternion, Vector
from typing import List, Optional
from .data import Psk from .data import Psk
from .properties import poly_flags_to_triangle_type_and_bit_flags from .properties import poly_flags_to_triangle_type_and_bit_flags
from ..shared.data import PsxBone from ..shared.data import PsxBone
from ..shared.helpers import rgb_to_srgb, is_bdk_addon_loaded from ..shared.helpers import is_bdk_addon_loaded, rgb_to_srgb
class PskImportOptions: class PskImportOptions:

View File

@@ -1,6 +1,6 @@
import sys import sys
from bpy.props import EnumProperty, BoolProperty, FloatProperty, StringProperty from bpy.props import BoolProperty, EnumProperty, FloatProperty, StringProperty
from bpy.types import PropertyGroup from bpy.types import PropertyGroup
mesh_triangle_types_items = ( mesh_triangle_types_items = (
@@ -44,8 +44,6 @@ 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()
def should_import_mesh_get(self): def should_import_mesh_get(self):
return self.import_components in {'ALL', 'MESH'} return self.import_components in {'ALL', 'MESH'}
@@ -57,13 +55,13 @@ def should_import_skleton_get(self):
class PskImportMixin: class PskImportMixin:
should_import_vertex_colors: BoolProperty( should_import_vertex_colors: BoolProperty(
default=True, default=True,
options=empty_set, options=set(),
name='Import Vertex Colors', name='Import Vertex Colors',
description='Import vertex colors, if available' description='Import vertex colors, if available'
) )
vertex_color_space: EnumProperty( vertex_color_space: EnumProperty(
name='Vertex Color Space', name='Vertex Color Space',
options=empty_set, options=set(),
description='The source vertex color space', description='The source vertex color space',
default='SRGBA', default='SRGBA',
items=( items=(
@@ -74,18 +72,18 @@ class PskImportMixin:
should_import_vertex_normals: BoolProperty( should_import_vertex_normals: BoolProperty(
default=True, default=True,
name='Import Vertex Normals', name='Import Vertex Normals',
options=empty_set, options=set(),
description='Import vertex normals, if available' description='Import vertex normals, if available'
) )
should_import_extra_uvs: BoolProperty( should_import_extra_uvs: BoolProperty(
default=True, default=True,
name='Import Extra UVs', name='Import Extra UVs',
options=empty_set, options=set(),
description='Import extra UV maps, if available' description='Import extra UV maps, if available'
) )
import_components: EnumProperty( import_components: EnumProperty(
name='Import Components', name='Import Components',
options=empty_set, options=set(),
description='Determine which components to import', description='Determine which components to import',
items=( items=(
('ALL', 'Mesh & Skeleton', 'Import mesh and skeleton'), ('ALL', 'Mesh & Skeleton', 'Import mesh and skeleton'),
@@ -101,7 +99,7 @@ class PskImportMixin:
should_import_materials: BoolProperty( should_import_materials: BoolProperty(
default=True, default=True,
name='Import Materials', name='Import Materials',
options=empty_set, options=set(),
) )
should_import_skeleton: BoolProperty( should_import_skeleton: BoolProperty(
name='Import Skeleton', name='Import Skeleton',
@@ -113,14 +111,14 @@ class PskImportMixin:
step=100, step=100,
soft_min=1.0, soft_min=1.0,
name='Bone Length', name='Bone Length',
options=empty_set, options=set(),
subtype='DISTANCE', subtype='DISTANCE',
description='Length of the bones' description='Length of the bones'
) )
should_import_shape_keys: BoolProperty( should_import_shape_keys: BoolProperty(
default=True, default=True,
name='Import Shape Keys', name='Import Shape Keys',
options=empty_set, options=set(),
description='Import shape keys, if available' description='Import shape keys, if available'
) )
scale: FloatProperty( scale: FloatProperty(
@@ -131,7 +129,7 @@ class PskImportMixin:
bdk_repository_id: StringProperty( bdk_repository_id: StringProperty(
name='BDK Repository ID', name='BDK Repository ID',
default='', default='',
options=empty_set, options=set(),
description='The ID of the BDK repository to use for loading materials' description='The ID of the BDK repository to use for loading materials'
) )

View File

@@ -4,7 +4,6 @@ import re
import warnings import warnings
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from ..shared.data import Section from ..shared.data import Section
from .data import Color, Psk, PsxBone, Vector2, Vector3 from .data import Color, Psk, PsxBone, Vector2, Vector3

View File

@@ -3,7 +3,7 @@ from ctypes import Structure, sizeof
from typing import Type from typing import Type
from .data import Psk from .data import Psk
from ..shared.data import Section, Vector3, PsxBone from ..shared.data import PsxBone, Section, Vector3
MAX_WEDGE_COUNT = 65536 MAX_WEDGE_COUNT = 65536
MAX_POINT_COUNT = 4294967296 MAX_POINT_COUNT = 4294967296

View File

@@ -1,7 +1,5 @@
from ctypes import Structure, c_char, c_int32, c_float, c_ubyte from ctypes import Structure, c_char, c_int32, c_float, c_ubyte
from typing import Tuple from typing import Tuple
from bpy.props import EnumProperty
from mathutils import Quaternion as BpyQuaternion from mathutils import Quaternion as BpyQuaternion
@@ -118,69 +116,3 @@ class Section(Structure):
def __init__(self, *args, **kw): def __init__(self, *args, **kw):
super().__init__(*args, **kw) super().__init__(*args, **kw)
self.type_flags = 1999801 self.type_flags = 1999801
bone_filter_mode_items = (
('ALL', 'All', 'All bones will be exported'),
('BONE_COLLECTIONS', 'Bone Collections', 'Only bones belonging to the selected bone collections and their ancestors will be exported')
)
axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z')
forward_items = (
('X', 'X Forward', ''),
('Y', 'Y Forward', ''),
('Z', 'Z Forward', ''),
('-X', '-X Forward', ''),
('-Y', '-Y Forward', ''),
('-Z', '-Z Forward', ''),
)
up_items = (
('X', 'X Up', ''),
('Y', 'Y Up', ''),
('Z', 'Z Up', ''),
('-X', '-X Up', ''),
('-Y', '-Y Up', ''),
('-Z', '-Z Up', ''),
)
def forward_axis_update(self, _context):
if self.forward_axis == self.up_axis:
# Automatically set the up axis to the next available axis
self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z')
def up_axis_update(self, _context):
if self.up_axis == self.forward_axis:
# Automatically set the forward axis to the next available axis
self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X')
class ForwardUpAxisMixin:
forward_axis: EnumProperty(
name='Forward',
items=forward_items,
default='X',
update=forward_axis_update
)
up_axis: EnumProperty(
name='Up',
items=up_items,
default='Z',
update=up_axis_update
)
export_space_items = [
('WORLD', 'World', 'Export in world space'),
('ARMATURE', 'Armature', 'Export the local space of the armature object'),
('ROOT', 'Root', 'Export in the space of the root bone')
]
class ExportSpaceMixin:
export_space: EnumProperty(
name='Export Space',
items=export_space_items,
default='WORLD'
)

View File

@@ -12,9 +12,9 @@ from mathutils import Matrix
class DfsObject: class DfsObject:
''' """
Represents an object in a depth-first search. Represents an object in a depth-first search.
''' """
def __init__(self, obj: Object, instance_objects: List[Object], matrix_world: Matrix): def __init__(self, obj: Object, instance_objects: List[Object], matrix_world: Matrix):
self.obj = obj self.obj = obj
self.instance_objects = instance_objects self.instance_objects = instance_objects
@@ -22,10 +22,11 @@ class DfsObject:
@property @property
def is_visible(self) -> bool: def is_visible(self) -> bool:
''' """
Check if the object is visible. Check if the object is visible.
@return: True if the object is visible, False otherwise. @return: True if the object is visible, False otherwise.
''' """
if self.instance_objects: if self.instance_objects:
return self.instance_objects[-1].visible_get() return self.instance_objects[-1].visible_get()
return self.obj.visible_get() return self.obj.visible_get()
@@ -41,11 +42,11 @@ class DfsObject:
return self.obj.select_get() return self.obj.select_get()
def _dfs_object_children(obj: Object, collection: Collection) -> Iterable[Object]: def _dfs_object_children(obj: Object, collection: Collection) -> Iterable[Object]:
''' '''
Construct a list of objects in hierarchy order from `collection.objects`, only keeping those that are in the Construct a list of objects in hierarchy order from `collection.objects`, only keeping those that are in the
collection. collection.
@param obj: The object to start the search from. @param obj: The object to start the search from.
@param collection: The collection to search in. @param collection: The collection to search in.
@return: An iterable of objects in hierarchy order. @return: An iterable of objects in hierarchy order.
@@ -60,6 +61,7 @@ def dfs_objects_in_collection(collection: Collection) -> Iterable[Object]:
''' '''
Returns a depth-first iterator over all objects in a collection, only keeping those that are directly in the Returns a depth-first iterator over all objects in a collection, only keeping those that are directly in the
collection. collection.
@param collection: The collection to search in. @param collection: The collection to search in.
@return: An iterable of objects in hierarchy order. @return: An iterable of objects in hierarchy order.
''' '''
@@ -74,6 +76,7 @@ def dfs_objects_in_collection(collection: Collection) -> Iterable[Object]:
def dfs_collection_objects(collection: Collection, visible_only: bool = False) -> Iterable[DfsObject]: def dfs_collection_objects(collection: Collection, visible_only: bool = False) -> Iterable[DfsObject]:
''' '''
Depth-first search of objects in a collection, including recursing into instances. Depth-first search of objects in a collection, including recursing into instances.
@param collection: The collection to search in. @param collection: The collection to search in.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix. @return: An iterable of tuples containing the object, the instance objects, and the world matrix.
''' '''
@@ -89,6 +92,7 @@ def _dfs_collection_objects_recursive(
''' '''
Depth-first search of objects in a collection, including recursing into instances. Depth-first search of objects in a collection, including recursing into instances.
This is a recursive function. This is a recursive function.
@param collection: The collection to search in. @param collection: The collection to search in.
@param instance_objects: The running hierarchy of instance objects. @param instance_objects: The running hierarchy of instance objects.
@param matrix_world: The world matrix of the current object. @param matrix_world: The world matrix of the current object.
@@ -130,6 +134,7 @@ def _dfs_collection_objects_recursive(
def dfs_view_layer_objects(view_layer: ViewLayer) -> Iterable[DfsObject]: def dfs_view_layer_objects(view_layer: ViewLayer) -> Iterable[DfsObject]:
''' '''
Depth-first iterator over all objects in a view layer, including recursing into instances. Depth-first iterator over all objects in a view layer, including recursing into instances.
@param view_layer: The view layer to inspect. @param view_layer: The view layer to inspect.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix. @return: An iterable of tuples containing the object, the instance objects, and the world matrix.
''' '''
@@ -146,6 +151,7 @@ def dfs_view_layer_objects(view_layer: ViewLayer) -> Iterable[DfsObject]:
def _is_dfs_object_visible(obj: Object, instance_objects: List[Object]) -> bool: def _is_dfs_object_visible(obj: Object, instance_objects: List[Object]) -> bool:
''' '''
Check if a DFS object is visible. Check if a DFS object is visible.
@param obj: The object. @param obj: The object.
@param instance_objects: The instance objects. @param instance_objects: The instance objects.
@return: True if the object is visible, False otherwise. @return: True if the object is visible, False otherwise.

View File

@@ -1,12 +1,9 @@
from collections import Counter
from typing import List, Iterable, cast, Optional, Dict, Tuple
import bpy import bpy
from collections import Counter
from typing import List, Iterable, Optional, Dict, Tuple, cast as typing_cast
from bpy.props import CollectionProperty from bpy.props import CollectionProperty
from bpy.types import AnimData, Object from bpy.types import Armature, AnimData, Object
from bpy.types import Armature
from mathutils import Matrix, Vector from mathutils import Matrix, Vector
from .data import Vector3, Quaternion from .data import Vector3, Quaternion
from ..shared.data import PsxBone from ..shared.data import PsxBone
@@ -57,7 +54,7 @@ def populate_bone_collection_list(armature_objects: Iterable[Object], bone_colle
bone_collection_list.clear() bone_collection_list.clear()
for armature_object in armature_objects: for armature_object in armature_objects:
armature = cast(Armature, armature_object.data) armature = typing_cast(Armature, armature_object.data)
if armature is None: if armature is None:
return return
@@ -85,15 +82,15 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
Note that the ancestors of bones within the bone collections will also be present in the returned list. Note that the ancestors of bones within the bone collections will also be present in the returned list.
:param armature_object: Blender object with type 'ARMATURE' :param armature_object: Blender object with type `'ARMATURE'`
:param bone_filter_mode: One of ['ALL', 'BONE_COLLECTIONS'] :param bone_filter_mode: One of `['ALL', 'BONE_COLLECTIONS']`
:param bone_collection_indices: A list of bone collection indices to export. :param bone_collection_indices: A list of bone collection indices to export.
:return: A sorted list of bone indices that should be exported. :return: A sorted list of bone indices that should be exported.
""" """
if armature_object is None or armature_object.type != 'ARMATURE': if armature_object is None or armature_object.type != 'ARMATURE':
raise ValueError('An armature object must be supplied') raise ValueError('An armature object must be supplied')
armature_data = cast(Armature, armature_object.data) armature_data = typing_cast(Armature, armature_object.data)
bones = armature_data.bones bones = armature_data.bones
bone_names = [x.name for x in bones] bone_names = [x.name for x in bones]
@@ -265,26 +262,6 @@ def create_psx_bones_from_blender_bones(
return psx_bones return psx_bones
# TODO: we need two different ones for the PSK and PSA.
# TODO: Figure out in what "space" the root bone is in for PSA animations.
# Maybe make a set of space-switching functions to make this easier to follow and figure out.
def get_export_space_matrix(export_space: str, armature_object: Optional[Object] = None) -> Matrix:
match export_space:
case 'WORLD':
return Matrix.Identity(4)
case 'ARMATURE':
# We do not care about the scale when dealing with export spaces, only the translation and rotation.
if armature_object is not None:
translation, rotation, _ = armature_object.matrix_world.decompose()
return (rotation.to_matrix().to_4x4() @ Matrix.Translation(translation)).inverted()
else:
return Matrix.Identity(4)
case 'ROOT':
pass
case _:
assert False, f'Invalid export space: {export_space}'
class PsxBoneCreateResult: class PsxBoneCreateResult:
def __init__(self, def __init__(self,
bones: List[Tuple[PsxBone, Optional[Object]]], # List of tuples of (psx_bone, armature_object) bones: List[Tuple[PsxBone, Optional[Object]]], # List of tuples of (psx_bone, armature_object)
@@ -324,9 +301,6 @@ def create_psx_bones(
total_bone_count = sum(len(armature_object.data.bones) for armature_object in armature_objects) total_bone_count = sum(len(armature_object.data.bones) for armature_object in armature_objects)
# Store the index of the root bone for each armature object.
# We will need this later to correctly assign vertex weights.
armature_object_root_bone_indices = dict()
# Store the bone names to be exported for each armature object. # Store the bone names to be exported for each armature object.
armature_object_bone_names: Dict[Object, List[str]] = dict() armature_object_bone_names: Dict[Object, List[str]] = dict()
@@ -335,6 +309,10 @@ def create_psx_bones(
bone_names = get_export_bone_names(armature_object, bone_filter_mode, armature_bone_collection_indices) bone_names = get_export_bone_names(armature_object, bone_filter_mode, armature_bone_collection_indices)
armature_object_bone_names[armature_object] = bone_names armature_object_bone_names[armature_object] = bone_names
# Store the index of the root bone for each armature object.
# We will need this later to correctly assign vertex weights.
armature_object_root_bone_indices: Dict[Optional[Object], int] = dict()
if len(armature_objects) == 0 or total_bone_count == 0: if len(armature_objects) == 0 or total_bone_count == 0:
# If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the # If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the
# requirement that a PSK file must have at least one bone. # requirement that a PSK file must have at least one bone.
@@ -366,7 +344,7 @@ def create_psx_bones(
for armature_object in armature_objects: for armature_object in armature_objects:
bone_names = armature_object_bone_names[armature_object] bone_names = armature_object_bone_names[armature_object]
armature_data = cast(Armature, armature_object.data) armature_data = typing_cast(Armature, armature_object.data)
armature_bones = [armature_data.bones[bone_name] for bone_name in bone_names] armature_bones = [armature_data.bones[bone_name] for bone_name in bone_names]
armature_psx_bones = create_psx_bones_from_blender_bones( armature_psx_bones = create_psx_bones_from_blender_bones(
@@ -379,7 +357,8 @@ def create_psx_bones(
root_bone=root_bone, root_bone=root_bone,
) )
# If we are appending these bones to an existing list of bones, we need to adjust the parent indices. # If we are appending these bones to an existing list of bones, we need to adjust the parent indices for
# all the non-root bones.
if len(bones) > 0: if len(bones) > 0:
parent_index_offset = len(bones) parent_index_offset = len(bones)
for bone in armature_psx_bones[1:]: for bone in armature_psx_bones[1:]:
@@ -393,7 +372,13 @@ def create_psx_bones(
bone_name_counts = Counter(bone[0].name.decode('windows-1252').upper() for bone in bones) bone_name_counts = Counter(bone[0].name.decode('windows-1252').upper() for bone in bones)
for bone_name, count in bone_name_counts.items(): for bone_name, count in bone_name_counts.items():
if count > 1: if count > 1:
raise RuntimeError(f'Found {count} bones with the name "{bone_name}". Bone names must be unique when compared case-insensitively.') error_message = f'Found {count} bones with the name "{bone_name}". '
f'Bone names must be unique when compared case-insensitively.'
if len(armature_objects) > 1 and bone_name == root_bone_name.upper():
error_message += f' This is the name of the automatically generated root bone. Consider changing this '
f''
raise RuntimeError(error_message)
return PsxBoneCreateResult( return PsxBoneCreateResult(
bones=bones, bones=bones,
@@ -416,6 +401,8 @@ def get_vector_from_axis_identifier(axis_identifier: str) -> Vector:
return Vector((0.0, -1.0, 0.0)) return Vector((0.0, -1.0, 0.0))
case '-Z': case '-Z':
return Vector((0.0, 0.0, -1.0)) return Vector((0.0, 0.0, -1.0))
case _:
assert False, f'Invalid axis identifier: {axis_identifier}'
def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z') -> Matrix: def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z') -> Matrix:

View File

@@ -1,5 +1,5 @@
import bpy import bpy
from bpy.props import StringProperty, IntProperty, BoolProperty, FloatProperty from bpy.props import CollectionProperty, EnumProperty, StringProperty, IntProperty, BoolProperty, FloatProperty
from bpy.types import PropertyGroup, UIList, UILayout, Context, AnyType, Panel from bpy.types import PropertyGroup, UIList, UILayout, Context, AnyType, Panel
@@ -56,6 +56,90 @@ class PSX_PT_action(Panel):
flow.prop(action.psa_export, 'fps') flow.prop(action.psa_export, 'fps')
bone_filter_mode_items = (
('ALL', 'All', 'All bones will be exported'),
('BONE_COLLECTIONS', 'Bone Collections', 'Only bones belonging to the selected bone collections and their ancestors will be exported')
)
axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z')
forward_items = (
('X', 'X Forward', ''),
('Y', 'Y Forward', ''),
('Z', 'Z Forward', ''),
('-X', '-X Forward', ''),
('-Y', '-Y Forward', ''),
('-Z', '-Z Forward', ''),
)
up_items = (
('X', 'X Up', ''),
('Y', 'Y Up', ''),
('Z', 'Z Up', ''),
('-X', '-X Up', ''),
('-Y', '-Y Up', ''),
('-Z', '-Z Up', ''),
)
def forward_axis_update(self, _context):
if self.forward_axis == self.up_axis:
# Automatically set the up axis to the next available axis
self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z')
def up_axis_update(self, _context):
if self.up_axis == self.forward_axis:
# Automatically set the forward axis to the next available axis
self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X')
class ForwardUpAxisMixin:
forward_axis: EnumProperty(
name='Forward',
items=forward_items,
default='X',
update=forward_axis_update
)
up_axis: EnumProperty(
name='Up',
items=up_items,
default='Z',
update=up_axis_update
)
export_space_items = [
('WORLD', 'World', 'Export in world space'),
('ARMATURE', 'Armature', 'Export the local space of the armature object'),
('ROOT', 'Root', 'Export in the space of the root bone')
]
class ExportSpaceMixin:
export_space: EnumProperty(
name='Export Space',
items=export_space_items,
default='WORLD'
)
class PsxBoneExportMixin:
bone_filter_mode: EnumProperty(
name='Bone Filter',
options=set(),
description='',
items=bone_filter_mode_items,
)
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
bone_collection_list_index: IntProperty(default=0, name='', description='')
root_bone_name: StringProperty(
name='Root Bone Name',
description='The name of the generated root bone when exporting multiple armatures',
default='ROOT',
)
classes = ( classes = (
PSX_PG_action_export, PSX_PG_action_export,
PSX_PG_bone_collection_list_item, PSX_PG_bone_collection_list_item,

View File

@@ -1,6 +1,6 @@
from bpy.types import UILayout from bpy.types import UILayout
from .data import bone_filter_mode_items from .types import bone_filter_mode_items
def is_bone_filter_mode_item_available(pg, identifier): def is_bone_filter_mode_item_available(pg, identifier):