From 94658ac221f917017a5c714a2ad04eb526a329ca Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 2 Oct 2023 22:59:58 -0700 Subject: [PATCH 01/48] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 34 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 +++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..f2d30b6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Versions** +Blender: (example: 3.6.2) +io_scene_psk_psa: (example: 5.0.0) + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Files** +Please post attachments of the PSK and/or PSA files that you are using to encounter the bug, if any. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From 122e765bcad241b0b333d02f84c4e08f5555169e Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 19 Oct 2023 13:14:57 -0700 Subject: [PATCH 02/48] PSA sequences are now selected by default There were multiple bug reports from users who were getting mixed signals from the addon, believing that the bone name warnings were the cause of the sequences not being imported. The actual issue was that users didn't know they needed to manually select the sequences within the PSA. This should fix the poor UX around this, as just having it selected by default is more sensible for a wider audience. --- io_scene_psk_psa/__init__.py | 2 +- io_scene_psk_psa/psa/export/properties.py | 2 +- io_scene_psk_psa/psa/import_/properties.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 9f240c8..3e4774d 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "PSK/PSA Importer/Exporter", "author": "Colin Basnett, Yurii Ti", - "version": (5, 0, 5), + "version": (5, 0, 6), "blender": (3, 4, 0), "description": "PSK/PSA Import/Export (.psk/.psa)", "warning": "", diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index 1f75f74..a215501 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -19,7 +19,7 @@ empty_set = set() class PSA_PG_export_action_list_item(PropertyGroup): action: PointerProperty(type=Action) name: StringProperty() - is_selected: BoolProperty(default=False) + is_selected: BoolProperty(default=True) frame_start: IntProperty(options={'HIDDEN'}) frame_end: IntProperty(options={'HIDDEN'}) is_pose_marker: BoolProperty(options={'HIDDEN'}) diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py index b90ade6..668e17b 100644 --- a/io_scene_psk_psa/psa/import_/properties.py +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -10,7 +10,7 @@ empty_set = set() class PSA_PG_import_action_list_item(PropertyGroup): action_name: StringProperty(options=empty_set) - is_selected: BoolProperty(default=False, options=empty_set) + is_selected: BoolProperty(default=True, options=empty_set) class PSA_PG_bone(PropertyGroup): From 2a5a4b2a3df105f516e1229140fc7cc1b431d612 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 19 Oct 2023 13:24:06 -0700 Subject: [PATCH 03/48] Added sanity check for populating the bone group list --- io_scene_psk_psa/helpers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index fe683e0..9f70ee8 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -59,6 +59,9 @@ def populate_bone_group_list(armature_object: Object, bone_group_list: bpy.props has_selected_groups = any([g.is_selected for g in bone_group_list]) unassigned_group_is_selected, selected_assigned_group_names = True, [] + if armature_object is None: + return + if has_selected_groups: # Preserve group selections before clearing the list. # We handle selections for the unassigned group separately to cover the edge case From 403e9a5cca36e3596fa8f0a61a958ca2ac3742f7 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 19 Oct 2023 16:06:03 -0700 Subject: [PATCH 04/48] Update README.md Clarified the need for manually assigning actions after importing PSAs. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2e6de7c..2452624 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ This Blender add-on allows you to import and export meshes and animations to and 3. Select the PSA file you want to import. 4. Select the sequences that you want to import and click `Import`. +> Note that in order to see the imported actions applied to your armature, you must use the [Dope Sheet](https://docs.blender.org/manual/en/latest/editors/dope_sheet/introduction.html) or [Nonlinear Animation](https://docs.blender.org/manual/en/latest/editors/nla/introduction.html) editors. + # FAQ ## Why are the mesh normals not accurate when importing a PSK extracted from [UE Viewer](https://www.gildor.org/en/projects/umodel)? If preserving the mesh normals of models is important for your workflow, it is *not recommended* to export PSK files from UE Viewer. This is because UE Viewer makes no attempt to reconstruct the original [smoothing groups](https://en.wikipedia.org/wiki/Smoothing_group). As a result, the normals of imported PSK files will be incorrect when imported into Blender and will need to be manually fixed. From 76440affdb795ce0d109dc87da93aae9a9cc603d Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 7 Nov 2023 18:33:18 -0800 Subject: [PATCH 05/48] Initial commit for handling the PSA config --- io_scene_psk_psa/__init__.py | 2 + io_scene_psk_psa/psa/config.py | 48 +++++++++++++++++++++++ io_scene_psk_psa/psa/import_/operators.py | 7 ++++ io_scene_psk_psa/psa/importer.py | 28 +++++++++---- 4 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 io_scene_psk_psa/psa/config.py diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index f9d5968..7092d7e 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -30,6 +30,7 @@ if 'bpy' in locals(): importlib.reload(psk_import_operators) importlib.reload(psa_data) + importlib.reload(psa_config) importlib.reload(psa_reader) importlib.reload(psa_writer) importlib.reload(psa_builder) @@ -55,6 +56,7 @@ else: from .psk.import_ import operators as psk_import_operators from .psa import data as psa_data + from .psa import config as psa_config from .psa import reader as psa_reader from .psa import writer as psa_writer from .psa import builder as psa_builder diff --git a/io_scene_psk_psa/psa/config.py b/io_scene_psk_psa/psa/config.py new file mode 100644 index 0000000..f9e1515 --- /dev/null +++ b/io_scene_psk_psa/psa/config.py @@ -0,0 +1,48 @@ +import re +from configparser import ConfigParser +from typing import Dict + +from .reader import PsaReader + +REMOVE_TRACK_LOCATION = (1 << 0) +REMOVE_TRACK_ROTATION = (1 << 1) + + +class PsaConfig: + def __init__(self): + self.sequence_bone_flags: Dict[str, Dict[int, int]] = dict() + + +def read_psa_config(psa_reader: PsaReader, file_path: str) -> PsaConfig: + psa_config = PsaConfig() + + config = ConfigParser() + config.read(file_path) + + psa_sequence_names = list(psa_reader.sequences.keys()) + lowercase_sequence_names = [sequence_name.lower() for sequence_name in psa_sequence_names] + + if config.has_section('RemoveTracks'): + for key, value in config.items('RemoveTracks'): + match = re.match(f'^(.+)\.(\d+)$', key) + sequence_name = match.group(1) + bone_index = int(match.group(2)) + + # Map the sequence name onto the actual sequence name in the PSA file. + try: + sequence_name = psa_sequence_names[lowercase_sequence_names.index(sequence_name.lower())] + except ValueError: + pass + + if sequence_name not in psa_config.sequence_bone_flags: + psa_config.sequence_bone_flags[sequence_name] = dict() + + match value: + case 'all': + psa_config.sequence_bone_flags[sequence_name][bone_index] = (REMOVE_TRACK_LOCATION | REMOVE_TRACK_ROTATION) + case 'trans': + psa_config.sequence_bone_flags[sequence_name][bone_index] = REMOVE_TRACK_LOCATION + case 'rot': + psa_config.sequence_bone_flags[sequence_name][bone_index] = REMOVE_TRACK_ROTATION + + return psa_config diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index 08af962..afa964d 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -1,10 +1,12 @@ import os +from pathlib import Path from bpy.props import StringProperty from bpy.types import Operator, Event, Context from bpy_extras.io_utils import ImportHelper from .properties import get_visible_sequences +from ..config import read_psa_config from ..importer import import_psa, PsaImportOptions from ..reader import PsaReader @@ -169,6 +171,11 @@ class PSA_OT_import(Operator, ImportHelper): options.fps_source = pg.fps_source options.fps_custom = pg.fps_custom + # Read the PSA config file if it exists. + config_path = Path(self.filepath).with_suffix('.config') + if config_path.exists(): + options.psa_config = read_psa_config(psa_reader, str(config_path)) + if len(sequence_names) == 0: self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected') return {'CANCELLED'} diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index c837e11..c4a66cc 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -6,6 +6,7 @@ import numpy from bpy.types import FCurve, Object, Context from mathutils import Vector, Quaternion +from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION from .data import Psa from .reader import PsaReader @@ -23,6 +24,7 @@ class PsaImportOptions(object): self.bone_mapping_mode = 'CASE_INSENSITIVE' self.fps_source = 'SEQUENCE' self.fps_custom: float = 30.0 + self.psa_config: PsaConfig = PsaConfig() class ImportBone(object): @@ -162,6 +164,11 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, sequence_name = sequence.name.decode('windows-1252') action_name = options.action_name_prefix + sequence_name + # Get the bone track flags for this sequence, or an empty dictionary if none exist. + sequence_bone_track_flags = dict() + if sequence_name in options.psa_config.sequence_bone_flags.keys(): + sequence_bone_track_flags = options.psa_config.sequence_bone_flags[sequence_name] + if options.should_overwrite and action_name in bpy.data.actions: action = bpy.data.actions[action_name] else: @@ -187,18 +194,21 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, # Create f-curves for the rotation and location of each bone. for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items(): + bone_track_flags = sequence_bone_track_flags.get(psa_bone_index, 0) import_bone = import_bones[psa_bone_index] pose_bone = import_bone.pose_bone rotation_data_path = pose_bone.path_from_id('rotation_quaternion') location_data_path = pose_bone.path_from_id('location') + add_rotation_fcurves = (bone_track_flags & REMOVE_TRACK_ROTATION) == 0 + add_location_fcurves = (bone_track_flags & REMOVE_TRACK_LOCATION) == 0 import_bone.fcurves = [ - action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name), # Qw - action.fcurves.new(rotation_data_path, index=1, action_group=pose_bone.name), # Qx - action.fcurves.new(rotation_data_path, index=2, action_group=pose_bone.name), # Qy - action.fcurves.new(rotation_data_path, index=3, action_group=pose_bone.name), # Qz - action.fcurves.new(location_data_path, index=0, action_group=pose_bone.name), # Lx - action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name), # Ly - action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name), # Lz + action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qw + action.fcurves.new(rotation_data_path, index=1, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qx + action.fcurves.new(rotation_data_path, index=2, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qy + action.fcurves.new(rotation_data_path, index=3, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qz + action.fcurves.new(location_data_path, index=0, action_group=pose_bone.name) if add_location_fcurves else None, # Lx + action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name) if add_location_fcurves else None, # Ly + action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name) if add_location_fcurves else None, # Lz ] # Read the sequence data matrix from the PSA. @@ -216,11 +226,15 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, # Write the keyframes out. fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float) + + # Populate the keyframe time data. fcurve_data[0::2] = [x * keyframe_time_dilation for x in range(sequence.frame_count)] for bone_index, import_bone in enumerate(import_bones): if import_bone is None: continue for fcurve_index, fcurve in enumerate(import_bone.fcurves): + if fcurve is None: + continue fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index] fcurve.keyframe_points.add(sequence.frame_count) fcurve.keyframe_points.foreach_set('co', fcurve_data) From c8e5b8eb47bec84d461fb701f2e7c64a3311f0fc Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Fri, 10 Nov 2023 23:19:36 -0800 Subject: [PATCH 06/48] Fixed a runtime error when importing PSKs --- io_scene_psk_psa/psk/importer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 809fbad..3d40c6e 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -227,7 +227,6 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: for vertex_normal in psk.vertex_normals: normals.append(tuple(vertex_normal)) mesh_data.normals_split_custom_set_from_vertices(normals) - mesh_data.use_auto_smooth = True bm.normal_update() bm.free() From 9fe5aa03889094b7faa963a1b32a1e0def33e741 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 14 Nov 2023 00:50:20 -0800 Subject: [PATCH 07/48] Now using `shade_smooth` to smoothshade imported meshes (same as it did in the pre 4.0 versions) --- io_scene_psk_psa/psk/importer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 3d40c6e..c701db9 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -222,11 +222,13 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: # VERTEX NORMALS if psk.has_vertex_normals and options.should_import_vertex_normals: - mesh_data.polygons.foreach_set("use_smooth", [True] * len(mesh_data.polygons)) + mesh_data.polygons.foreach_set('use_smooth', [True] * len(mesh_data.polygons)) normals = [] for vertex_normal in psk.vertex_normals: normals.append(tuple(vertex_normal)) mesh_data.normals_split_custom_set_from_vertices(normals) + else: + mesh_data.shade_smooth() bm.normal_update() bm.free() From b23677f2911c9a631e6785c47b7785737fb69d64 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 14 Nov 2023 01:33:18 -0800 Subject: [PATCH 08/48] Update README.md --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2452624..2e17c48 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ -[![Blender](https://img.shields.io/badge/Blender->=3.4-blue?logo=blender&logoColor=white)](https://www.blender.org/download/ "Download Blender") +[![Blender](https://img.shields.io/badge/Blender->=2.9-blue?logo=blender&logoColor=white)](https://www.blender.org/download/ "Download Blender") [![GitHub release](https://img.shields.io/github/release/DarklightGames/io_scene_psk_psa?include_prereleases=&sort=semver&color=blue)](https://github.com/DarklightGames/io_scene_psk_psa/releases/) [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/L4L3853VR) This Blender add-on allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many versions of the Unreal Engine. -> **NOTE**: This addon requires Blender 3.4+. If this is not available to you, install version [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0), as it has a minimum Blender version of 2.9, but is no longer receiving new features. +| Blender Version | Addon Version | [LTS](https://en.wikipedia.org/wiki/Long-term_support) | +|-----------------|---------------|-----| +| 4.0 | [6.0.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | ✔ +| 3.4 - 3.6 | [5.0.6](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.6) | ✔ +| 2.9 - 3.3 | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | # Features * Full PSK/PSA import and export capabilities. From b1a6fb9dea3ff6048e8fa90ba91d51c8482c032c Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 14 Nov 2023 01:43:32 -0800 Subject: [PATCH 09/48] Updated README --- README.md | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5eabf8b..1176e23 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,15 @@ This Blender add-on allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many versions of the Unreal Engine. -| Blender Version | Addon Version | [LTS](https://en.wikipedia.org/wiki/Long-term_support) | -|-----------------|---------------|-----| -| 4.0 | [6.0.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | ✔ -| 3.4 - 3.6 | [5.0.6](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.6) | ✔ -| 2.9 - 3.3 | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | +## Compatibility + +| Blender Version | Addon Version | Long Term Support | +|--------------------------------------------------------------|--------------------------------------------------------------------------------|-------------------| +| 4.0+ | [latest](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | TBD | +| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.5](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.5) | ✅️ June 2025 | +| [2.93 - 3.3](https://www.blender.org/download/releases/3-3/) | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | ✅️ September 2024 | + +Bug fixes will be issued for legacy addon versions that are under [Blender's LTS maintenance period](https://www.blender.org/download/lts/). Once the LTS period has ended, legacy addon-on versions will no longer be supported by the maintainers of this repository, although we will accept pull requests for bug fixes. # Features * Full PSK/PSA import and export capabilities. @@ -20,16 +24,6 @@ This Blender add-on allows you to import and export meshes and animations to and * PSA sequences can be exported directly from actions or delineated using a scene's [timeline markers](https://docs.blender.org/manual/en/latest/animation/markers.html) or NLA track strips, allowing direct use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences. * Manual re-ordering of material slots when exporting multiple mesh objects. -## Compatibility - -| Blender Version | Addon Version | LTS Support | -|--------------------------------------------------------------|--------------------------------------------------------------------------------|-------------------| -| 4.0+ | [latest](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | TBD | -| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.5](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.5) | ✅️ June 2025 | -| [2.93 - 3.3](https://www.blender.org/download/releases/3-3/) | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | ✅️ September 2024 | - -Bug fixes will be issued for legacy addon versions that are under [Blender's LTS maintenance period](https://www.blender.org/download/lts/). Once the LTS period has ended, legacy addon-on versions will no longer be supported by the maintainers of this repository, although we will accept pull requests for bug fixes. - # Installation 1. Download the zip file for the latest version from the [releases](https://github.com/DarklightGames/io_export_psk_psa/releases) page. 2. Open Blender 4.0.0 or later. From 3cf10abe915848350536b41304a93a7a592aad1a Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 20 Nov 2023 13:39:26 -0800 Subject: [PATCH 10/48] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1176e23..99782e2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/L4L3853VR) -This Blender add-on allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many versions of the Unreal Engine. +This Blender addon allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many versions of the Unreal Engine. ## Compatibility @@ -13,7 +13,7 @@ This Blender add-on allows you to import and export meshes and animations to and | [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.5](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.5) | ✅️ June 2025 | | [2.93 - 3.3](https://www.blender.org/download/releases/3-3/) | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | ✅️ September 2024 | -Bug fixes will be issued for legacy addon versions that are under [Blender's LTS maintenance period](https://www.blender.org/download/lts/). Once the LTS period has ended, legacy addon-on versions will no longer be supported by the maintainers of this repository, although we will accept pull requests for bug fixes. +Bug fixes will be issued for legacy addon versions that are under [Blender's LTS maintenance period](https://www.blender.org/download/lts/). Once the LTS period has ended, legacy addon versions will no longer be supported by the maintainers of this repository, although we will accept pull requests for bug fixes. # Features * Full PSK/PSA import and export capabilities. From 68c7d93d6a5e603b60f9e3f3c20821e93e8fabea Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Wed, 22 Nov 2023 19:19:49 -0800 Subject: [PATCH 11/48] Incremented version to 6.1.0 --- io_scene_psk_psa/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 7092d7e..9f12fa4 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -3,7 +3,7 @@ from bpy.app.handlers import persistent bl_info = { "name": "PSK/PSA Importer/Exporter", "author": "Colin Basnett, Yurii Ti", - "version": (6, 0, 0), + "version": (6, 1, 0), "blender": (4, 0, 0), "description": "PSK/PSA Import/Export (.psk/.psa)", "warning": "", From d0fe7d978605b2f84dd772e2074c8513e0384973 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Wed, 22 Nov 2023 19:20:16 -0800 Subject: [PATCH 12/48] Added progress indicator when exporting PSK files --- io_scene_psk_psa/psk/builder.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 80b3fe4..623ae66 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -146,7 +146,9 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: psk_material.texture_index = len(psk.materials) psk.materials.append(psk_material) - for input_mesh_object in input_objects.mesh_objects: + context.window_manager.progress_begin(0, len(input_objects.mesh_objects)) + + for object_index, input_mesh_object in enumerate(input_objects.mesh_objects): # MATERIALS material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots] @@ -288,6 +290,10 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: bpy.data.meshes.remove(mesh_data) del mesh_data + context.window_manager.progress_update(object_index) + + context.window_manager.progress_end() + result.psk = psk return result From 0c11b326afb70444aa610b036e10a3626a8cae9e Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 16 Jan 2024 20:21:12 -0800 Subject: [PATCH 13/48] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 99782e2..a60f3f0 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ Bug fixes will be issued for legacy addon versions that are under [Blender's LTS > Note that in order to see the imported actions applied to your armature, you must use the [Dope Sheet](https://docs.blender.org/manual/en/latest/editors/dope_sheet/introduction.html) or [Nonlinear Animation](https://docs.blender.org/manual/en/latest/editors/nla/introduction.html) editors. # FAQ + +## Why can't I see the animations imported from my PSA? +Simply importing an animation into the scene will not automatically apply the action to the armature. This is in part because a PSA can have multiple sequences imported from it, and also that it's generally bad form for importers to modify the scene when they don't need to. + +The PSA importer creates [Actions](https://docs.blender.org/manual/en/latest/animation/actions.html) for each of the selected sequences in the PSA. These actions can be applied to your armature via the [Action Editor](https://docs.blender.org/manual/en/latest/editors/dope_sheet/action.html) or [NLA Editor](https://docs.blender.org/manual/en/latest/editors/nla/index.html). + ## Why are the mesh normals not accurate when importing a PSK extracted from [UE Viewer](https://www.gildor.org/en/projects/umodel)? If preserving the mesh normals of models is important for your workflow, it is *not recommended* to export PSK files from UE Viewer. This is because UE Viewer makes no attempt to reconstruct the original [smoothing groups](https://en.wikipedia.org/wiki/Smoothing_group). As a result, the normals of imported PSK files will be incorrect when imported into Blender and will need to be manually fixed. From e9ba117fa975fcbe449aefe6b19f6ceb5c28c9b5 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 20 Jan 2024 14:48:18 -0800 Subject: [PATCH 14/48] Added file handler for PSK/PSKX files --- io_scene_psk_psa/psk/import_/operators.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psk/import_/operators.py b/io_scene_psk_psa/psk/import_/operators.py index 1cabb22..75141d7 100644 --- a/io_scene_psk_psa/psk/import_/operators.py +++ b/io_scene_psk_psa/psk/import_/operators.py @@ -2,7 +2,7 @@ import os import sys from bpy.props import StringProperty, BoolProperty, EnumProperty, FloatProperty -from bpy.types import Operator +from bpy.types import Operator, FileHandler, Context from bpy_extras.io_utils import ImportHelper from ..importer import PskImportOptions, import_psk @@ -11,6 +11,17 @@ from ..reader import read_psk empty_set = set() +class PSX_FH_psk(FileHandler): + bl_idname = 'PSX_FH_psk' + bl_label = 'Unreal PSK/PSKX' + bl_import_operator = 'import_scene.psk' + bl_file_extensions = '.psk;.pskx' + + @classmethod + def poll_drop(cls, context: Context): + return context.area.type == 'VIEW_3D' + + class PSK_OT_import(Operator, ImportHelper): bl_idname = 'import_scene.psk' bl_label = 'Import' @@ -141,4 +152,5 @@ class PSK_OT_import(Operator, ImportHelper): classes = ( PSK_OT_import, + PSX_FH_psk, ) From 82310d695ce9beb3e07beec180dfa17a9369ad49 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 20 Jan 2024 15:25:00 -0800 Subject: [PATCH 15/48] Fix for parsing errors on .config files coming from UEViewer --- io_scene_psk_psa/psa/config.py | 58 ++++++++++++++++------ io_scene_psk_psa/psa/import_/operators.py | 22 +++++--- io_scene_psk_psa/psa/import_/properties.py | 6 +++ io_scene_psk_psa/psa/importer.py | 1 + 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/io_scene_psk_psa/psa/config.py b/io_scene_psk_psa/psa/config.py index f9e1515..184d59c 100644 --- a/io_scene_psk_psa/psa/config.py +++ b/io_scene_psk_psa/psa/config.py @@ -13,36 +13,66 @@ class PsaConfig: self.sequence_bone_flags: Dict[str, Dict[int, int]] = dict() +def _load_config_file(file_path: str) -> ConfigParser: + """ + UEViewer exports a dialect of INI files that is not compatible with Python's ConfigParser. + Specifically, it allows values in this format: + + [Section] + Key1 + Key2 + + This is not allowed in Python's ConfigParser, which requires a '=' character after each key name. + To work around this, we'll modify the file to add the '=' character after each key name if it is missing. + """ + with open(file_path, 'r') as f: + lines = f.read().split('\n') + + lines = [re.sub(r'^\s*(\w+)\s*$', r'\1=', line) for line in lines] + + contents = '\n'.join(lines) + + config = ConfigParser() + config.read_string(contents) + + return config + + +def _get_bone_flags_from_value(value: str) -> int: + match value: + case 'all': + return (REMOVE_TRACK_LOCATION | REMOVE_TRACK_ROTATION) + case 'trans': + return REMOVE_TRACK_LOCATION + case 'rot': + return REMOVE_TRACK_ROTATION + case _: + return 0 + + def read_psa_config(psa_reader: PsaReader, file_path: str) -> PsaConfig: psa_config = PsaConfig() - config = ConfigParser() - config.read(file_path) - - psa_sequence_names = list(psa_reader.sequences.keys()) - lowercase_sequence_names = [sequence_name.lower() for sequence_name in psa_sequence_names] + config = _load_config_file(file_path) if config.has_section('RemoveTracks'): for key, value in config.items('RemoveTracks'): match = re.match(f'^(.+)\.(\d+)$', key) sequence_name = match.group(1) - bone_index = int(match.group(2)) # Map the sequence name onto the actual sequence name in the PSA file. try: + psa_sequence_names = list(psa_reader.sequences.keys()) + lowercase_sequence_names = [sequence_name.lower() for sequence_name in psa_sequence_names] sequence_name = psa_sequence_names[lowercase_sequence_names.index(sequence_name.lower())] except ValueError: - pass + # Sequence name is not in the PSA file. + continue if sequence_name not in psa_config.sequence_bone_flags: psa_config.sequence_bone_flags[sequence_name] = dict() - match value: - case 'all': - psa_config.sequence_bone_flags[sequence_name][bone_index] = (REMOVE_TRACK_LOCATION | REMOVE_TRACK_ROTATION) - case 'trans': - psa_config.sequence_bone_flags[sequence_name][bone_index] = REMOVE_TRACK_LOCATION - case 'rot': - psa_config.sequence_bone_flags[sequence_name][bone_index] = REMOVE_TRACK_ROTATION + bone_index = int(match.group(2)) + psa_config.sequence_bone_flags[sequence_name][bone_index] = _get_bone_flags_from_value(value) return psa_config diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index afa964d..4b75a1a 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -158,6 +158,10 @@ class PSA_OT_import(Operator, ImportHelper): psa_reader = PsaReader(self.filepath) sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected] + if len(sequence_names) == 0: + self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected') + return {'CANCELLED'} + options = PsaImportOptions() options.sequence_names = sequence_names options.should_use_fake_user = pg.should_use_fake_user @@ -171,14 +175,14 @@ class PSA_OT_import(Operator, ImportHelper): options.fps_source = pg.fps_source options.fps_custom = pg.fps_custom - # Read the PSA config file if it exists. - config_path = Path(self.filepath).with_suffix('.config') - if config_path.exists(): - options.psa_config = read_psa_config(psa_reader, str(config_path)) - - if len(sequence_names) == 0: - self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected') - return {'CANCELLED'} + if options.should_use_config_file: + # Read the PSA config file if it exists. + config_path = Path(self.filepath).with_suffix('.config') + if config_path.exists(): + try: + options.psa_config = read_psa_config(psa_reader, str(config_path)) + except Exception as e: + self.report({'WARNING'}, f'Failed to read PSA config file: {e}') result = import_psa(context, psa_reader, context.view_layer.objects.active, options) @@ -258,6 +262,8 @@ class PSA_OT_import(Operator, ImportHelper): col.use_property_decorate = False col.prop(pg, 'should_use_fake_user') col.prop(pg, 'should_stash') + col.prop(pg, 'should_use_config_file') + col.prop(pg, 'should_use_action_name_prefix') if pg.should_use_action_name_prefix: diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py index b7e14f5..43dd375 100644 --- a/io_scene_psk_psa/psa/import_/properties.py +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -32,6 +32,12 @@ class PSA_PG_import(PropertyGroup): description='Assign each imported action a fake user so that the data block is ' 'saved even it has no users', options=empty_set) + should_use_config_file: BoolProperty(default=True, name='Use Config File', + description='Use the .config file that is sometimes generated when the PSA ' + 'file is exported from UEViewer. This file contains ' + 'options that can be used to filter out certain bones tracks ' + 'from the imported actions', + options=empty_set) should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track', options=empty_set) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index c4a66cc..5864645 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -24,6 +24,7 @@ class PsaImportOptions(object): self.bone_mapping_mode = 'CASE_INSENSITIVE' self.fps_source = 'SEQUENCE' self.fps_custom: float = 30.0 + self.should_use_config_file = True self.psa_config: PsaConfig = PsaConfig() From d0f64a6546dccec8035d97f981edf09ab98249c8 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 20 Jan 2024 15:25:14 -0800 Subject: [PATCH 16/48] Incremented version to 6.1.1 --- io_scene_psk_psa/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 9f12fa4..aa72ddc 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -3,7 +3,7 @@ from bpy.app.handlers import persistent bl_info = { "name": "PSK/PSA Importer/Exporter", "author": "Colin Basnett, Yurii Ti", - "version": (6, 1, 0), + "version": (6, 1, 1), "blender": (4, 0, 0), "description": "PSK/PSA Import/Export (.psk/.psa)", "warning": "", From b4712295847fabb0ca3e989cfe7236add6e43dae Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 20 Jan 2024 15:25:48 -0800 Subject: [PATCH 17/48] Rephrased description of PSA export option --- io_scene_psk_psa/psa/export/properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index 518adf9..59cd755 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -152,7 +152,7 @@ class PSA_PG_export(PropertyGroup): default=False, name='Enforce Bone Name Restrictions', description='Bone names restrictions will be enforced. Note that bone names without properly formatted names ' - 'cannot be referenced in scripts' + 'may not be able to be referenced in-engine' ) sequence_name_prefix: StringProperty(name='Prefix', options=empty_set) sequence_name_suffix: StringProperty(name='Suffix', options=empty_set) From 361a7f021859695b30ed2baeda47bbe994e6f4a2 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 20 Jan 2024 15:31:26 -0800 Subject: [PATCH 18/48] Added the name of the imported PSK object to the info report --- io_scene_psk_psa/psk/import_/operators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psk/import_/operators.py b/io_scene_psk_psa/psk/import_/operators.py index 1cabb22..e90b44c 100644 --- a/io_scene_psk_psa/psk/import_/operators.py +++ b/io_scene_psk_psa/psk/import_/operators.py @@ -113,7 +113,7 @@ class PSK_OT_import(Operator, ImportHelper): message += '\n'.join(result.warnings) self.report({'WARNING'}, message) else: - self.report({'INFO'}, f'PSK imported') + self.report({'INFO'}, f'PSK imported ({options.name})') return {'FINISHED'} From 7e6911c709a044bccdcd147995c3d49a2a7b78a5 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 22 Jan 2024 11:26:09 -0800 Subject: [PATCH 19/48] PSK files imported with custom normals will now have `Auto Smooth` enabled by default (#67) --- io_scene_psk_psa/psk/importer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index c701db9..f07fd11 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -227,6 +227,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: for vertex_normal in psk.vertex_normals: normals.append(tuple(vertex_normal)) mesh_data.normals_split_custom_set_from_vertices(normals) + mesh_data.use_auto_smooth = True else: mesh_data.shade_smooth() From fda976d0839321013c6f669bd46c39fdb16ed93f Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 22 Jan 2024 11:32:06 -0800 Subject: [PATCH 20/48] Incremented version to 6.1.2 --- io_scene_psk_psa/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index aa72ddc..f4496db 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -3,7 +3,7 @@ from bpy.app.handlers import persistent bl_info = { "name": "PSK/PSA Importer/Exporter", "author": "Colin Basnett, Yurii Ti", - "version": (6, 1, 1), + "version": (6, 1, 2), "blender": (4, 0, 0), "description": "PSK/PSA Import/Export (.psk/.psa)", "warning": "", From 3d3bbb9296b60550081c376765419379b4ca1e85 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 22 Jan 2024 14:50:06 -0800 Subject: [PATCH 21/48] Set the subtype of the bone length property on PSK import to distance --- io_scene_psk_psa/psk/import_/operators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/io_scene_psk_psa/psk/import_/operators.py b/io_scene_psk_psa/psk/import_/operators.py index e90b44c..3ebc084 100644 --- a/io_scene_psk_psa/psk/import_/operators.py +++ b/io_scene_psk_psa/psk/import_/operators.py @@ -82,6 +82,7 @@ class PSK_OT_import(Operator, ImportHelper): soft_min=1.0, name='Bone Length', options=empty_set, + subtype='DISTANCE', description='Length of the bones' ) should_import_shape_keys: BoolProperty( From b2f59856817d86d2346c5f6c7fa034f3d3821774 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 22 Jan 2024 14:56:51 -0800 Subject: [PATCH 22/48] Improved PSK vertex color importing to be per-corner instead of per-vertex Previously, the importer was storing the vertex colors in the point domain. However, the PSK itself stores colors per "wedge", which maps to corners, resulting in the "ambiguous vertex colors" warning if two corners mapped to the same vertex but had different colors. The new method is now more correct and doesn't discard data. --- io_scene_psk_psa/psk/importer.py | 49 ++++++++++++++------------------ 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index f07fd11..15c0801 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -1,4 +1,3 @@ -from math import inf from typing import Optional, List import bmesh @@ -17,7 +16,7 @@ class PskImportOptions: self.should_import_mesh = True self.should_reuse_materials = True self.should_import_vertex_colors = True - self.vertex_color_space = 'sRGB' + self.vertex_color_space = 'SRGB' self.should_import_vertex_normals = True self.should_import_extra_uvs = True self.should_import_skeleton = True @@ -144,6 +143,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: bm.verts.ensure_lookup_table() + # FACES invalid_face_indices = set() for face_index, face in enumerate(psk.faces): point_indices = map(lambda i: psk.wedges[i].point_index, reversed(face.wedge_indices)) @@ -192,33 +192,28 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: # VERTEX COLORS if psk.has_vertex_colors and options.should_import_vertex_colors: - size = (len(psk.points), 4) - vertex_colors = np.full(size, inf) - vertex_color_data = mesh_data.vertex_colors.new(name='VERTEXCOLOR') - ambiguous_vertex_color_point_indices = [] + # Convert vertex colors to sRGB if necessary. + psk_vertex_colors = np.zeros((len(psk.vertex_colors), 4)) + for i in range(len(psk.vertex_colors)): + psk_vertex_colors[i,:] = psk.vertex_colors[i].normalized() + match options.vertex_color_space: + case 'SRGBA': + for i in range(psk_vertex_colors.shape[0]): + psk_vertex_colors[i, :3] = tuple(map(lambda x: rgb_to_srgb(x), psk_vertex_colors[i, :3])) + case _: + pass - for wedge_index, wedge in enumerate(psk.wedges): - point_index = wedge.point_index - psk_vertex_color = psk.vertex_colors[wedge_index].normalized() - if vertex_colors[point_index, 0] != inf and tuple(vertex_colors[point_index]) != psk_vertex_color: - ambiguous_vertex_color_point_indices.append(point_index) - else: - vertex_colors[point_index] = psk_vertex_color + # Map the PSK vertex colors to the face corners. + face_corner_colors = np.full((len(psk.faces * 3), 4), 1.0) + face_corner_color_index = 0 + for face_index, face in enumerate(psk.faces): + for wedge_index in reversed(face.wedge_indices): + face_corner_colors[face_corner_color_index] = psk_vertex_colors[wedge_index] + face_corner_color_index += 1 - if options.vertex_color_space == 'SRGBA': - for i in range(vertex_colors.shape[0]): - vertex_colors[i, :3] = tuple(map(lambda x: rgb_to_srgb(x), vertex_colors[i, :3])) - - for loop_index, loop in enumerate(mesh_data.loops): - vertex_color = vertex_colors[loop.vertex_index] - if vertex_color is not None: - vertex_color_data.data[loop_index].color = vertex_color - else: - vertex_color_data.data[loop_index].color = 1.0, 1.0, 1.0, 1.0 - - if len(ambiguous_vertex_color_point_indices) > 0: - result.warnings.append( - f'{len(ambiguous_vertex_color_point_indices)} vertex(es) with ambiguous vertex colors.') + # Create the vertex color attribute. + face_corner_color_attribute = mesh_data.attributes.new(name='VERTEXCOLOR', type='FLOAT_COLOR', domain='CORNER') + face_corner_color_attribute.data.foreach_set('color', face_corner_colors.flatten()) # VERTEX NORMALS if psk.has_vertex_normals and options.should_import_vertex_normals: From 564f7ec221766b9659df85308f9331e2df5b8efc Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 22 Jan 2024 15:23:01 -0800 Subject: [PATCH 23/48] All string literals now use single-quotes instead of double-quotes where possible --- io_scene_psk_psa/__init__.py | 18 +++++++------- io_scene_psk_psa/helpers.py | 8 +++---- io_scene_psk_psa/psa/config.py | 4 ++-- io_scene_psk_psa/psa/data.py | 4 ++-- io_scene_psk_psa/psa/export/ui.py | 6 ++--- io_scene_psk_psa/psa/import_/operators.py | 6 ++--- io_scene_psk_psa/psa/import_/ui.py | 8 +++---- io_scene_psk_psa/psa/importer.py | 4 ++-- io_scene_psk_psa/psa/reader.py | 29 +++++++++++------------ io_scene_psk_psa/psk/export/operators.py | 4 ++-- io_scene_psk_psa/psk/importer.py | 4 ++-- io_scene_psk_psa/psk/reader.py | 2 +- 12 files changed, 48 insertions(+), 49 deletions(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index f4496db..7570fe9 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -1,15 +1,15 @@ from bpy.app.handlers import persistent bl_info = { - "name": "PSK/PSA Importer/Exporter", - "author": "Colin Basnett, Yurii Ti", - "version": (6, 1, 2), - "blender": (4, 0, 0), - "description": "PSK/PSA Import/Export (.psk/.psa)", - "warning": "", - "doc_url": "https://github.com/DarklightGames/io_scene_psk_psa", - "tracker_url": "https://github.com/DarklightGames/io_scene_psk_psa/issues", - "category": "Import-Export" + 'name': 'PSK/PSA Importer/Exporter', + 'author': 'Colin Basnett, Yurii Ti', + 'version': (6, 1, 2), + 'blender': (4, 0, 0), + 'description': 'PSK/PSA Import/Export (.psk/.psa)', + 'warning': '', + 'doc_url': 'https://github.com/DarklightGames/io_scene_psk_psa', + 'tracker_url': 'https://github.com/DarklightGames/io_scene_psk_psa/issues', + 'category': 'Import-Export' } if 'bpy' in locals(): diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index f778e1e..b7a6520 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -30,12 +30,12 @@ def get_nla_strips_in_frame_range(animation_data: AnimData, frame_min: float, fr def populate_bone_collection_list(armature_object: Object, bone_collection_list: bpy.props.CollectionProperty) -> None: - """ + ''' Updates the bone collections collection. Bone collection selections are preserved between updates unless none of the groups were previously selected; otherwise, all collections are selected by default. - """ + ''' has_selected_collections = any([g.is_selected for g in bone_collection_list]) unassigned_collection_is_selected, selected_assigned_collection_names = True, [] @@ -84,7 +84,7 @@ def check_bone_names(bone_names: Iterable[str]): def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_collection_indices: List[int]) -> List[str]: - """ + ''' Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone collections. Note that the ancestors of bones within the bone collections will also be present in the returned list. @@ -93,7 +93,7 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c :param bone_filter_mode: One of ['ALL', 'BONE_COLLECTIONS'] :param bone_collection_indices: List of bone collection indices to be exported. :return: A sorted list of bone indices that should be exported. - """ + ''' if armature_object is None or armature_object.type != 'ARMATURE': raise ValueError('An armature object must be supplied') diff --git a/io_scene_psk_psa/psa/config.py b/io_scene_psk_psa/psa/config.py index 184d59c..3016c59 100644 --- a/io_scene_psk_psa/psa/config.py +++ b/io_scene_psk_psa/psa/config.py @@ -14,7 +14,7 @@ class PsaConfig: def _load_config_file(file_path: str) -> ConfigParser: - """ + ''' UEViewer exports a dialect of INI files that is not compatible with Python's ConfigParser. Specifically, it allows values in this format: @@ -24,7 +24,7 @@ def _load_config_file(file_path: str) -> ConfigParser: This is not allowed in Python's ConfigParser, which requires a '=' character after each key name. To work around this, we'll modify the file to add the '=' character after each key name if it is missing. - """ + ''' with open(file_path, 'r') as f: lines = f.read().split('\n') diff --git a/io_scene_psk_psa/psa/data.py b/io_scene_psk_psa/psa/data.py index 8d5d8e5..776d244 100644 --- a/io_scene_psk_psa/psa/data.py +++ b/io_scene_psk_psa/psa/data.py @@ -4,10 +4,10 @@ from typing import List from ..data import * -""" +''' Note that keys are not stored within the Psa object. Use the PsaReader::get_sequence_keys to get the keys for a sequence. -""" +''' class Psa: diff --git a/io_scene_psk_psa/psa/export/ui.py b/io_scene_psk_psa/psa/export/ui.py index d302265..203b530 100644 --- a/io_scene_psk_psa/psa/export/ui.py +++ b/io_scene_psk_psa/psa/export/ui.py @@ -30,15 +30,15 @@ class PSA_UL_export_sequences(UIList): pg = getattr(context.scene, 'psa_export') row = layout.row() subrow = row.row(align=True) - subrow.prop(pg, 'sequence_filter_name', text="") - subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT') + subrow.prop(pg, 'sequence_filter_name', text='') + subrow.prop(pg, 'sequence_use_filter_invert', text='', icon='ARROW_LEFTRIGHT') # subrow.prop(pg, 'sequence_use_filter_sort_reverse', text='', icon='SORT_ASC') if pg.sequence_source == 'ACTIONS': subrow = row.row(align=True) subrow.prop(pg, 'sequence_filter_asset', icon_only=True, icon='ASSET_MANAGER') subrow.prop(pg, 'sequence_filter_pose_marker', icon_only=True, icon='PMARKER') - subrow.prop(pg, 'sequence_filter_reversed', text="", icon='FRAME_PREV') + subrow.prop(pg, 'sequence_filter_reversed', text='', icon='FRAME_PREV') def filter_items(self, context, data, prop): pg = getattr(context.scene, 'psa_export') diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index 4b75a1a..7891a32 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -95,15 +95,15 @@ class PSA_OT_import_select_file(Operator): bl_options = {'INTERNAL'} bl_description = 'Select a PSA file from which to import animations' filepath: StringProperty(subtype='FILE_PATH') - filter_glob: StringProperty(default="*.psa", options={'HIDDEN'}) + filter_glob: StringProperty(default='*.psa', options={'HIDDEN'}) def execute(self, context): getattr(context.scene, 'psa_import').psa_file_path = self.filepath - return {"FINISHED"} + return {'FINISHED'} def invoke(self, context, event): context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} + return {'RUNNING_MODAL'} def load_psa_file(context, filepath: str): diff --git a/io_scene_psk_psa/psa/import_/ui.py b/io_scene_psk_psa/psa/import_/ui.py index 597c134..987a8c1 100644 --- a/io_scene_psk_psa/psa/import_/ui.py +++ b/io_scene_psk_psa/psa/import_/ui.py @@ -17,10 +17,10 @@ class PSA_UL_sequences(UIList): pg = getattr(context.scene, 'psa_import') row = layout.row() sub_row = row.row(align=True) - sub_row.prop(pg, 'sequence_filter_name', text="") - sub_row.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT') - sub_row.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT') - sub_row.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT') + sub_row.prop(pg, 'sequence_filter_name', text='') + sub_row.prop(pg, 'sequence_use_filter_invert', text='', icon='ARROW_LEFTRIGHT') + sub_row.prop(pg, 'sequence_use_filter_regex', text='', icon='SORTBYEXT') + sub_row.prop(pg, 'sequence_filter_is_selected', text='', icon='CHECKBOX_HLT') def filter_items(self, context, data, property_): pg = getattr(context.scene, 'psa_import') diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 5864645..44f2572 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -64,12 +64,12 @@ class PsaImportResult: def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_names: List[str], bone_mapping_mode: str = 'EXACT') -> Optional[int]: - """ + ''' @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'. @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): if bone_mapping_mode == 'CASE_INSENSITIVE': if armature_bone_name.lower() == psa_bone_name.lower(): diff --git a/io_scene_psk_psa/psa/reader.py b/io_scene_psk_psa/psa/reader.py index 6b74c2f..e60fbb4 100644 --- a/io_scene_psk_psa/psa/reader.py +++ b/io_scene_psk_psa/psa/reader.py @@ -11,24 +11,23 @@ def _try_fix_cue4parse_issue_103(sequences) -> bool: # The issue was that the frame_start_index was not being set correctly, and was always being set to the same value # as the frame_count. # This fix will eventually be deprecated as it is only necessary for files exported prior to the fix. - if len(sequences) > 0: - if sequences[0].frame_start_index == sequences[0].frame_count: - # Manually set the frame_start_index for each sequence. This assumes that the sequences are in order with - # no shared frames between sequences (all exporters that I know of do this, so it's a safe assumption). - frame_start_index = 0 - for i, sequence in enumerate(sequences): - sequence.frame_start_index = frame_start_index - frame_start_index += sequence.frame_count - return True + if len(sequences) == 0 and sequences[0].frame_start_index == sequences[0].frame_count: + # Manually set the frame_start_index for each sequence. This assumes that the sequences are in order with + # no shared frames between sequences (all exporters that I know of do this, so it's a safe assumption). + frame_start_index = 0 + for i, sequence in enumerate(sequences): + sequence.frame_start_index = frame_start_index + frame_start_index += sequence.frame_count + return True return False class PsaReader(object): - """ + ''' This class reads the sequences and bone information immediately upon instantiation and holds onto a file handle. The keyframe data is not read into memory upon instantiation due to its potentially very large size. To read the key data for a particular sequence, call :read_sequence_keys. - """ + ''' def __init__(self, path): self.keys_data_offset: int = 0 @@ -44,11 +43,11 @@ class PsaReader(object): return self.psa.sequences 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. - """ + ''' sequence = self.psa.sequences[sequence_name] keys = self.read_sequence_keys(sequence_name) bone_count = len(self.bones) @@ -61,12 +60,12 @@ class PsaReader(object): return matrix def read_sequence_keys(self, sequence_name: str) -> List[Psa.Key]: - """ + ''' Reads and returns the key data for a sequence. @param sequence_name: The name of the sequence. @return: A list of Psa.Keys. - """ + ''' # Set the file reader to the beginning of the keys data sequence = self.psa.sequences[sequence_name] data_size = sizeof(Psa.Key) diff --git a/io_scene_psk_psa/psk/export/operators.py b/io_scene_psk_psa/psk/export/operators.py index 80e097f..a902b27 100644 --- a/io_scene_psk_psa/psk/export/operators.py +++ b/io_scene_psk_psa/psk/export/operators.py @@ -51,7 +51,7 @@ class PSK_OT_material_list_move_up(Operator): pg = getattr(context.scene, 'psk_export') pg.material_list.move(pg.material_list_index, pg.material_list_index - 1) pg.material_list_index -= 1 - return {"FINISHED"} + return {'FINISHED'} class PSK_OT_material_list_move_down(Operator): @@ -69,7 +69,7 @@ class PSK_OT_material_list_move_down(Operator): pg = getattr(context.scene, 'psk_export') pg.material_list.move(pg.material_list_index, pg.material_list_index + 1) pg.material_list_index += 1 - return {"FINISHED"} + return {'FINISHED'} class PSK_OT_export(Operator, ExportHelper): diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 15c0801..eb8866d 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -26,9 +26,9 @@ class PskImportOptions: class ImportBone: - """ + ''' Intermediate bone type for the purpose of construction. - """ + ''' def __init__(self, index: int, psk_bone: Psk.Bone): self.index: int = index self.psk_bone: Psk.Bone = psk_bone diff --git a/io_scene_psk_psa/psk/reader.py b/io_scene_psk_psa/psk/reader.py index 6fa2da7..f71a006 100644 --- a/io_scene_psk_psa/psk/reader.py +++ b/io_scene_psk_psa/psk/reader.py @@ -23,7 +23,7 @@ def _read_material_references(path: str) -> List[str]: return [] # Do a crude regex match to find the Material list entries. contents = property_file_path.read_text() - pattern = r"Material\s*=\s*([^\s^,]+)" + pattern = r'Material\s*=\s*([^\s^,]+)' return re.findall(pattern, contents) From 5a870104f1eb0b0b3ea7a32e5ef59c4df112f6f2 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 22 Jan 2024 15:23:16 -0800 Subject: [PATCH 24/48] Added TODO about 4.1 compatibility --- io_scene_psk_psa/psk/importer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index eb8866d..530ba7e 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -222,6 +222,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: for vertex_normal in psk.vertex_normals: normals.append(tuple(vertex_normal)) mesh_data.normals_split_custom_set_from_vertices(normals) + # TODO: This has been removed in 4.1! mesh_data.use_auto_smooth = True else: mesh_data.shade_smooth() From 9438a35cd1f4d2dca27b910ada25b358317b0dc0 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Wed, 24 Jan 2024 18:46:03 -0800 Subject: [PATCH 25/48] Fixed a bug where any invalid faces would result in import failure if also importing vertex colors --- io_scene_psk_psa/psk/importer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 530ba7e..2594e8f 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -204,9 +204,12 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: pass # Map the PSK vertex colors to the face corners. - face_corner_colors = np.full((len(psk.faces * 3), 4), 1.0) + face_count = len(psk.faces) - len(invalid_face_indices) + face_corner_colors = np.full((face_count * 3, 4), 1.0) face_corner_color_index = 0 for face_index, face in enumerate(psk.faces): + if face_index in invalid_face_indices: + continue for wedge_index in reversed(face.wedge_indices): face_corner_colors[face_corner_color_index] = psk_vertex_colors[wedge_index] face_corner_color_index += 1 From ced03afafe42e1e3398f31f0fb2faf01a84647ee Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 25 Jan 2024 11:43:13 -0800 Subject: [PATCH 26/48] Added "Scale" option for PSK import --- io_scene_psk_psa/psa/importer.py | 18 +++---- io_scene_psk_psa/psk/import_/operators.py | 60 ++++++++++++++--------- io_scene_psk_psa/psk/importer.py | 8 +++ 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 44f2572..d9438f6 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -176,15 +176,15 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, action = bpy.data.actions.new(name=action_name) # Calculate the target FPS. - target_fps = sequence.fps - if options.fps_source == 'CUSTOM': - target_fps = options.fps_custom - elif options.fps_source == 'SCENE': - target_fps = context.scene.render.fps - elif options.fps_source == 'SEQUENCE': - target_fps = sequence.fps - else: - raise ValueError(f'Unknown FPS source: {options.fps_source}') + match options.fps_source: + case 'CUSTOM': + target_fps = options.fps_custom + case 'SCENE': + target_fps = context.scene.render.fps + case 'SEQUENCE': + target_fps = sequence.fps + case _: + raise ValueError(f'Unknown FPS source: {options.fps_source}') keyframe_time_dilation = target_fps / sequence.fps diff --git a/io_scene_psk_psa/psk/import_/operators.py b/io_scene_psk_psa/psk/import_/operators.py index 3ebc084..ea57ccc 100644 --- a/io_scene_psk_psa/psk/import_/operators.py +++ b/io_scene_psk_psa/psk/import_/operators.py @@ -27,8 +27,8 @@ class PSK_OT_import(Operator, ImportHelper): should_import_vertex_colors: BoolProperty( default=True, options=empty_set, - name='Vertex Colors', - description='Import vertex colors from PSKX files, if available' + name='Import Vertex Colors', + description='Import vertex colors, if available' ) vertex_color_space: EnumProperty( name='Vertex Color Space', @@ -42,13 +42,13 @@ class PSK_OT_import(Operator, ImportHelper): ) should_import_vertex_normals: BoolProperty( default=True, - name='Vertex Normals', + name='Import Vertex Normals', options=empty_set, description='Import vertex normals, if available' ) should_import_extra_uvs: BoolProperty( default=True, - name='Extra UVs', + name='Import Extra UVs', options=empty_set, description='Import extra UV maps, if available' ) @@ -63,12 +63,6 @@ class PSK_OT_import(Operator, ImportHelper): name='Import Materials', options=empty_set, ) - should_reuse_materials: BoolProperty( - default=True, - name='Reuse Materials', - options=empty_set, - description='Existing materials with matching names will be reused when available' - ) should_import_skeleton: BoolProperty( default=True, name='Import Skeleton', @@ -87,10 +81,15 @@ class PSK_OT_import(Operator, ImportHelper): ) should_import_shape_keys: BoolProperty( default=True, - name='Shape Keys', + name='Import Shape Keys', options=empty_set, description='Import shape keys, if available' ) + scale: FloatProperty( + name='Scale', + default=1.0, + soft_min=0.0, + ) def execute(self, context): psk = read_psk(self.filepath) @@ -106,6 +105,7 @@ class PSK_OT_import(Operator, ImportHelper): options.bone_length = self.bone_length options.should_import_materials = self.should_import_materials options.should_import_shape_keys = self.should_import_shape_keys + options.scale = self.scale result = import_psk(psk, context, options) @@ -120,24 +120,36 @@ class PSK_OT_import(Operator, ImportHelper): def draw(self, context): layout = self.layout - layout.prop(self, 'should_import_materials') + + row = layout.row() + + col = row.column() + col.use_property_split = True + col.use_property_decorate = False + col.prop(self, 'scale') + layout.prop(self, 'should_import_mesh') - row = layout.column() - row.use_property_split = True - row.use_property_decorate = False + if self.should_import_mesh: - row.prop(self, 'should_import_vertex_normals') - row.prop(self, 'should_import_extra_uvs') - row.prop(self, 'should_import_vertex_colors') + row = layout.row() + col = row.column() + col.use_property_split = True + col.use_property_decorate = False + col.prop(self, 'should_import_materials', text='Materials') + col.prop(self, 'should_import_vertex_normals', text='Vertex Normals') + col.prop(self, 'should_import_extra_uvs', text='Extra UVs') + col.prop(self, 'should_import_vertex_colors', text='Vertex Colors') if self.should_import_vertex_colors: - row.prop(self, 'vertex_color_space') - row.prop(self, 'should_import_shape_keys') + col.prop(self, 'vertex_color_space') + col.prop(self, 'should_import_shape_keys', text='Shape Keys') + layout.prop(self, 'should_import_skeleton') - row = layout.column() - row.use_property_split = True - row.use_property_decorate = False if self.should_import_skeleton: - row.prop(self, 'bone_length') + row = layout.row() + col = row.column() + col.use_property_split = True + col.use_property_decorate = False + col.prop(self, 'bone_length') classes = ( diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 2594e8f..e6efe89 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -23,6 +23,7 @@ class PskImportOptions: self.should_import_shape_keys = True self.bone_length = 1.0 self.should_import_materials = True + self.scale = 1.0 class ImportBone: @@ -51,6 +52,10 @@ class PskImportResult: def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: result = PskImportResult() armature_object = None + mesh_object = None + + if not options.should_import_mesh and not options.should_import_skeleton: + raise Exception('Nothing to import') if options.should_import_skeleton: # ARMATURE @@ -266,6 +271,9 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: armature_modifier.object = armature_object mesh_object.parent = armature_object + root_object = armature_object if options.should_import_skeleton else mesh_object + root_object.scale = (options.scale, options.scale, options.scale) + try: bpy.ops.object.mode_set(mode='OBJECT') except: From 0a7804b5abdb8564be9dd033710c53856a598a44 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 27 Jan 2024 00:42:51 -0800 Subject: [PATCH 27/48] Improved reporting of PSK import error when there is nothing selected to import --- io_scene_psk_psa/psk/import_/operators.py | 4 ++++ io_scene_psk_psa/psk/importer.py | 3 --- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/io_scene_psk_psa/psk/import_/operators.py b/io_scene_psk_psa/psk/import_/operators.py index ea57ccc..0a4db97 100644 --- a/io_scene_psk_psa/psk/import_/operators.py +++ b/io_scene_psk_psa/psk/import_/operators.py @@ -107,6 +107,10 @@ class PSK_OT_import(Operator, ImportHelper): options.should_import_shape_keys = self.should_import_shape_keys options.scale = self.scale + if not options.should_import_mesh and not options.should_import_skeleton: + self.report({'ERROR'}, 'Nothing to import') + return {'CANCELLED'} + result = import_psk(psk, context, options) if len(result.warnings): diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index e6efe89..8fe4da5 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -54,9 +54,6 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: armature_object = None mesh_object = None - if not options.should_import_mesh and not options.should_import_skeleton: - raise Exception('Nothing to import') - if options.should_import_skeleton: # ARMATURE armature_data = bpy.data.armatures.new(options.name) From 9125606bc4ca6cbdf472c17abf0996e9f5bf9646 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Fri, 2 Feb 2024 12:28:45 -0800 Subject: [PATCH 28/48] Fix for issue #71 --- io_scene_psk_psa/psa/reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psa/reader.py b/io_scene_psk_psa/psa/reader.py index e60fbb4..08060b9 100644 --- a/io_scene_psk_psa/psa/reader.py +++ b/io_scene_psk_psa/psa/reader.py @@ -11,7 +11,7 @@ def _try_fix_cue4parse_issue_103(sequences) -> bool: # The issue was that the frame_start_index was not being set correctly, and was always being set to the same value # as the frame_count. # This fix will eventually be deprecated as it is only necessary for files exported prior to the fix. - if len(sequences) == 0 and sequences[0].frame_start_index == sequences[0].frame_count: + if len(sequences) > 0 and sequences[0].frame_start_index == sequences[0].frame_count: # Manually set the frame_start_index for each sequence. This assumes that the sequences are in order with # no shared frames between sequences (all exporters that I know of do this, so it's a safe assumption). frame_start_index = 0 From 44100a50f062afea0af59c683e86f1d0ac23beda Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 6 Feb 2024 13:18:19 -0800 Subject: [PATCH 29/48] Added better handling for exporting meshes with negative scaling The exporter now corrects the face normals of meshes with negative scaling that would otherwise be inverted on export. The face orientation should now always match what is seen in the viewport. --- io_scene_psk_psa/psk/builder.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 623ae66..95e4b2d 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -150,6 +150,8 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: for object_index, input_mesh_object in enumerate(input_objects.mesh_objects): + should_flip_normals = False + # MATERIALS material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots] @@ -177,8 +179,16 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: mesh_object.matrix_world = input_mesh_object.matrix_world scale = (input_mesh_object.scale.x, input_mesh_object.scale.y, input_mesh_object.scale.z) - if any(map(lambda x: x < 0, scale)): - result.warnings.append(f'Mesh "{input_mesh_object.name}" has negative scaling which may result in inverted normals.') + + # Negative scaling in Blender results in inverted normals after the scale is applied. However, if the scale + # is not applied, the normals will appear unaffected in the viewport. The evaluated mesh data used in the + # export will have the scale applied, but this behavior is not obvious to the user. + # + # In order to have the exporter be as WYSIWYG as possible, we need to check for negative scaling and invert + # the normals if necessary. If two axes have negative scaling and the third has positive scaling, 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 # Copy the vertex groups for vertex_group in input_mesh_object.vertex_groups: @@ -233,6 +243,7 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: # FACES poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=True) + psk_face_start_index = len(psk.faces) for f in mesh_data.loop_triangles: face = Psk.Face() face.material_index = material_indices[f.material_index] @@ -242,6 +253,11 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: face.smoothing_groups = poly_groups[f.polygon_index] psk.faces.append(face) + if should_flip_normals: + # Invert the normals of the faces. + for face in psk.faces[psk_face_start_index:]: + face.wedge_indices[0], face.wedge_indices[2] = face.wedge_indices[2], face.wedge_indices[0] + # WEIGHTS if armature_object is not None: armature_data = typing.cast(Armature, armature_object.data) From c2d7eecb4f85ba25f95e1e0a974df34b47cefb2f Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 6 Feb 2024 13:26:48 -0800 Subject: [PATCH 30/48] Minor naming changes and refactoring --- io_scene_psk_psa/psk/builder.py | 21 ++++++++++++--------- io_scene_psk_psa/psk/data.py | 10 +++++----- io_scene_psk_psa/psk/importer.py | 16 ++++++++-------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 95e4b2d..081be45 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -1,5 +1,8 @@ +from typing import Optional + import bmesh import bpy +import numpy as np from bpy.types import Armature from .data import * @@ -9,7 +12,7 @@ from ..helpers import * class PskInputObjects(object): def __init__(self): self.mesh_objects = [] - self.armature_object = None + self.armature_object: Optional[Object] = None class PskBuildOptions(object): @@ -61,7 +64,7 @@ def get_psk_input_objects(context) -> PskInputObjects: class PskBuildResult(object): def __init__(self): self.psk = None - self.warnings = [] + self.warnings: List[str] = [] def build_psk(context, options: PskBuildOptions) -> PskBuildResult: @@ -217,11 +220,11 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: # Build a list of non-unique wedges. wedges = [] for loop_index, loop in enumerate(mesh_data.loops): - wedge = Psk.Wedge() - wedge.point_index = loop.vertex_index + vertex_offset - wedge.u, wedge.v = uv_layer[loop_index].uv - wedge.v = 1.0 - wedge.v - wedges.append(wedge) + wedges.append(Psk.Wedge( + point_index=loop.vertex_index + vertex_offset, + u=uv_layer[loop_index].uv[0], + v=1.0 - uv_layer[loop_index].uv[1] + )) # Assign material indices to the wedges. for triangle in mesh_data.loop_triangles: @@ -229,8 +232,8 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: wedges[loop_index].material_index = material_indices[triangle.material_index] # Populate the list of wedges with unique wedges & build a look-up table of loop indices to wedge indices - wedge_indices = {} - loop_wedge_indices = [-1] * len(mesh_data.loops) + wedge_indices = dict() + loop_wedge_indices = np.full(len(mesh_data.loops), -1) for loop_index, wedge in enumerate(wedges): wedge_hash = hash(wedge) if wedge_hash in wedge_indices: diff --git a/io_scene_psk_psa/psk/data.py b/io_scene_psk_psa/psk/data.py index 8df58fa..03bd49a 100644 --- a/io_scene_psk_psa/psk/data.py +++ b/io_scene_psk_psa/psk/data.py @@ -5,11 +5,11 @@ from ..data import * class Psk(object): class Wedge(object): - def __init__(self): - self.point_index: int = 0 - self.u: float = 0.0 - self.v: float = 0.0 - self.material_index: int = 0 + def __init__(self, point_index: int, u: float, v: float, material_index: int = 0): + self.point_index: int = point_index + self.u: float = u + self.v: float = v + self.material_index = material_index def __hash__(self): return hash(f'{self.point_index}-{self.u}-{self.v}-{self.material_index}') diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 8fe4da5..c00ff07 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -166,38 +166,38 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: bm.to_mesh(mesh_data) # TEXTURE COORDINATES - data_index = 0 + uv_layer_data_index = 0 uv_layer = mesh_data.uv_layers.new(name='VTXW0000') for face_index, face in enumerate(psk.faces): if face_index in invalid_face_indices: continue face_wedges = [psk.wedges[i] for i in reversed(face.wedge_indices)] for wedge in face_wedges: - uv_layer.data[data_index].uv = wedge.u, 1.0 - wedge.v - data_index += 1 + uv_layer.data[uv_layer_data_index].uv = wedge.u, 1.0 - wedge.v + uv_layer_data_index += 1 # EXTRA UVS if psk.has_extra_uvs and options.should_import_extra_uvs: extra_uv_channel_count = int(len(psk.extra_uvs) / len(psk.wedges)) wedge_index_offset = 0 for extra_uv_index in range(extra_uv_channel_count): - data_index = 0 + uv_layer_data_index = 0 uv_layer = mesh_data.uv_layers.new(name=f'EXTRAUV{extra_uv_index}') for face_index, face in enumerate(psk.faces): if face_index in invalid_face_indices: continue for wedge_index in reversed(face.wedge_indices): u, v = psk.extra_uvs[wedge_index_offset + wedge_index] - uv_layer.data[data_index].uv = u, 1.0 - v - data_index += 1 + uv_layer.data[uv_layer_data_index].uv = u, 1.0 - v + uv_layer_data_index += 1 wedge_index_offset += len(psk.wedges) # VERTEX COLORS if psk.has_vertex_colors and options.should_import_vertex_colors: # Convert vertex colors to sRGB if necessary. psk_vertex_colors = np.zeros((len(psk.vertex_colors), 4)) - for i in range(len(psk.vertex_colors)): - psk_vertex_colors[i,:] = psk.vertex_colors[i].normalized() + for vertex_color_index in range(len(psk.vertex_colors)): + psk_vertex_colors[vertex_color_index,:] = psk.vertex_colors[vertex_color_index].normalized() match options.vertex_color_space: case 'SRGBA': for i in range(psk_vertex_colors.shape[0]): From eb8cee697332a1e3f46bffb0f81eebeb5badb3a5 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 6 Feb 2024 13:27:16 -0800 Subject: [PATCH 31/48] Markers with empty names are now ignored as export options --- io_scene_psk_psa/psa/export/operators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index 115451f..a44f02c 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -47,7 +47,7 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature): if not is_action_for_armature(armature, action): continue - if not action.name.startswith('#'): + if action.name != '' and not action.name.startswith('#'): for (name, frame_start, frame_end) in get_sequences_from_action(action): item = pg.action_list.add() item.action = action @@ -60,7 +60,7 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature): # Pose markers are not guaranteed to be in frame-order, so make sure that they are. pose_markers = sorted(action.pose_markers, key=lambda x: x.frame) for pose_marker_index, pose_marker in enumerate(pose_markers): - if pose_marker.name.startswith('#'): + if pose_marker.name.strip() == '' or pose_marker.name.startswith('#'): continue for (name, frame_start, frame_end) in get_sequences_from_action_pose_marker(action, pose_markers, pose_marker, pose_marker_index): item = pg.action_list.add() @@ -78,7 +78,7 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature): for marker_name in marker_names: if marker_name not in sequence_frame_ranges: continue - if marker_name.startswith('#'): + if marker_name.strip() == '' or marker_name.startswith('#'): continue frame_start, frame_end = sequence_frame_ranges[marker_name] sequences = get_sequences_from_name_and_frame_range(marker_name, frame_start, frame_end) From 82eaddf1cb66e747c264b829f7d2857801dc78fc Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 12 Feb 2024 11:06:52 -0800 Subject: [PATCH 32/48] Increased max bone count to int32 limit --- io_scene_psk_psa/psk/writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psk/writer.py b/io_scene_psk_psa/psk/writer.py index eec3234..5dae319 100644 --- a/io_scene_psk_psa/psk/writer.py +++ b/io_scene_psk_psa/psk/writer.py @@ -6,7 +6,7 @@ from ..data import Section, Vector3 MAX_WEDGE_COUNT = 65536 MAX_POINT_COUNT = 4294967296 -MAX_BONE_COUNT = 256 +MAX_BONE_COUNT = 2147483647 MAX_MATERIAL_COUNT = 256 From f7bbe911ea5de7cdd528bc8cd261d1c6f361d72c Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 13 Feb 2024 00:19:12 -0800 Subject: [PATCH 33/48] Removed `use_auto_smooth`...again --- io_scene_psk_psa/psk/importer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index c00ff07..4f6551e 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -227,8 +227,6 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: for vertex_normal in psk.vertex_normals: normals.append(tuple(vertex_normal)) mesh_data.normals_split_custom_set_from_vertices(normals) - # TODO: This has been removed in 4.1! - mesh_data.use_auto_smooth = True else: mesh_data.shade_smooth() From 11bf205fe2aaf16ffed1acfcd1cc4fa770eba674 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 13 Feb 2024 14:03:04 -0800 Subject: [PATCH 34/48] Added PSA resampling on import + some fixes for 4.1 --- io_scene_psk_psa/psa/export/operators.py | 19 +++--- io_scene_psk_psa/psa/import_/operators.py | 24 ++------ io_scene_psk_psa/psa/importer.py | 74 +++++++++++++++++++---- io_scene_psk_psa/psk/import_/operators.py | 27 +++++---- 4 files changed, 92 insertions(+), 52 deletions(-) diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index a44f02c..0aa46bb 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -91,15 +91,16 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature): def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float: - if fps_source == 'SCENE': - return context.scene.render.fps - elif fps_source == 'CUSTOM': - return fps_custom - elif fps_source == 'ACTION_METADATA': - # Get the minimum value of action metadata FPS values. - return min([action.psa_export.fps for action in actions]) - else: - raise RuntimeError(f'Invalid FPS source "{fps_source}"') + match fps_source: + case 'SCENE': + return context.scene.render.fps + case 'CUSTOM': + return fps_custom + case 'ACTION_METADATA': + # Get the minimum value of action metadata FPS values. + return min([action.psa_export.fps for action in actions]) + case _: + raise RuntimeError(f'Invalid FPS source "{fps_source}"') def get_animation_data_object(context: Context) -> Object: diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index 7891a32..c588208 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -2,7 +2,7 @@ import os from pathlib import Path from bpy.props import StringProperty -from bpy.types import Operator, Event, Context +from bpy.types import Operator, Event, Context, FileHandler from bpy_extras.io_utils import ImportHelper from .properties import get_visible_sequences @@ -89,23 +89,6 @@ class PSA_OT_import_sequences_deselect_all(Operator): return {'FINISHED'} -class PSA_OT_import_select_file(Operator): - bl_idname = 'psa_import.select_file' - bl_label = 'Select' - bl_options = {'INTERNAL'} - bl_description = 'Select a PSA file from which to import animations' - filepath: StringProperty(subtype='FILE_PATH') - filter_glob: StringProperty(default='*.psa', options={'HIDDEN'}) - - def execute(self, context): - getattr(context.scene, 'psa_import').psa_file_path = self.filepath - return {'FINISHED'} - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} - - def load_psa_file(context, filepath: str): pg = context.scene.psa_import pg.sequence_list.clear() @@ -270,10 +253,13 @@ class PSA_OT_import(Operator, ImportHelper): col.prop(pg, 'action_name_prefix') +class PSA_FH_import(FileHandler): + + + classes = ( PSA_OT_import_sequences_select_all, PSA_OT_import_sequences_deselect_all, PSA_OT_import_sequences_from_text, PSA_OT_import, - PSA_OT_import_select_file, ) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index d9438f6..d2f5a18 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -1,8 +1,9 @@ +import math import typing from typing import List, Optional import bpy -import numpy +import numpy as np from bpy.types import FCurve, Object, Context from mathutils import Vector, Quaternion @@ -80,6 +81,50 @@ def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_name return None +def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, target_frame_count: int): + ''' + 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 target_frame_count: The number of frames to resample to. + @return: The resampled sequence data matrix, or sequence_data_matrix if no resampling is necessary. + ''' + def get_sample_times(source_frame_count: int, target_frame_count: int) -> typing.Iterable[float]: + time = 0.0 + time_step = source_frame_count / target_frame_count + while time <= target_frame_count - 1: + yield time + time += time_step + yield 1.0 + + source_frame_count, bone_count = sequence_data_matrix.shape[:1] + + if target_frame_count == source_frame_count: + # No resampling is necessary. + return sequence_data_matrix + + resampled_sequence_data_matrix = np.zeros((target_frame_count, bone_count, 7), dtype=float) + sample_times = get_sample_times(source_frame_count, target_frame_count) + + for frame_index, sample_time in enumerate(sample_times): + if sample_time % 1.0 == 0.0: + # Sample time has no fractional part, so just copy the frame. + resampled_sequence_data_matrix[frame_index, :, :] = sequence_data_matrix[int(sample_time), :, :] + else: + # Sample time has a fractional part, so interpolate between two frames. + for bone_index in range(bone_count): + frame_index = int(sample_time) + source_frame_1_data = sequence_data_matrix[frame_index, bone_index, :] + source_frame_2_data = sequence_data_matrix[frame_index + 1, bone_index, :] + factor = sample_time - frame_index + q = Quaternion((source_frame_1_data[:4])).slerp(Quaternion((source_frame_2_data[:4])), factor) + q.normalize() + l = Vector(source_frame_1_data[4:]).lerp(Vector(source_frame_2_data[4:]), factor) + resampled_sequence_data_matrix[frame_index, bone_index, :] = q.w, q.x, q.y, q.z, l.x, l.y, l.z + + return resampled_sequence_data_matrix + + 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] @@ -186,12 +231,9 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, case _: raise ValueError(f'Unknown FPS source: {options.fps_source}') - keyframe_time_dilation = target_fps / sequence.fps - if options.should_write_keyframes: - # Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2) - while len(action.fcurves) > 0: - action.fcurves.remove(action.fcurves[-1]) + # Remove existing f-curves. + action.fcurves.clear() # Create f-curves for the rotation and location of each bone. for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items(): @@ -225,19 +267,27 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, # Calculate the local-space key data for the bone. sequence_data_matrix[frame_index, bone_index] = _calculate_fcurve_data(import_bone, key_data) - # Write the keyframes out. - fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float) + # Resample the sequence data to the target FPS. + # TODO: target frame count can be fractional. + target_frame_count = math.ceil(sequence.frame_count * (target_fps / sequence.fps)) + + # Write the keyframes out. + # Note that the f-curve data consists of alternating time and value data. + fcurve_data = np.zeros(2 * target_frame_count, dtype=float) + fcurve_data[0::2] = range(0, target_frame_count) + + # Resample the sequence data matrix to the target frame count. + # If the target frame count is the same as the source frame count, this will be a no-op. + resampled_sequence_data_matrix = _resample_sequence_data_matrix(sequence_data_matrix, target_frame_count) - # Populate the keyframe time data. - fcurve_data[0::2] = [x * keyframe_time_dilation for x in range(sequence.frame_count)] for bone_index, import_bone in enumerate(import_bones): if import_bone is None: continue for fcurve_index, fcurve in enumerate(import_bone.fcurves): if fcurve is None: continue - fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index] - fcurve.keyframe_points.add(sequence.frame_count) + fcurve_data[1::2] = resampled_sequence_data_matrix[:, bone_index, fcurve_index] + fcurve.keyframe_points.add(target_frame_count) fcurve.keyframe_points.foreach_set('co', fcurve_data) for fcurve_keyframe in fcurve.keyframe_points: fcurve_keyframe.interpolation = 'LINEAR' diff --git a/io_scene_psk_psa/psk/import_/operators.py b/io_scene_psk_psa/psk/import_/operators.py index 741775b..3cebfa4 100644 --- a/io_scene_psk_psa/psk/import_/operators.py +++ b/io_scene_psk_psa/psk/import_/operators.py @@ -11,15 +11,15 @@ from ..reader import read_psk empty_set = set() -class PSX_FH_psk(FileHandler): - bl_idname = 'PSX_FH_psk' - bl_label = 'Unreal PSK/PSKX' +class PSK_FH_import(FileHandler): + bl_idname = 'PSK_FH_import' + bl_label = 'File handler for Unreal PSK/PSKX import' bl_import_operator = 'import_scene.psk' - bl_file_extensions = '.psk;.pskx' + bl_file_extensions = '.psk' @classmethod def poll_drop(cls, context: Context): - return context.area.type == 'VIEW_3D' + return context.area and context.area.type == 'VIEW_3D' class PSK_OT_import(Operator, ImportHelper): @@ -143,10 +143,11 @@ class PSK_OT_import(Operator, ImportHelper): col.use_property_decorate = False col.prop(self, 'scale') - layout.prop(self, 'should_import_mesh') + mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False) + mesh_header.prop(self, 'should_import_mesh') - if self.should_import_mesh: - row = layout.row() + if mesh_panel and self.should_import_mesh: + row = mesh_panel.row() col = row.column() col.use_property_split = True col.use_property_decorate = False @@ -158,9 +159,11 @@ class PSK_OT_import(Operator, ImportHelper): col.prop(self, 'vertex_color_space') col.prop(self, 'should_import_shape_keys', text='Shape Keys') - layout.prop(self, 'should_import_skeleton') - if self.should_import_skeleton: - row = layout.row() + skeleton_header, skeleton_panel = layout.panel('skeleton_panel_id', default_closed=False) + skeleton_header.prop(self, 'should_import_skeleton') + + if skeleton_panel and self.should_import_skeleton: + row = skeleton_panel.row() col = row.column() col.use_property_split = True col.use_property_decorate = False @@ -169,5 +172,5 @@ class PSK_OT_import(Operator, ImportHelper): classes = ( PSK_OT_import, - PSX_FH_psk, + PSK_FH_import, ) From ce1a4112005324caf333755b2a23bacc120f1731 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 17 Feb 2024 23:01:42 -0800 Subject: [PATCH 35/48] Fix for issue #76 --- io_scene_psk_psa/psa/export/operators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index a44f02c..aa5d88d 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -110,7 +110,7 @@ def get_animation_data_object(context: Context) -> Object: if active_object.type != 'ARMATURE': raise RuntimeError('Selected object must be an Armature') - if pg.should_override_animation_data: + if pg.sequence_source != 'ACTIONS' and pg.should_override_animation_data: animation_data_object = pg.animation_data_override else: animation_data_object = active_object From ead1e3c793e317d5be1509805cf5be4a1fe2260a Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Wed, 28 Feb 2024 00:51:33 -0800 Subject: [PATCH 36/48] Initial commit of UT99 poly flags --- io_scene_psk_psa/__init__.py | 8 +++++ io_scene_psk_psa/psk/builder.py | 17 +++++----- io_scene_psk_psa/psk/export/operators.py | 14 ++++----- io_scene_psk_psa/psk/export/properties.py | 6 ++-- io_scene_psk_psa/psk/export/ui.py | 2 +- io_scene_psk_psa/psk/properties.py | 38 +++++++++++++++++++++++ io_scene_psk_psa/psk/ui.py | 28 +++++++++++++++++ io_scene_psk_psa/types.py | 2 +- 8 files changed, 96 insertions(+), 19 deletions(-) create mode 100644 io_scene_psk_psa/psk/properties.py create mode 100644 io_scene_psk_psa/psk/ui.py diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 7570fe9..4076dd9 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -24,6 +24,8 @@ if 'bpy' in locals(): importlib.reload(psk_writer) importlib.reload(psk_builder) importlib.reload(psk_importer) + importlib.reload(psk_properties) + importlib.reload(psk_ui) importlib.reload(psk_export_properties) importlib.reload(psk_export_operators) importlib.reload(psk_export_ui) @@ -50,6 +52,8 @@ else: from .psk import writer as psk_writer from .psk import builder as psk_builder from .psk import importer as psk_importer + from .psk import properties as psk_properties + from .psk import ui as psk_ui from .psk.export import properties as psk_export_properties from .psk.export import operators as psk_export_operators from .psk.export import ui as psk_export_ui @@ -72,6 +76,8 @@ import bpy from bpy.props import PointerProperty classes = psx_types.classes +\ + psk_properties.classes +\ + psk_ui.classes +\ psk_import_operators.classes +\ psk_export_properties.classes +\ psk_export_operators.classes +\ @@ -107,6 +113,7 @@ def register(): bpy.types.TOPBAR_MT_file_import.append(psk_import_menu_func) bpy.types.TOPBAR_MT_file_export.append(psa_export_menu_func) bpy.types.TOPBAR_MT_file_import.append(psa_import_menu_func) + bpy.types.Material.psk = PointerProperty(type=psk_properties.PSX_PG_material) bpy.types.Scene.psa_import = PointerProperty(type=psa_import_properties.PSA_PG_import) bpy.types.Scene.psa_export = PointerProperty(type=psa_export_properties.PSA_PG_export) bpy.types.Scene.psk_export = PointerProperty(type=psk_export_properties.PSK_PG_export) @@ -114,6 +121,7 @@ def register(): def unregister(): + del bpy.types.Material.psk del bpy.types.Scene.psa_import del bpy.types.Scene.psa_export del bpy.types.Scene.psk_export diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 081be45..2ba6c34 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -3,9 +3,10 @@ from typing import Optional import bmesh import bpy import numpy as np -from bpy.types import Armature +from bpy.types import Armature, Material from .data import * +from .properties import get_poly_flags from ..helpers import * @@ -20,7 +21,7 @@ class PskBuildOptions(object): self.bone_filter_mode = 'ALL' self.bone_collection_indices: List[int] = [] self.use_raw_mesh_data = True - self.material_names: List[str] = [] + self.materials: List[Material] = [] self.should_enforce_bone_name_restrictions = False @@ -138,19 +139,21 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: psk.bones.append(psk_bone) # MATERIALS - material_names = options.material_names - - for material_name in material_names: + for material in options.materials: psk_material = Psk.Material() try: - psk_material.name = bytes(material_name, encoding='windows-1252') + psk_material.name = bytes(material.name, encoding='windows-1252') except UnicodeEncodeError: - raise RuntimeError(f'Material name "{material_name}" contains characters that cannot be encoded in the Windows-1252 codepage') + raise RuntimeError(f'Material name "{material.name}" contains characters that cannot be encoded in the Windows-1252 codepage') psk_material.texture_index = len(psk.materials) + psk_material.poly_flags = get_poly_flags(material.psk) + print(psk_material.name, psk_material.poly_flags) psk.materials.append(psk_material) context.window_manager.progress_begin(0, len(input_objects.mesh_objects)) + material_names = [m.name for m in options.materials] + for object_index, input_mesh_object in enumerate(input_objects.mesh_objects): should_flip_normals = False diff --git a/io_scene_psk_psa/psk/export/operators.py b/io_scene_psk_psa/psk/export/operators.py index a902b27..98d8f4f 100644 --- a/io_scene_psk_psa/psk/export/operators.py +++ b/io_scene_psk_psa/psk/export/operators.py @@ -20,19 +20,19 @@ def is_bone_filter_mode_item_available(context, identifier): def populate_material_list(mesh_objects, material_list): material_list.clear() - material_names = [] + materials = [] for mesh_object in mesh_objects: for i, material_slot in enumerate(mesh_object.material_slots): material = material_slot.material # TODO: put this in the poll arg? if material is None: raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')') - if material.name not in material_names: - material_names.append(material.name) + if material not in materials: + materials.append(material) - for index, material_name in enumerate(material_names): + for index, material in enumerate(materials): m = material_list.add() - m.material_name = material_name + m.material = material m.index = index @@ -159,9 +159,9 @@ class PSK_OT_export(Operator, ExportHelper): options.bone_filter_mode = pg.bone_filter_mode options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected] options.use_raw_mesh_data = pg.use_raw_mesh_data - options.material_names = [m.material_name for m in pg.material_list] + options.materials = [m.material for m in pg.material_list] options.should_enforce_bone_name_restrictions = pg.should_enforce_bone_name_restrictions - + try: result = build_psk(context, options) for warning in result.warnings: diff --git a/io_scene_psk_psa/psk/export/properties.py b/io_scene_psk_psa/psk/export/properties.py index 8382d01..bdb6493 100644 --- a/io_scene_psk_psa/psk/export/properties.py +++ b/io_scene_psk_psa/psk/export/properties.py @@ -1,11 +1,11 @@ -from bpy.props import EnumProperty, CollectionProperty, IntProperty, BoolProperty, StringProperty -from bpy.types import PropertyGroup +from bpy.props import EnumProperty, CollectionProperty, IntProperty, BoolProperty, PointerProperty +from bpy.types import PropertyGroup, Material from ...types import PSX_PG_bone_collection_list_item class PSK_PG_material_list_item(PropertyGroup): - material_name: StringProperty() + material: PointerProperty(type=Material) index: IntProperty() diff --git a/io_scene_psk_psa/psk/export/ui.py b/io_scene_psk_psa/psk/export/ui.py index 6a7a056..4fa55af 100644 --- a/io_scene_psk_psa/psk/export/ui.py +++ b/io_scene_psk_psa/psk/export/ui.py @@ -4,7 +4,7 @@ from bpy.types import UIList class PSK_UL_materials(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): row = layout.row() - row.label(text=str(getattr(item, 'material_name')), icon='MATERIAL') + row.prop(item.material, 'name', text='', emboss=False, icon_value=layout.icon(item.material)) classes = ( diff --git a/io_scene_psk_psa/psk/properties.py b/io_scene_psk_psa/psk/properties.py new file mode 100644 index 0000000..2719336 --- /dev/null +++ b/io_scene_psk_psa/psk/properties.py @@ -0,0 +1,38 @@ +from bpy.props import EnumProperty +from bpy.types import PropertyGroup + +mesh_triangle_types_items = ( + ('MTT_Normal', 'Normal', 'Normal one-sided', 0), + ('MTT_NormalTwoSided', 'Normal Two-Sided', 'Normal but two-sided', 1), + ('MTT_Translucent', 'Translucent', 'Translucent two-sided', 2), + ('MTT_Masked', 'Masked', 'Masked two-sided', 3), + ('MTT_Modulate', 'Modulate', 'Modulation blended two-sided', 4), + ('MTT_Placeholder', 'Placeholder', 'Placeholder triangle for positioning weapon. Invisible', 8), +) + +mesh_triangle_bit_flags_items = ( + ('MTT_Unlit', 'Unlit', 'Full brightness, no lighting', 16), + ('MTT_Flat', 'Flat', 'Flat surface, don\'t do bMeshCurvy thing', 32), + ('MTT_Environment', 'Environment', 'Environment mapped', 64), + ('MTT_NoSmooth', 'No Smooth', 'No bilinear filtering on this poly\'s texture', 128), +) + +class PSX_PG_material(PropertyGroup): + mesh_triangle_type: EnumProperty(items=mesh_triangle_types_items, name='Triangle Type') + mesh_triangle_bit_flags: EnumProperty(items=mesh_triangle_bit_flags_items, name='Triangle Bit Flags', + options={'ENUM_FLAG'}) + +mesh_triangle_types_items_dict = {item[0]: item[3] for item in mesh_triangle_types_items} +mesh_triangle_bit_flags_items_dict = {item[0]: item[3] for item in mesh_triangle_bit_flags_items} + + +def get_poly_flags(material: PSX_PG_material) -> int: + poly_flags = 0 + poly_flags |= mesh_triangle_types_items_dict[material.mesh_triangle_type] + for flag in material.mesh_triangle_bit_flags: + poly_flags |= mesh_triangle_bit_flags_items_dict[flag] + return poly_flags + +classes = ( + PSX_PG_material, +) diff --git a/io_scene_psk_psa/psk/ui.py b/io_scene_psk_psa/psk/ui.py new file mode 100644 index 0000000..a3577b0 --- /dev/null +++ b/io_scene_psk_psa/psk/ui.py @@ -0,0 +1,28 @@ +from bpy.types import Panel + + +class PSK_PT_material(Panel): + bl_label = 'PSK Material' + bl_idname = 'PSK_PT_material' + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_context = 'material' + bl_options = {'DEFAULT_CLOSED'} + + @classmethod + def poll(cls, context): + return context.material is not None + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False + material = context.material + layout.prop(material.psk, 'mesh_triangle_type') + col = layout.column() + col.prop(material.psk, 'mesh_triangle_bit_flags', expand=True, text='Flags') + + +classes = ( + PSK_PT_material, +) diff --git a/io_scene_psk_psa/types.py b/io_scene_psk_psa/types.py index ae3e9a9..4f13902 100644 --- a/io_scene_psk_psa/types.py +++ b/io_scene_psk_psa/types.py @@ -51,5 +51,5 @@ classes = ( PSX_PG_action_export, PSX_PG_bone_collection_list_item, PSX_UL_bone_collection_list, - PSX_PT_action + PSX_PT_action, ) From 14116963bbf453cb66f9a5e6cc00c2dc33288918 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Wed, 28 Feb 2024 23:21:31 -0800 Subject: [PATCH 37/48] Incremented version to 6.2.0 --- io_scene_psk_psa/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 4076dd9..a5f5c6a 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -3,7 +3,7 @@ from bpy.app.handlers import persistent bl_info = { 'name': 'PSK/PSA Importer/Exporter', 'author': 'Colin Basnett, Yurii Ti', - 'version': (6, 1, 2), + 'version': (6, 2, 0), 'blender': (4, 0, 0), 'description': 'PSK/PSA Import/Export (.psk/.psa)', 'warning': '', From 15e2c6ccdded3fbd2c506754423ef0f1ecf23efc Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 29 Feb 2024 00:32:42 -0800 Subject: [PATCH 38/48] Importing PSKs with poly flags now works --- io_scene_psk_psa/psk/builder.py | 6 ++--- io_scene_psk_psa/psk/importer.py | 4 ++++ io_scene_psk_psa/psk/properties.py | 38 +++++++++++++++++++----------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 2ba6c34..4ab52d5 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -6,7 +6,7 @@ import numpy as np from bpy.types import Armature, Material from .data import * -from .properties import get_poly_flags +from .properties import triangle_type_and_bit_flags_to_poly_flags from ..helpers import * @@ -146,8 +146,8 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: except UnicodeEncodeError: raise RuntimeError(f'Material name "{material.name}" contains characters that cannot be encoded in the Windows-1252 codepage') psk_material.texture_index = len(psk.materials) - psk_material.poly_flags = get_poly_flags(material.psk) - print(psk_material.name, psk_material.poly_flags) + psk_material.poly_flags = triangle_type_and_bit_flags_to_poly_flags(material.psk.mesh_triangle_type, + material.psk.mesh_triangle_bit_flags) psk.materials.append(psk_material) context.window_manager.progress_begin(0, len(input_objects.mesh_objects)) diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index c00ff07..e9e6e50 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -7,6 +7,7 @@ from bpy.types import VertexGroup from mathutils import Quaternion, Vector, Matrix from .data import Psk +from .properties import poly_flags_to_triangle_type_and_bit_flags from ..helpers import rgb_to_srgb, is_bdk_addon_loaded @@ -134,6 +135,9 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult: else: # Just create a blank material. material = bpy.data.materials.new(material_name) + mesh_triangle_type, mesh_triangle_bit_flags = poly_flags_to_triangle_type_and_bit_flags(psk_material.poly_flags) + material.psk.mesh_triangle_type = mesh_triangle_type + material.psk.mesh_triangle_bit_flags = mesh_triangle_bit_flags material.use_nodes = True mesh_data.materials.append(material) diff --git a/io_scene_psk_psa/psk/properties.py b/io_scene_psk_psa/psk/properties.py index 2719336..917f390 100644 --- a/io_scene_psk_psa/psk/properties.py +++ b/io_scene_psk_psa/psk/properties.py @@ -2,19 +2,19 @@ from bpy.props import EnumProperty from bpy.types import PropertyGroup mesh_triangle_types_items = ( - ('MTT_Normal', 'Normal', 'Normal one-sided', 0), - ('MTT_NormalTwoSided', 'Normal Two-Sided', 'Normal but two-sided', 1), - ('MTT_Translucent', 'Translucent', 'Translucent two-sided', 2), - ('MTT_Masked', 'Masked', 'Masked two-sided', 3), - ('MTT_Modulate', 'Modulate', 'Modulation blended two-sided', 4), - ('MTT_Placeholder', 'Placeholder', 'Placeholder triangle for positioning weapon. Invisible', 8), + ('NORMAL', 'Normal', 'Normal one-sided', 0), + ('NORMAL_TWO_SIDED', 'Normal Two-Sided', 'Normal but two-sided', 1), + ('TRANSLUCENT', 'Translucent', 'Translucent two-sided', 2), + ('MASKED', 'Masked', 'Masked two-sided', 3), + ('MODULATE', 'Modulate', 'Modulation blended two-sided', 4), + ('PLACEHOLDER', 'Placeholder', 'Placeholder triangle for positioning weapon. Invisible', 8), ) mesh_triangle_bit_flags_items = ( - ('MTT_Unlit', 'Unlit', 'Full brightness, no lighting', 16), - ('MTT_Flat', 'Flat', 'Flat surface, don\'t do bMeshCurvy thing', 32), - ('MTT_Environment', 'Environment', 'Environment mapped', 64), - ('MTT_NoSmooth', 'No Smooth', 'No bilinear filtering on this poly\'s texture', 128), + ('UNLIT', 'Unlit', 'Full brightness, no lighting', 16), + ('FLAT', 'Flat', 'Flat surface, don\'t do bMeshCurvy thing', 32), + ('ENVIRONMENT', 'Environment', 'Environment mapped', 64), + ('NO_SMOOTH', 'No Smooth', 'No bilinear filtering on this poly\'s texture', 128), ) class PSX_PG_material(PropertyGroup): @@ -26,13 +26,23 @@ mesh_triangle_types_items_dict = {item[0]: item[3] for item in mesh_triangle_typ mesh_triangle_bit_flags_items_dict = {item[0]: item[3] for item in mesh_triangle_bit_flags_items} -def get_poly_flags(material: PSX_PG_material) -> int: +def triangle_type_and_bit_flags_to_poly_flags(mesh_triangle_type: str, mesh_triangle_bit_flags: set[str]) -> int: poly_flags = 0 - poly_flags |= mesh_triangle_types_items_dict[material.mesh_triangle_type] - for flag in material.mesh_triangle_bit_flags: - poly_flags |= mesh_triangle_bit_flags_items_dict[flag] + poly_flags |= mesh_triangle_types_items_dict.get(mesh_triangle_type, 0) + for flag in mesh_triangle_bit_flags: + poly_flags |= mesh_triangle_bit_flags_items_dict.get(flag, 0) return poly_flags + +def poly_flags_to_triangle_type_and_bit_flags(poly_flags: int) -> (str, set[str]): + try: + triangle_type = next(item[0] for item in mesh_triangle_types_items if item[3] == (poly_flags & 15)) + except StopIteration: + triangle_type = 'NORMAL' + triangle_bit_flags = {item[0] for item in mesh_triangle_bit_flags_items if item[3] & poly_flags} + return triangle_type, triangle_bit_flags + + classes = ( PSX_PG_material, ) From 5d3c7cc5706a2406e52e7d451fae84646efee967 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 29 Feb 2024 16:03:47 -0800 Subject: [PATCH 39/48] Fixed PSA import resampling logic --- io_scene_psk_psa/__init__.py | 1 + io_scene_psk_psa/psa/importer.py | 41 ++++++++++++++++---------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 7570fe9..4b2589f 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -34,6 +34,7 @@ if 'bpy' in locals(): importlib.reload(psa_reader) importlib.reload(psa_writer) importlib.reload(psa_builder) + importlib.reload(psa_importer) importlib.reload(psa_export_properties) importlib.reload(psa_export_operators) importlib.reload(psa_export_ui) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index d2f5a18..aad193f 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -1,4 +1,3 @@ -import math import typing from typing import List, Optional @@ -81,7 +80,7 @@ def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_name return None -def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, target_frame_count: int): +def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, time_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 @@ -89,38 +88,40 @@ def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, target_fram @param target_frame_count: The number of frames to resample to. @return: The resampled sequence data matrix, or sequence_data_matrix if no resampling is necessary. ''' - def get_sample_times(source_frame_count: int, target_frame_count: int) -> typing.Iterable[float]: + def get_sample_times(source_frame_count: int, time_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 - time_step = source_frame_count / target_frame_count - while time <= target_frame_count - 1: + while time < source_frame_count - 1: yield time time += time_step - yield 1.0 + yield source_frame_count - 1 - source_frame_count, bone_count = sequence_data_matrix.shape[:1] - - if target_frame_count == source_frame_count: + if time_step == 1.0: # No resampling is necessary. return sequence_data_matrix + source_frame_count, bone_count = sequence_data_matrix.shape[:2] + sample_times = list(get_sample_times(source_frame_count, time_step)) + target_frame_count = len(sample_times) resampled_sequence_data_matrix = np.zeros((target_frame_count, bone_count, 7), dtype=float) - sample_times = get_sample_times(source_frame_count, target_frame_count) - for frame_index, sample_time in enumerate(sample_times): + for sample_index, sample_time in enumerate(sample_times): + frame_index = int(sample_time) if sample_time % 1.0 == 0.0: # Sample time has no fractional part, so just copy the frame. - resampled_sequence_data_matrix[frame_index, :, :] = sequence_data_matrix[int(sample_time), :, :] + resampled_sequence_data_matrix[sample_index, :, :] = sequence_data_matrix[frame_index, :, :] else: # Sample time has a fractional part, so interpolate between two frames. + next_frame_index = frame_index + 1 for bone_index in range(bone_count): - frame_index = int(sample_time) source_frame_1_data = sequence_data_matrix[frame_index, bone_index, :] - source_frame_2_data = sequence_data_matrix[frame_index + 1, bone_index, :] + source_frame_2_data = sequence_data_matrix[next_frame_index, bone_index, :] factor = sample_time - frame_index q = Quaternion((source_frame_1_data[:4])).slerp(Quaternion((source_frame_2_data[:4])), factor) q.normalize() l = Vector(source_frame_1_data[4:]).lerp(Vector(source_frame_2_data[4:]), factor) - resampled_sequence_data_matrix[frame_index, bone_index, :] = q.w, q.x, q.y, q.z, l.x, l.y, l.z + resampled_sequence_data_matrix[sample_index, bone_index, :] = q.w, q.x, q.y, q.z, l.x, l.y, l.z return resampled_sequence_data_matrix @@ -268,18 +269,16 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, sequence_data_matrix[frame_index, bone_index] = _calculate_fcurve_data(import_bone, key_data) # Resample the sequence data to the target FPS. - # TODO: target frame count can be fractional. - target_frame_count = math.ceil(sequence.frame_count * (target_fps / sequence.fps)) + # If the target frame count is the same as the source frame count, this will be a no-op. + resampled_sequence_data_matrix = _resample_sequence_data_matrix(sequence_data_matrix, + time_step=sequence.fps / target_fps) # Write the keyframes out. # Note that the f-curve data consists of alternating time and value data. + target_frame_count = resampled_sequence_data_matrix.shape[0] fcurve_data = np.zeros(2 * target_frame_count, dtype=float) fcurve_data[0::2] = range(0, target_frame_count) - # Resample the sequence data matrix to the target frame count. - # If the target frame count is the same as the source frame count, this will be a no-op. - resampled_sequence_data_matrix = _resample_sequence_data_matrix(sequence_data_matrix, target_frame_count) - for bone_index, import_bone in enumerate(import_bones): if import_bone is None: continue From 4d41f1af834d2f49953419379ad759b3ef9345eb Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Fri, 1 Mar 2024 15:14:37 -0800 Subject: [PATCH 40/48] When exporting PSKs, armatures with no bones are now more sensibly handled umodel, for some reason, exports some models with no bones. For compatibility and convenience, an armature with no bones may as well not exist, so we treat it as though it doesn't on export, and a single fake root bone is added for maximum compatibility. --- io_scene_psk_psa/psk/builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 4ab52d5..05d1654 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -76,9 +76,9 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult: psk = Psk() bones = [] - if armature_object is None: - # If the mesh has no armature object, simply assign it a dummy bone at the root to satisfy the requirement - # that a PSK file must have at least one bone. + if armature_object is None or len(armature_object.data.bones) == 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. psk_bone = Psk.Bone() psk_bone.name = bytes('root', encoding='windows-1252') psk_bone.flags = 0 From d81477673bf29959aa7d07d6635805e7cddec104 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 2 Mar 2024 13:15:48 -0800 Subject: [PATCH 41/48] Fixed a script reload issue --- io_scene_psk_psa/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index a5f5c6a..51a7a51 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -36,6 +36,7 @@ if 'bpy' in locals(): importlib.reload(psa_reader) importlib.reload(psa_writer) importlib.reload(psa_builder) + importlib.reload(psa_importer) importlib.reload(psa_export_properties) importlib.reload(psa_export_operators) importlib.reload(psa_export_ui) From 226e403925875dc6012332473662240a1e097d68 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 11 Mar 2024 18:46:18 -0700 Subject: [PATCH 42/48] Fix for syntax error --- io_scene_psk_psa/psa/import_/operators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index c588208..71790c5 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -254,7 +254,7 @@ class PSA_OT_import(Operator, ImportHelper): class PSA_FH_import(FileHandler): - + pass classes = ( From e27b0788666323e3a7133a28c550a3eea86492b6 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 14 Mar 2024 18:53:53 -0700 Subject: [PATCH 43/48] Now handling PSKX files in the PSK file handler --- io_scene_psk_psa/psk/import_/operators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psk/import_/operators.py b/io_scene_psk_psa/psk/import_/operators.py index 3cebfa4..8d9dad2 100644 --- a/io_scene_psk_psa/psk/import_/operators.py +++ b/io_scene_psk_psa/psk/import_/operators.py @@ -15,7 +15,7 @@ class PSK_FH_import(FileHandler): bl_idname = 'PSK_FH_import' bl_label = 'File handler for Unreal PSK/PSKX import' bl_import_operator = 'import_scene.psk' - bl_file_extensions = '.psk' + bl_file_extensions = '.psk;.pskx' @classmethod def poll_drop(cls, context: Context): From 6b8088225a1d83339e05c6699d9249191e85a66d Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 14 Mar 2024 18:55:28 -0700 Subject: [PATCH 44/48] Fix for root bone being incorrectly oriented if it wasn't at the identity rotation in the bind pose --- io_scene_psk_psa/psa/importer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index aad193f..1a7ea18 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -199,7 +199,8 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, import_bone.original_rotation.conjugate() else: import_bone.original_location = armature_bone.matrix_local.translation.copy() - import_bone.original_rotation = armature_bone.matrix_local.to_quaternion() + import_bone.original_rotation = armature_bone.matrix_local.to_quaternion().conjugated() + import_bone.post_rotation = import_bone.original_rotation.conjugated() context.window_manager.progress_begin(0, len(sequences)) From c65fdaa6a437faafa5150e60431b4b41b0ef4477 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 14 Mar 2024 19:04:12 -0700 Subject: [PATCH 45/48] Fixing PEP warnings --- io_scene_psk_psa/psa/config.py | 6 +++--- io_scene_psk_psa/psa/export/ui.py | 2 -- io_scene_psk_psa/psa/importer.py | 4 ++-- io_scene_psk_psa/psa/reader.py | 12 ++++++------ io_scene_psk_psa/psk/export/properties.py | 3 ++- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/io_scene_psk_psa/psa/config.py b/io_scene_psk_psa/psa/config.py index 3016c59..66864e4 100644 --- a/io_scene_psk_psa/psa/config.py +++ b/io_scene_psk_psa/psa/config.py @@ -14,7 +14,7 @@ class PsaConfig: def _load_config_file(file_path: str) -> ConfigParser: - ''' + """ UEViewer exports a dialect of INI files that is not compatible with Python's ConfigParser. Specifically, it allows values in this format: @@ -24,7 +24,7 @@ def _load_config_file(file_path: str) -> ConfigParser: This is not allowed in Python's ConfigParser, which requires a '=' character after each key name. To work around this, we'll modify the file to add the '=' character after each key name if it is missing. - ''' + """ with open(file_path, 'r') as f: lines = f.read().split('\n') @@ -41,7 +41,7 @@ def _load_config_file(file_path: str) -> ConfigParser: def _get_bone_flags_from_value(value: str) -> int: match value: case 'all': - return (REMOVE_TRACK_LOCATION | REMOVE_TRACK_ROTATION) + return REMOVE_TRACK_LOCATION | REMOVE_TRACK_ROTATION case 'trans': return REMOVE_TRACK_LOCATION case 'rot': diff --git a/io_scene_psk_psa/psa/export/ui.py b/io_scene_psk_psa/psa/export/ui.py index 203b530..1c92337 100644 --- a/io_scene_psk_psa/psa/export/ui.py +++ b/io_scene_psk_psa/psa/export/ui.py @@ -32,7 +32,6 @@ class PSA_UL_export_sequences(UIList): subrow = row.row(align=True) subrow.prop(pg, 'sequence_filter_name', text='') subrow.prop(pg, 'sequence_use_filter_invert', text='', icon='ARROW_LEFTRIGHT') - # subrow.prop(pg, 'sequence_use_filter_sort_reverse', text='', icon='SORT_ASC') if pg.sequence_source == 'ACTIONS': subrow = row.row(align=True) @@ -44,7 +43,6 @@ class PSA_UL_export_sequences(UIList): pg = getattr(context.scene, 'psa_export') actions = getattr(data, prop) flt_flags = filter_sequences(pg, actions) - # flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name') flt_neworder = list(range(len(actions))) return flt_flags, flt_neworder diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 1a7ea18..cedef80 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -64,12 +64,12 @@ class PsaImportResult: def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_names: List[str], bone_mapping_mode: str = 'EXACT') -> Optional[int]: - ''' + """ @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'. @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): if bone_mapping_mode == 'CASE_INSENSITIVE': if armature_bone_name.lower() == psa_bone_name.lower(): diff --git a/io_scene_psk_psa/psa/reader.py b/io_scene_psk_psa/psa/reader.py index 08060b9..bd12dc7 100644 --- a/io_scene_psk_psa/psa/reader.py +++ b/io_scene_psk_psa/psa/reader.py @@ -23,11 +23,11 @@ def _try_fix_cue4parse_issue_103(sequences) -> bool: class PsaReader(object): - ''' + """ This class reads the sequences and bone information immediately upon instantiation and holds onto a file handle. The keyframe data is not read into memory upon instantiation due to its potentially very large size. To read the key data for a particular sequence, call :read_sequence_keys. - ''' + """ def __init__(self, path): self.keys_data_offset: int = 0 @@ -43,11 +43,11 @@ class PsaReader(object): return self.psa.sequences 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. - ''' + """ sequence = self.psa.sequences[sequence_name] keys = self.read_sequence_keys(sequence_name) bone_count = len(self.bones) @@ -60,12 +60,12 @@ class PsaReader(object): return matrix def read_sequence_keys(self, sequence_name: str) -> List[Psa.Key]: - ''' + """ Reads and returns the key data for a sequence. @param sequence_name: The name of the sequence. @return: A list of Psa.Keys. - ''' + """ # Set the file reader to the beginning of the keys data sequence = self.psa.sequences[sequence_name] data_size = sizeof(Psa.Key) diff --git a/io_scene_psk_psa/psk/export/properties.py b/io_scene_psk_psa/psk/export/properties.py index bdb6493..536ca62 100644 --- a/io_scene_psk_psa/psk/export/properties.py +++ b/io_scene_psk_psa/psk/export/properties.py @@ -3,6 +3,7 @@ from bpy.types import PropertyGroup, Material from ...types import PSX_PG_bone_collection_list_item +empty_set = set() class PSK_PG_material_list_item(PropertyGroup): material: PointerProperty(type=Material) @@ -12,7 +13,7 @@ class PSK_PG_material_list_item(PropertyGroup): class PSK_PG_export(PropertyGroup): bone_filter_mode: EnumProperty( name='Bone Filter', - options=set(), + options=empty_set, description='', items=( ('ALL', 'All', 'All bones will be exported'), From a93450eab92c8dab221f14dec6c9f0113aac0ca2 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 14 Mar 2024 19:06:03 -0700 Subject: [PATCH 46/48] Added PSA file handler --- io_scene_psk_psa/psa/import_/operators.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index 71790c5..dbdff50 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -254,7 +254,14 @@ class PSA_OT_import(Operator, ImportHelper): class PSA_FH_import(FileHandler): - pass + bl_idname = 'PSA_FH_import' + bl_label = 'File handler for Unreal PSA import' + bl_import_operator = 'psa_import.import' + bl_file_extensions = '.psa' + + @classmethod + def poll_drop(cls, context: Context): + return context.area and context.area.type == 'VIEW_3D' classes = ( @@ -262,4 +269,5 @@ classes = ( PSA_OT_import_sequences_deselect_all, PSA_OT_import_sequences_from_text, PSA_OT_import, + PSA_FH_import, ) From d4d46bea661de6a1c00e439c4d2b064eabbb0653 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 14 Mar 2024 19:06:29 -0700 Subject: [PATCH 47/48] PSA import dialog now uses new Blender 4.1 UI panels --- io_scene_psk_psa/psa/import_/operators.py | 100 +++++++++++----------- 1 file changed, 52 insertions(+), 48 deletions(-) diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index dbdff50..0dd98c9 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -190,67 +190,71 @@ class PSA_OT_import(Operator, ImportHelper): layout = self.layout pg = getattr(context.scene, 'psa_import') - if pg.psa_error: - row = layout.row() - row.label(text='Select a PSA file', icon='ERROR') - else: - box = layout.box() + sequences_header, sequences_panel = layout.panel('sequences_panel_id', default_closed=False) + sequences_header.label(text='Sequences') - box.label(text=f'Sequences ({len(pg.sequence_list)})', icon='ARMATURE_DATA') + if sequences_panel: + if pg.psa_error: + row = sequences_panel.row() + row.label(text='Select a PSA file', icon='ERROR') + else: + # Select buttons. + rows = max(3, min(len(pg.sequence_list), 10)) - # Select buttons. - rows = max(3, min(len(pg.sequence_list), 10)) + row = sequences_panel.row() + col = row.column() - row = box.row() - col = row.column() + row2 = col.row(align=True) + row2.label(text='Select') + row2.operator(PSA_OT_import_sequences_from_text.bl_idname, text='', icon='TEXT') + row2.operator(PSA_OT_import_sequences_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') + row2.operator(PSA_OT_import_sequences_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') - row2 = col.row(align=True) - row2.label(text='Select') - row2.operator(PSA_OT_import_sequences_from_text.bl_idname, text='', icon='TEXT') - row2.operator(PSA_OT_import_sequences_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') - row2.operator(PSA_OT_import_sequences_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') + col = col.row() + col.template_list('PSA_UL_import_sequences', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows) - col = col.row() - col.template_list('PSA_UL_import_sequences', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows) - - col = layout.column(heading='') - col.use_property_split = True - col.use_property_decorate = False - col.prop(pg, 'should_overwrite') - - col = layout.column(heading='Write') - col.use_property_split = True - col.use_property_decorate = False - col.prop(pg, 'should_write_keyframes') - col.prop(pg, 'should_write_metadata') - - col = layout.column() - col.use_property_split = True - col.use_property_decorate = False - col.prop(pg, 'bone_mapping_mode') - - if pg.should_write_keyframes: - col = layout.column(heading='Keyframes') + col = sequences_panel.column(heading='') col.use_property_split = True col.use_property_decorate = False - col.prop(pg, 'should_convert_to_samples') - col.separator() - # FPS col.prop(pg, 'fps_source') if pg.fps_source == 'CUSTOM': col.prop(pg, 'fps_custom') + col.prop(pg, 'should_overwrite') + col.prop(pg, 'should_use_action_name_prefix') + if pg.should_use_action_name_prefix: + col.prop(pg, 'action_name_prefix') - col = layout.column(heading='Options') - col.use_property_split = True - col.use_property_decorate = False - col.prop(pg, 'should_use_fake_user') - col.prop(pg, 'should_stash') - col.prop(pg, 'should_use_config_file') + data_header, data_panel = layout.panel('data_panel_id', default_closed=False) + data_header.label(text='Data') - col.prop(pg, 'should_use_action_name_prefix') + if data_panel: + col = data_panel.column(heading='Write') + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'should_write_keyframes') + col.prop(pg, 'should_write_metadata') - if pg.should_use_action_name_prefix: - col.prop(pg, 'action_name_prefix') + if pg.should_write_keyframes: + col = col.column(heading='Keyframes') + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'should_convert_to_samples') + + advanced_header, advanced_panel = layout.panel('advanced_panel_id', default_closed=True) + advanced_header.label(text='Advanced') + + if advanced_panel: + col = advanced_panel.column() + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'bone_mapping_mode') + + col = advanced_panel.column(heading='Options') + col.use_property_split = True + col.use_property_decorate = False + col.prop(pg, 'should_use_fake_user') + col.prop(pg, 'should_stash') + col.prop(pg, 'should_use_config_file') class PSA_FH_import(FileHandler): From fb0274238105067dfeb6029219293cd75ab8c725 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 14 Mar 2024 19:08:32 -0700 Subject: [PATCH 48/48] Reorganizing & renaming some things for clarity and correctness --- io_scene_psk_psa/psa/import_/properties.py | 6 +-- io_scene_psk_psa/psa/importer.py | 57 +++++++++++----------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py index 43dd375..0664eb8 100644 --- a/io_scene_psk_psa/psa/import_/properties.py +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -75,13 +75,13 @@ class PSA_PG_import(PropertyGroup): ) fps_source: EnumProperty(name='FPS Source', items=( ('SEQUENCE', 'Sequence', 'The sequence frame rate matches the original frame rate', 'ACTION', 0), - ('SCENE', 'Scene', 'The sequence frame rate dilates to match that of the scene', 'SCENE_DATA', 1), - ('CUSTOM', 'Custom', 'The sequence frame rate dilates to match a custom frame rate', 2), + ('SCENE', 'Scene', 'The sequence is resampled to the frame rate of the scene', 'SCENE_DATA', 1), + ('CUSTOM', 'Custom', 'The sequence is resampled to a custom frame rate', 2), )) fps_custom: FloatProperty( default=30.0, name='Custom FPS', - description='The frame rate to which the imported actions will be converted', + description='The frame rate to which the imported sequences will be resampled to', options=empty_set, min=1.0, soft_min=1.0, diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index cedef80..c307413 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -46,16 +46,16 @@ def _calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[fl key_location = Vector(key_data[4:]) q = import_bone.post_rotation.copy() q.rotate(import_bone.original_rotation) - quat = q + rotation = q q = import_bone.post_rotation.copy() if import_bone.parent is None: q.rotate(key_rotation.conjugated()) else: q.rotate(key_rotation) - quat.rotate(q.conjugated()) - loc = key_location - import_bone.original_location - loc.rotate(import_bone.post_rotation.conjugated()) - return quat.w, quat.x, quat.y, quat.z, loc.x, loc.y, loc.z + rotation.rotate(q.conjugated()) + location = key_location - import_bone.original_location + location.rotate(import_bone.post_rotation.conjugated()) + return rotation.w, rotation.x, rotation.y, rotation.z, location.x, location.y, location.z class PsaImportResult: @@ -79,49 +79,48 @@ 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, time_step: float = 1.0) -> np.ndarray: - ''' +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 target_frame_count: The number of frames to resample to. + @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_times(source_frame_count: int, time_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 += time_step - yield source_frame_count - 1 - - if time_step == 1.0: + """ + if frame_step == 1.0: # No resampling is necessary. return sequence_data_matrix source_frame_count, bone_count = sequence_data_matrix.shape[:2] - sample_times = list(get_sample_times(source_frame_count, time_step)) - target_frame_count = len(sample_times) + sample_frame_times = list(_get_sample_frame_times(source_frame_count, frame_step)) + target_frame_count = len(sample_frame_times) resampled_sequence_data_matrix = np.zeros((target_frame_count, bone_count, 7), dtype=float) - for sample_index, sample_time in enumerate(sample_times): - frame_index = int(sample_time) - if sample_time % 1.0 == 0.0: + for sample_frame_index, sample_frame_time in enumerate(sample_frame_times): + frame_index = int(sample_frame_time) + if sample_frame_time % 1.0 == 0.0: # Sample time has no fractional part, so just copy the frame. - resampled_sequence_data_matrix[sample_index, :, :] = sequence_data_matrix[frame_index, :, :] + resampled_sequence_data_matrix[sample_frame_index, :, :] = sequence_data_matrix[frame_index, :, :] else: # Sample time has a fractional part, so interpolate between two frames. next_frame_index = frame_index + 1 for bone_index in range(bone_count): source_frame_1_data = sequence_data_matrix[frame_index, bone_index, :] source_frame_2_data = sequence_data_matrix[next_frame_index, bone_index, :] - factor = sample_time - frame_index + factor = sample_frame_time - frame_index q = Quaternion((source_frame_1_data[:4])).slerp(Quaternion((source_frame_2_data[:4])), factor) q.normalize() l = Vector(source_frame_1_data[4:]).lerp(Vector(source_frame_2_data[4:]), factor) - resampled_sequence_data_matrix[sample_index, bone_index, :] = q.w, q.x, q.y, q.z, l.x, l.y, l.z + resampled_sequence_data_matrix[sample_frame_index, bone_index, :] = q.w, q.x, q.y, q.z, l.x, l.y, l.z return resampled_sequence_data_matrix @@ -188,8 +187,10 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, for import_bone in filter(lambda x: x is not None, import_bones): armature_bone = import_bone.armature_bone + if armature_bone.parent is not None and armature_bone.parent.name in psa_bone_names: import_bone.parent = import_bones_dict[armature_bone.parent.name] + # Calculate the original location & rotation of each bone (in world-space maybe?) if import_bone.parent is not None: import_bone.original_location = armature_bone.matrix_local.translation - armature_bone.parent.matrix_local.translation @@ -272,7 +273,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, # Resample the sequence data to the target FPS. # If the target frame count is the same as the source frame count, this will be a no-op. resampled_sequence_data_matrix = _resample_sequence_data_matrix(sequence_data_matrix, - time_step=sequence.fps / target_fps) + frame_step=sequence.fps / target_fps) # Write the keyframes out. # Note that the f-curve data consists of alternating time and value data.