Compare commits

..

1 Commits

Author SHA1 Message Date
Colin Basnett
76440affdb Initial commit for handling the PSA config 2023-11-07 18:33:18 -08:00
6 changed files with 91 additions and 23 deletions

View File

@@ -1,20 +1,10 @@
[![Blender](https://img.shields.io/badge/Blender->=2.9-blue?logo=blender&logoColor=white)](https://www.blender.org/download/ "Download Blender") [![Blender](https://img.shields.io/badge/Blender->=4.0-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/) [![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) [![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 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.
## 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 # Features
* Full PSK/PSA import and export capabilities. * Full PSK/PSA import and export capabilities.
* Non-standard file section data is supported for import only (vertex normals, extra UV channels, vertex colors, shape keys). * Non-standard file section data is supported for import only (vertex normals, extra UV channels, vertex colors, shape keys).
@@ -24,6 +14,16 @@ Bug fixes will be issued for legacy addon versions that are under [Blender's LTS
* 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. * 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. * 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 # Installation
1. Download the zip file for the latest version from the [releases](https://github.com/DarklightGames/io_export_psk_psa/releases) page. 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. 2. Open Blender 4.0.0 or later.
@@ -54,8 +54,6 @@ Bug fixes will be issued for legacy addon versions that are under [Blender's LTS
3. Select the PSA file you want to import. 3. Select the PSA file you want to import.
4. Select the sequences that you want to import and click `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 # FAQ
## Why are the mesh normals not accurate when importing a PSK extracted from [UE Viewer](https://www.gildor.org/en/projects/umodel)? ## 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. 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.

View File

@@ -30,6 +30,7 @@ if 'bpy' in locals():
importlib.reload(psk_import_operators) importlib.reload(psk_import_operators)
importlib.reload(psa_data) importlib.reload(psa_data)
importlib.reload(psa_config)
importlib.reload(psa_reader) importlib.reload(psa_reader)
importlib.reload(psa_writer) importlib.reload(psa_writer)
importlib.reload(psa_builder) importlib.reload(psa_builder)
@@ -55,6 +56,7 @@ else:
from .psk.import_ import operators as psk_import_operators from .psk.import_ import operators as psk_import_operators
from .psa import data as psa_data from .psa import data as psa_data
from .psa import config as psa_config
from .psa import reader as psa_reader from .psa import reader as psa_reader
from .psa import writer as psa_writer from .psa import writer as psa_writer
from .psa import builder as psa_builder from .psa import builder as psa_builder

View File

@@ -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

View File

@@ -1,10 +1,12 @@
import os import os
from pathlib import Path
from bpy.props import StringProperty from bpy.props import StringProperty
from bpy.types import Operator, Event, Context from bpy.types import Operator, Event, Context
from bpy_extras.io_utils import ImportHelper from bpy_extras.io_utils import ImportHelper
from .properties import get_visible_sequences from .properties import get_visible_sequences
from ..config import read_psa_config
from ..importer import import_psa, PsaImportOptions from ..importer import import_psa, PsaImportOptions
from ..reader import PsaReader from ..reader import PsaReader
@@ -169,6 +171,11 @@ class PSA_OT_import(Operator, ImportHelper):
options.fps_source = pg.fps_source options.fps_source = pg.fps_source
options.fps_custom = pg.fps_custom 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: if len(sequence_names) == 0:
self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected') self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected')
return {'CANCELLED'} return {'CANCELLED'}

View File

@@ -6,6 +6,7 @@ import numpy
from bpy.types import FCurve, Object, Context from bpy.types import FCurve, Object, Context
from mathutils import Vector, Quaternion from mathutils import Vector, Quaternion
from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION
from .data import Psa from .data import Psa
from .reader import PsaReader from .reader import PsaReader
@@ -23,6 +24,7 @@ class PsaImportOptions(object):
self.bone_mapping_mode = 'CASE_INSENSITIVE' self.bone_mapping_mode = 'CASE_INSENSITIVE'
self.fps_source = 'SEQUENCE' self.fps_source = 'SEQUENCE'
self.fps_custom: float = 30.0 self.fps_custom: float = 30.0
self.psa_config: PsaConfig = PsaConfig()
class ImportBone(object): 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') sequence_name = sequence.name.decode('windows-1252')
action_name = options.action_name_prefix + sequence_name 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: if options.should_overwrite and action_name in bpy.data.actions:
action = bpy.data.actions[action_name] action = bpy.data.actions[action_name]
else: 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. # 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(): 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] import_bone = import_bones[psa_bone_index]
pose_bone = import_bone.pose_bone pose_bone = import_bone.pose_bone
rotation_data_path = pose_bone.path_from_id('rotation_quaternion') rotation_data_path = pose_bone.path_from_id('rotation_quaternion')
location_data_path = pose_bone.path_from_id('location') 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 = [ import_bone.fcurves = [
action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name), # Qw 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), # Qx 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), # Qy 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), # Qz 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), # Lx 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), # Ly 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), # Lz 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. # 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. # Write the keyframes out.
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float) 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)] fcurve_data[0::2] = [x * keyframe_time_dilation for x in range(sequence.frame_count)]
for bone_index, import_bone in enumerate(import_bones): for bone_index, import_bone in enumerate(import_bones):
if import_bone is None: if import_bone is None:
continue continue
for fcurve_index, fcurve in enumerate(import_bone.fcurves): 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_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index]
fcurve.keyframe_points.add(sequence.frame_count) fcurve.keyframe_points.add(sequence.frame_count)
fcurve.keyframe_points.foreach_set('co', fcurve_data) fcurve.keyframe_points.foreach_set('co', fcurve_data)

View File

@@ -222,13 +222,12 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
# VERTEX NORMALS # VERTEX NORMALS
if psk.has_vertex_normals and options.should_import_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 = [] normals = []
for vertex_normal in psk.vertex_normals: for vertex_normal in psk.vertex_normals:
normals.append(tuple(vertex_normal)) normals.append(tuple(vertex_normal))
mesh_data.normals_split_custom_set_from_vertices(normals) mesh_data.normals_split_custom_set_from_vertices(normals)
else: mesh_data.use_auto_smooth = True
mesh_data.shade_smooth()
bm.normal_update() bm.normal_update()
bm.free() bm.free()