From 15614c6d3738665332c862e99267718b7c06c02a Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sun, 30 Mar 2025 23:36:12 -0700 Subject: [PATCH] Loads of cleanup --- io_scene_psk_psa/__init__.py | 38 ++++++---- io_scene_psk_psa/blender_manifest.toml | 2 +- io_scene_psk_psa/psa/builder.py | 3 - io_scene_psk_psa/psa/data.py | 11 ++- io_scene_psk_psa/psa/export/operators.py | 38 +++++++--- io_scene_psk_psa/psa/export/properties.py | 62 +++++++--------- io_scene_psk_psa/psa/export/ui.py | 4 +- io_scene_psk_psa/psa/import_/properties.py | 53 +++++++------ io_scene_psk_psa/psa/importer.py | 31 ++++---- io_scene_psk_psa/psa/reader.py | 1 + io_scene_psk_psa/psk/builder.py | 44 +++++------ io_scene_psk_psa/psk/export/operators.py | 19 +++-- io_scene_psk_psa/psk/export/properties.py | 34 +++------ io_scene_psk_psa/psk/import_/operators.py | 6 +- io_scene_psk_psa/psk/importer.py | 10 +-- io_scene_psk_psa/psk/properties.py | 22 +++--- io_scene_psk_psa/psk/reader.py | 1 - io_scene_psk_psa/psk/writer.py | 2 +- io_scene_psk_psa/shared/data.py | 68 ----------------- io_scene_psk_psa/shared/dfs.py | 16 ++-- io_scene_psk_psa/shared/helpers.py | 59 ++++++--------- io_scene_psk_psa/shared/types.py | 86 +++++++++++++++++++++- io_scene_psk_psa/shared/ui.py | 2 +- 23 files changed, 317 insertions(+), 295 deletions(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 124b498..6953ffb 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -36,28 +36,40 @@ if 'bpy' in locals(): else: 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 .psk import data as psk_data, builder as psk_builder, writer as psk_writer, \ - importer as psk_importer, properties as psk_properties + from .psk import ( + 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.export import properties as psk_export_properties, ui as psk_export_ui, \ - operators as psk_export_operators + from .psk.export import ( + 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 .psa import config as psa_config, data as psa_data, 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 ( + config as psa_config, + data as psa_data, + 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 ui as psa_import_ui, properties as psa_import_properties import bpy 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 +\ psk_properties.classes +\ psk_ui.classes +\ diff --git a/io_scene_psk_psa/blender_manifest.toml b/io_scene_psk_psa/blender_manifest.toml index 5a48d66..0c2353b 100644 --- a/io_scene_psk_psa/blender_manifest.toml +++ b/io_scene_psk_psa/blender_manifest.toml @@ -24,4 +24,4 @@ paths_exclude_pattern = [ ] [permissions] -files = "Import/export PSK and PSA files from/to disk" +files = "Read and write PSK and PSA files from and to disk" diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index e52da43..41864a4 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -124,9 +124,6 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa: if len(psa.bones) == 0: 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. for export_sequence in options.sequences: export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}' diff --git a/io_scene_psk_psa/psa/data.py b/io_scene_psk_psa/psa/data.py index 0c1cd0b..64eea4e 100644 --- a/io_scene_psk_psa/psa/data.py +++ b/io_scene_psk_psa/psa/data.py @@ -1,16 +1,15 @@ -import typing 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 ..shared.data import PsxBone, Quaternion, Vector3 class Psa: - ''' + """ 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): _fields_ = [ @@ -50,5 +49,5 @@ class Psa: def __init__(self): self.bones: List[PsxBone] = [] - self.sequences: typing.OrderedDict[str, Psa.Sequence] = OrderedDict() + self.sequences: OrderedDictType[str, Psa.Sequence] = OrderedDict() self.keys: List[Psa.Key] = [] diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index aa0b61a..aeafe47 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -6,8 +6,12 @@ from bpy.props import StringProperty from bpy.types import Context, Action, Object, AnimData, TimelineMarker, Operator from bpy_extras.io_utils import ExportHelper -from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences, \ - get_sequences_from_name_and_frame_range +from .properties import ( + 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 ..writer import write_psa 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): if pose_marker.name.strip() == '' or pose_marker.name.startswith('#'): 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.action = action 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}' -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: case 'ACTION_METADATA': # 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 -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. sequence_frame_ranges = dict() 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) -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 sequence_name = pose_marker.name 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_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') 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', - rows=rows) + bones_panel.template_list( + '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.label(text='Advanced') @@ -559,7 +579,7 @@ class PSA_OT_export_actions_select_all(Operator): case 'ACTIVE_ACTION': return pg.active_action_list case _: - return None + assert False, f'Invalid sequence source: {pg.sequence_source}' @classmethod def poll(cls, context): diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index b454240..5d907dd 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -2,22 +2,23 @@ import re import sys from fnmatch import fnmatch from typing import List, Optional - -from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \ - StringProperty +from bpy.props import ( + BoolProperty, + PointerProperty, + EnumProperty, + FloatProperty, + CollectionProperty, + IntProperty, + StringProperty, +) from bpy.types import PropertyGroup, Object, Action, AnimData, Context - -from ...shared.data import bone_filter_mode_items, ForwardUpAxisMixin, ExportSpaceMixin -from ...shared.types import PSX_PG_bone_collection_list_item +from ...shared.types import ForwardUpAxisMixin, ExportSpaceMixin, PsxBoneExportMixin def psa_export_property_group_animation_data_override_poll(_context, obj): return obj.animation_data is not None -empty_set = set() - - class PSA_PG_export_action_list_item(PropertyGroup): action: PointerProperty(type=Action) name: StringProperty() @@ -102,10 +103,10 @@ def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context): self.nla_track = '' -class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin): +class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin, PsxBoneExportMixin): should_override_animation_data: BoolProperty( name='Override Animation Data', - options=empty_set, + options=set(), default=False, description='Use the animation data from a different object instead of the selected object', update=animation_data_override_update_cb, @@ -117,7 +118,7 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin): ) sequence_source: EnumProperty( name='Source', - options=empty_set, + options=set(), description='', items=( ('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0), @@ -128,7 +129,7 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin): ) nla_track: StringProperty( name='NLA Track', - options=empty_set, + options=set(), description='', search=nla_track_search_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) fps_source: EnumProperty( name='FPS Source', - options=empty_set, + options=set(), description='', items=( ('SCENE', 'Scene', '', 'SCENE_DATA', 0), @@ -144,11 +145,11 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin): ('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) compression_ratio_source: EnumProperty( name='Compression Ratio Source', - options=empty_set, + options=set(), description='', 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), @@ -164,16 +165,8 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin): nla_strip_list_index: IntProperty(default=0) active_action_list: CollectionProperty(type=PSA_PG_export_active_action_list_item) active_action_list_index: IntProperty(default=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, name='', description='') - sequence_name_prefix: StringProperty(name='Prefix', options=empty_set) - sequence_name_suffix: StringProperty(name='Suffix', options=empty_set) + sequence_name_prefix: StringProperty(name='Prefix', options=set()) + sequence_name_suffix: StringProperty(name='Suffix', options=set()) sequence_filter_name: StringProperty( default='', name='Filter by Name', @@ -182,21 +175,21 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin): sequence_use_filter_invert: BoolProperty( default=False, name='Invert', - options=empty_set, + options=set(), description='Invert filtering (show hidden items, and vice versa)') sequence_filter_asset: BoolProperty( default=False, name='Show assets', - options=empty_set, + options=set(), description='Show actions that belong to an asset library') sequence_filter_pose_marker: BoolProperty( default=True, name='Show pose markers', - options=empty_set) - sequence_use_filter_sort_reverse: BoolProperty(default=True, options=empty_set) + options=set()) + sequence_use_filter_sort_reverse: BoolProperty(default=True, options=set()) sequence_filter_reversed: BoolProperty( default=True, - options=empty_set, + options=set(), name='Show Reversed', description='Show reversed sequences' ) @@ -209,7 +202,7 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin): ) sampling_mode: EnumProperty( name='Sampling Mode', - options=empty_set, + options=set(), description='The method by which frames are sampled', items=( ('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' ) - 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]: diff --git a/io_scene_psk_psa/psa/export/ui.py b/io_scene_psk_psa/psa/export/ui.py index ab7544c..b7d8fe9 100644 --- a/io_scene_psk_psa/psa/export/ui.py +++ b/io_scene_psk_psa/psa/export/ui.py @@ -1,4 +1,4 @@ -import typing +from typing import cast as typing_cast from bpy.types import UIList @@ -14,7 +14,7 @@ class PSA_UL_export_sequences(UIList): self.use_filter_show = True 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 layout.prop(item, 'is_selected', icon_only=True, text=item.name) diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py index 455553f..83b1087 100644 --- a/io_scene_psk_psa/psa/import_/properties.py +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -2,20 +2,25 @@ import re from fnmatch import fnmatch from typing import List -from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty, PointerProperty, EnumProperty, \ - FloatProperty +from bpy.props import ( + BoolProperty, + CollectionProperty, + EnumProperty, + FloatProperty, + IntProperty, + PointerProperty, + StringProperty, +) from bpy.types import PropertyGroup, Text -empty_set = set() - class PSA_PG_import_action_list_item(PropertyGroup): - action_name: StringProperty(options=empty_set) - is_selected: BoolProperty(default=True, options=empty_set) + action_name: StringProperty(options=set()) + is_selected: BoolProperty(default=True, options=set()) class PSA_PG_bone(PropertyGroup): - bone_name: StringProperty(options=empty_set) + bone_name: StringProperty(options=set()) class PSA_PG_data(PropertyGroup): @@ -43,31 +48,31 @@ class PsaImportMixin: should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is ' 'saved even it has no users', - options=empty_set) + options=set()) should_use_config_file: BoolProperty(default=True, name='Use Config File', description='Use the .config file that is sometimes generated when the PSA ' 'file is exported from UEViewer. This file contains ' 'options that can be used to filter out certain bones tracks ' 'from the imported actions', - options=empty_set) + options=set()) should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track', - options=empty_set) - should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=empty_set) - action_name_prefix: StringProperty(default='', name='Prefix', options=empty_set) - should_overwrite: BoolProperty(default=False, name='Overwrite', options=empty_set, + options=set()) + should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=set()) + action_name_prefix: StringProperty(default='', name='Prefix', options=set()) + should_overwrite: BoolProperty(default=False, name='Overwrite', options=set(), description='If an action with a matching name already exists, the existing action ' 'will have it\'s data overwritten instead of a new action being created') - should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=empty_set) - should_write_metadata: BoolProperty(default=True, name='Metadata', options=empty_set, + should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=set()) + should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(), description='Additional data will be written to the custom properties of the ' 'Action (e.g., frame rate)') sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) - sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected', + sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected', 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', - description='Filter using regular expressions', options=empty_set) + description='Filter using regular expressions', options=set()) should_convert_to_samples: BoolProperty( default=False, @@ -77,7 +82,7 @@ class PsaImportMixin: ) bone_mapping_mode: EnumProperty( name='Bone Mapping', - options=empty_set, + options=set(), description='The method by which bones from the incoming PSA file are mapped to the armature', items=bone_mapping_items, default='CASE_INSENSITIVE' @@ -87,7 +92,7 @@ class PsaImportMixin: default=30.0, name='Custom FPS', description='The frame rate to which the imported sequences will be resampled to', - options=empty_set, + options=set(), min=1.0, soft_min=1.0, soft_max=60.0, @@ -98,7 +103,7 @@ class PsaImportMixin: default=1.0, name='Custom Compression Ratio', description='The compression ratio to apply to the imported sequences', - options=empty_set, + options=set(), min=0.0, soft_min=0.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_index: IntProperty(name='', default=0) sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) - sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected', + sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected', 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', - description='Filter using regular expressions', options=empty_set) + description='Filter using regular expressions', options=set()) select_text: PointerProperty(type=Text) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 6bb6ec3..3221855 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -1,9 +1,9 @@ import typing -from typing import List, Optional +from typing import Iterable, List, Optional, cast as typing_cast import bpy 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 .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION @@ -56,7 +56,7 @@ class ImportBone(object): 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. key_rotation = Quaternion(key_data[0: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 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. """ 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 None -def _get_sample_frame_times(source_frame_count: int, frame_step: float) -> typing.Iterable[float]: - # TODO: for correctness, we should also emit the target frame time as well (because the last frame can be a - # fractional frame). - time = 0.0 - while time < source_frame_count - 1: - yield time - time += frame_step - 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. """ + + 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 + # fractional frame). + assert frame_step > 0.0, 'Frame step must be greater than 0' + time = 0.0 + while time < source_frame_count - 1: + yield time + time += frame_step + yield source_frame_count - 1 + if frame_step == 1.0: # No resampling is necessary. 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: result = PsaImportResult() 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. psa_to_armature_bone_indices = {} diff --git a/io_scene_psk_psa/psa/reader.py b/io_scene_psk_psa/psa/reader.py index 1869707..71cf0b8 100644 --- a/io_scene_psk_psa/psa/reader.py +++ b/io_scene_psk_psa/psa/reader.py @@ -53,6 +53,7 @@ class PsaReader(object): def read_sequence_data_matrix(self, sequence_name: str) -> np.ndarray: """ Reads and returns the data matrix for the given 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. """ diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 09a2288..1dc8d78 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -1,18 +1,18 @@ -import typing -from typing import Dict, Generator, Set, Iterable, Optional, cast, Tuple, List - import bmesh import bpy 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 typing import Dict, Generator, Iterable, List, Optional, Set, Tuple, cast as typing_cast from .data import Psk from .properties import triangle_type_and_bit_flags_to_poly_flags from ..shared.data import Vector3 -from ..shared.dfs import dfs_collection_objects, dfs_view_layer_objects, DfsObject -from ..shared.helpers import convert_string_to_cp1252_bytes, \ - create_psx_bones, get_coordinate_system_transform +from ..shared.dfs import DfsObject, dfs_collection_objects, dfs_view_layer_objects +from ..shared.helpers import ( + convert_string_to_cp1252_bytes, + create_psx_bones, + get_coordinate_system_transform, +) class PskInputObjects(object): @@ -108,11 +108,11 @@ class PskBuildResult(object): 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: 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() # 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() @@ -121,10 +121,10 @@ def _get_mesh_export_space_matrix(armature_object: Object, export_space: str) -> case 'WORLD': return Matrix.Identity(4) case 'ARMATURE': - return get_object_space_space_matrix(armature_object).inverted() + return get_object_space_matrix(armature_object).inverted() case 'ROOT': - armature_data = cast(armature_object.data, Armature) - armature_space_matrix = get_object_space_space_matrix(armature_object) @ armature_data.bones[0].matrix_local + armature_data = typing_cast(Armature, armature_object.data) + armature_space_matrix = get_object_space_matrix(armature_object) @ armature_data.bones[0].matrix_local return armature_space_matrix.inverted() case _: 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) - # TODO: we need to store this per armature - armature_mesh_export_space_matrices: Dict[Optional[Object], Matrix] = dict() - + # Calculate the export spaces for the armature objects. + # 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: armature_mesh_export_space_matrices[armature_object] = _get_mesh_export_space_matrix(armature_object, options.export_space) 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. # 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] 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) @@ -272,7 +272,8 @@ 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 # odd. If it is, we need to invert the normals of the mesh by swapping the order of the vertices in each # face. - should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1 + if not should_flip_normals: + should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1 # Copy the vertex groups for vertex_group in obj.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 # Vertices + vertex_offset = len(psk.points) for vertex in mesh_data.vertices: point = Vector3() v = point_transform_matrix @ vertex.co @@ -298,8 +300,6 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil # Wedges mesh_data.calc_loop_triangles() - vertex_offset = len(psk.points) - # Build a list of non-unique wedges. wedges = [] for loop_index, loop in enumerate(mesh_data.loops): @@ -346,7 +346,7 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil # Weights 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] # 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. diff --git a/io_scene_psk_psa/psk/export/operators.py b/io_scene_psk_psa/psk/export/operators.py index dbbd9e3..e160c49 100644 --- a/io_scene_psk_psa/psk/export/operators.py +++ b/io_scene_psk_psa/psk/export/operators.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Iterable, List, Optional, cast +from typing import Iterable, List, Optional, cast as typing_cast import bpy 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 .properties import PskExportMixin -from ..builder import PskBuildOptions, build_psk, get_psk_input_objects_for_context, \ - get_psk_input_objects_for_collection, get_materials_for_mesh_objects +from ..builder import ( + 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 ...shared.helpers import populate_bone_collection_list 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': return None - space_data = cast(SpaceProperties, context.space_data) + space_data = typing_cast(SpaceProperties, context.space_data) if space_data.use_pin_id: - return cast(Collection, space_data.pin_id) + return typing_cast(Collection, space_data.pin_id) else: return context.collection @@ -226,13 +231,11 @@ class PSK_OT_material_list_name_move_down(Operator): return {'FINISHED'} -empty_set = set() - - 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 end of the list in the order they are found. + @param materials: A list of materials to sort @param material_names: A list of material names to sort by @return: A sorted list of materials diff --git a/io_scene_psk_psa/psk/export/properties.py b/io_scene_psk_psa/psk/export/properties.py index 588253d..3128e17 100644 --- a/io_scene_psk_psa/psk/export/properties.py +++ b/io_scene_psk_psa/psk/export/properties.py @@ -1,11 +1,14 @@ -from bpy.props import EnumProperty, CollectionProperty, IntProperty, PointerProperty, FloatProperty, StringProperty, \ - BoolProperty -from bpy.types import PropertyGroup, Material - -from ...shared.data import bone_filter_mode_items, ExportSpaceMixin, ForwardUpAxisMixin -from ...shared.types import PSX_PG_bone_collection_list_item - -empty_set = set() +from bpy.props import ( + BoolProperty, + CollectionProperty, + EnumProperty, + FloatProperty, + IntProperty, + PointerProperty, + StringProperty, +) +from bpy.types import Material, PropertyGroup +from ...shared.types import ExportSpaceMixin, ForwardUpAxisMixin, PsxBoneExportMixin object_eval_state_items = ( ('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'), @@ -26,7 +29,7 @@ class PSK_PG_material_name_list_item(PropertyGroup): index: IntProperty() -class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin): +class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin, PsxBoneExportMixin): object_eval_state: EnumProperty( items=object_eval_state_items, name='Object Evaluation State', @@ -44,14 +47,6 @@ class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin): min=0.0001, 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( name='Material Order', 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_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): diff --git a/io_scene_psk_psa/psk/import_/operators.py b/io_scene_psk_psa/psk/import_/operators.py index 9cc298f..1afef08 100644 --- a/io_scene_psk_psa/psk/import_/operators.py +++ b/io_scene_psk_psa/psk/import_/operators.py @@ -1,16 +1,14 @@ import os from pathlib import Path -from bpy.props import StringProperty, CollectionProperty -from bpy.types import Operator, FileHandler, Context, OperatorFileListElement, UILayout +from bpy.props import CollectionProperty, StringProperty +from bpy.types import Context, FileHandler, Operator, OperatorFileListElement, UILayout from bpy_extras.io_utils import ImportHelper from ..importer import PskImportOptions, import_psk from ..properties import PskImportMixin from ..reader import read_psk -empty_set = set() - def get_psk_import_options_from_properties(property_group: PskImportMixin): options = PskImportOptions() options.should_import_mesh = property_group.should_import_mesh diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index f8939bb..a902aa3 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -1,15 +1,15 @@ -from typing import Optional, List - import bmesh import bpy 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 .properties import poly_flags_to_triangle_type_and_bit_flags 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: diff --git a/io_scene_psk_psa/psk/properties.py b/io_scene_psk_psa/psk/properties.py index 6931358..6e1dcf6 100644 --- a/io_scene_psk_psa/psk/properties.py +++ b/io_scene_psk_psa/psk/properties.py @@ -1,6 +1,6 @@ import sys -from bpy.props import EnumProperty, BoolProperty, FloatProperty, StringProperty +from bpy.props import BoolProperty, EnumProperty, FloatProperty, StringProperty from bpy.types import PropertyGroup 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} return triangle_type, triangle_bit_flags -empty_set = set() - def should_import_mesh_get(self): return self.import_components in {'ALL', 'MESH'} @@ -57,13 +55,13 @@ def should_import_skleton_get(self): class PskImportMixin: should_import_vertex_colors: BoolProperty( default=True, - options=empty_set, + options=set(), name='Import Vertex Colors', description='Import vertex colors, if available' ) vertex_color_space: EnumProperty( name='Vertex Color Space', - options=empty_set, + options=set(), description='The source vertex color space', default='SRGBA', items=( @@ -74,18 +72,18 @@ class PskImportMixin: should_import_vertex_normals: BoolProperty( default=True, name='Import Vertex Normals', - options=empty_set, + options=set(), description='Import vertex normals, if available' ) should_import_extra_uvs: BoolProperty( default=True, name='Import Extra UVs', - options=empty_set, + options=set(), description='Import extra UV maps, if available' ) import_components: EnumProperty( name='Import Components', - options=empty_set, + options=set(), description='Determine which components to import', items=( ('ALL', 'Mesh & Skeleton', 'Import mesh and skeleton'), @@ -101,7 +99,7 @@ class PskImportMixin: should_import_materials: BoolProperty( default=True, name='Import Materials', - options=empty_set, + options=set(), ) should_import_skeleton: BoolProperty( name='Import Skeleton', @@ -113,14 +111,14 @@ class PskImportMixin: step=100, soft_min=1.0, name='Bone Length', - options=empty_set, + options=set(), subtype='DISTANCE', description='Length of the bones' ) should_import_shape_keys: BoolProperty( default=True, name='Import Shape Keys', - options=empty_set, + options=set(), description='Import shape keys, if available' ) scale: FloatProperty( @@ -131,7 +129,7 @@ class PskImportMixin: bdk_repository_id: StringProperty( name='BDK Repository ID', default='', - options=empty_set, + options=set(), description='The ID of the BDK repository to use for loading materials' ) diff --git a/io_scene_psk_psa/psk/reader.py b/io_scene_psk_psa/psk/reader.py index 64c1fbd..e67f76c 100644 --- a/io_scene_psk_psa/psk/reader.py +++ b/io_scene_psk_psa/psk/reader.py @@ -4,7 +4,6 @@ import re import warnings from pathlib import Path from typing import List - from ..shared.data import Section from .data import Color, Psk, PsxBone, Vector2, Vector3 diff --git a/io_scene_psk_psa/psk/writer.py b/io_scene_psk_psa/psk/writer.py index c49011e..9a8a1ab 100644 --- a/io_scene_psk_psa/psk/writer.py +++ b/io_scene_psk_psa/psk/writer.py @@ -3,7 +3,7 @@ from ctypes import Structure, sizeof from typing import Type from .data import Psk -from ..shared.data import Section, Vector3, PsxBone +from ..shared.data import PsxBone, Section, Vector3 MAX_WEDGE_COUNT = 65536 MAX_POINT_COUNT = 4294967296 diff --git a/io_scene_psk_psa/shared/data.py b/io_scene_psk_psa/shared/data.py index 7e4b924..401da7a 100644 --- a/io_scene_psk_psa/shared/data.py +++ b/io_scene_psk_psa/shared/data.py @@ -1,7 +1,5 @@ from ctypes import Structure, c_char, c_int32, c_float, c_ubyte from typing import Tuple - -from bpy.props import EnumProperty from mathutils import Quaternion as BpyQuaternion @@ -118,69 +116,3 @@ class Section(Structure): def __init__(self, *args, **kw): super().__init__(*args, **kw) 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' - ) diff --git a/io_scene_psk_psa/shared/dfs.py b/io_scene_psk_psa/shared/dfs.py index c1a05fd..77bbff2 100644 --- a/io_scene_psk_psa/shared/dfs.py +++ b/io_scene_psk_psa/shared/dfs.py @@ -12,9 +12,9 @@ from mathutils import Matrix class DfsObject: - ''' + """ Represents an object in a depth-first search. - ''' + """ def __init__(self, obj: Object, instance_objects: List[Object], matrix_world: Matrix): self.obj = obj self.instance_objects = instance_objects @@ -22,10 +22,11 @@ class DfsObject: @property def is_visible(self) -> bool: - ''' + """ Check if the object is visible. + @return: True if the object is visible, False otherwise. - ''' + """ if self.instance_objects: return self.instance_objects[-1].visible_get() return self.obj.visible_get() @@ -41,11 +42,11 @@ class DfsObject: return self.obj.select_get() - 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 collection. + @param obj: The object to start the search from. @param collection: The collection to search in. @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 collection. + @param collection: The collection to search in. @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]: ''' Depth-first search of objects in a collection, including recursing into instances. + @param collection: The collection to search in. @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. This is a recursive function. + @param collection: The collection to search in. @param instance_objects: The running hierarchy of instance objects. @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]: ''' Depth-first iterator over all objects in a view layer, including recursing into instances. + @param view_layer: The view layer to inspect. @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: ''' Check if a DFS object is visible. + @param obj: The object. @param instance_objects: The instance objects. @return: True if the object is visible, False otherwise. diff --git a/io_scene_psk_psa/shared/helpers.py b/io_scene_psk_psa/shared/helpers.py index f6bd219..60c5df5 100644 --- a/io_scene_psk_psa/shared/helpers.py +++ b/io_scene_psk_psa/shared/helpers.py @@ -1,12 +1,9 @@ -from collections import Counter -from typing import List, Iterable, cast, Optional, Dict, Tuple - 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.types import AnimData, Object -from bpy.types import Armature +from bpy.types import Armature, AnimData, Object from mathutils import Matrix, Vector - from .data import Vector3, Quaternion from ..shared.data import PsxBone @@ -57,7 +54,7 @@ def populate_bone_collection_list(armature_objects: Iterable[Object], bone_colle bone_collection_list.clear() for armature_object in armature_objects: - armature = cast(Armature, armature_object.data) + armature = typing_cast(Armature, armature_object.data) if armature is None: 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. - :param armature_object: Blender object with type 'ARMATURE' - :param bone_filter_mode: One of ['ALL', 'BONE_COLLECTIONS'] + :param armature_object: Blender object with type `'ARMATURE'` + :param bone_filter_mode: One of `['ALL', 'BONE_COLLECTIONS']` :param bone_collection_indices: A list of bone collection indices to export. :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') - armature_data = cast(Armature, armature_object.data) + armature_data = typing_cast(Armature, armature_object.data) bones = armature_data.bones bone_names = [x.name for x in bones] @@ -265,26 +262,6 @@ def create_psx_bones_from_blender_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: def __init__(self, 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) - # 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. 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) 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 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. @@ -366,7 +344,7 @@ def create_psx_bones( for armature_object in armature_objects: 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_psx_bones = create_psx_bones_from_blender_bones( @@ -379,7 +357,8 @@ def create_psx_bones( 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: parent_index_offset = len(bones) 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) for bone_name, count in bone_name_counts.items(): 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( bones=bones, @@ -416,6 +401,8 @@ def get_vector_from_axis_identifier(axis_identifier: str) -> Vector: return Vector((0.0, -1.0, 0.0)) case '-Z': 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: diff --git a/io_scene_psk_psa/shared/types.py b/io_scene_psk_psa/shared/types.py index 77a5fa9..179a157 100644 --- a/io_scene_psk_psa/shared/types.py +++ b/io_scene_psk_psa/shared/types.py @@ -1,5 +1,5 @@ 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 @@ -56,6 +56,90 @@ class PSX_PT_action(Panel): 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 = ( PSX_PG_action_export, PSX_PG_bone_collection_list_item, diff --git a/io_scene_psk_psa/shared/ui.py b/io_scene_psk_psa/shared/ui.py index ff8ea87..88f10be 100644 --- a/io_scene_psk_psa/shared/ui.py +++ b/io_scene_psk_psa/shared/ui.py @@ -1,6 +1,6 @@ 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):