Compare commits

..

40 Commits

Author SHA1 Message Date
Colin Basnett
ff74f47178 Implemented multiple PSA import (#55)
This can be invoked by drag-and-dropping multiple PSA files onto the
Blender viewport when you have the target armature selected
2024-09-09 17:07:36 -07:00
Colin Basnett
bdd35ef61d Incremented version to v7.1.2 2024-09-09 16:30:01 -07:00
Colin Basnett
1c4967bd67 Fixed is_bdk_addon_loaded function 2024-09-09 16:29:20 -07:00
Colin Basnett
b5dba35ac4 Implemented feature requested in #87 2024-09-09 16:25:29 -07:00
Colin Basnett
7cc5cbe667 Added Visible Only option to the PSK collection exporter 2024-09-09 15:59:13 -07:00
Colin Basnett
e1f0fc7e89 Fix #101: Dashes in the names of PSA config keys results in parsing errors
The issue here was the regex pattern was too restrictive, so it did not
pick up the lines as ones that needed to have the `=` appended at the
end so that the ConfigParser could properly parse the file.
2024-08-07 23:36:48 -07:00
Colin Basnett
03c69783b3 Updated workflow file to be targetted against the stable version of 4.2 2024-07-31 19:16:00 -07:00
Colin Basnett
da4960298b Incremented version to 7.1.1 2024-07-31 19:09:49 -07:00
Colin Basnett
a9706d88a5 Vertices without explicit weights are now weighted to the root bone
There is an issue in some older versions of Unreal (e.g. Postal), where
the engine does not handle vertices without explicit weighting,
resulting in corrupted meshes. This now mitigates the issue.

Thank you to makabray for reporting this issue.
2024-07-31 19:09:22 -07:00
Colin Basnett
9dd02260d5 Replaced __doc__ with bl_description 2024-07-31 19:01:48 -07:00
Colin Basnett
e79af9e8e3 Attempted fix for no-weight issue 2024-07-26 00:58:40 -07:00
Colin Basnett
10a25dc036 Added support for collection exporters 2024-07-17 01:38:32 -07:00
Colin Basnett
14f5b0424c Fixed README link to VTXNORMS UE Viewer PR 2024-07-10 01:34:24 -07:00
Colin Basnett
d26d195a85 PSA export dialog now uses inline panels 2024-07-10 01:30:45 -07:00
Colin Basnett
02913f6922 Updated README in preparation for Blender 4.2 release 2024-07-10 00:59:12 -07:00
Colin Basnett
5cfb37d1a2 Updated BDK repository support to match bdk_addon 2024-06-22 23:44:43 -07:00
Colin Basnett
3863e4edcc Removed MIT license file 2024-06-10 20:56:48 -07:00
Colin Basnett
e77ed7cc8d Merge branch 'master' of https://github.com/DarklightGames/io_scene_psk_psa 2024-06-10 14:04:04 -07:00
Colin Basnett
87eff06f71 Changed name of the addon to Unreal PSK/PSA (.psk/.psa) (short and sweet)
Also added missing `files` permission
2024-06-10 14:03:55 -07:00
Colin Basnett
1a8bd66503 Update README.md 2024-06-10 13:52:38 -07:00
Colin Basnett
143e7af36b Update main.yml 2024-06-10 13:47:23 -07:00
Colin Basnett
6b46cb257d Update main.yml 2024-06-10 12:13:11 -07:00
Colin Basnett
1c98790cfe Update main.yml 2024-06-10 12:02:36 -07:00
Colin Basnett
3fbef00edc Update main.yml 2024-06-09 17:52:31 -07:00
Colin Basnett
4279338574 Update main.yml 2024-06-09 17:47:24 -07:00
Colin Basnett
3b37dbceb9 Update main.yml 2024-06-09 17:43:05 -07:00
Colin Basnett
47c3ed795f Update main.yml 2024-06-09 17:36:26 -07:00
Colin Basnett
ee30938be8 Update main.yml 2024-06-09 17:28:23 -07:00
Colin Basnett
f049055273 Update main.yml 2024-06-09 17:20:51 -07:00
Colin Basnett
fac13ac86b Update main.yml 2024-06-09 14:58:20 -07:00
Colin Basnett
e11b863744 Update main.yml 2024-06-09 14:52:08 -07:00
Colin Basnett
97231079a7 Update main.yml 2024-06-09 14:39:54 -07:00
Colin Basnett
6205c1900c Update main.yml 2024-06-09 14:31:45 -07:00
Colin Basnett
810fe2f14f Update main.yml 2024-06-09 12:08:07 -07:00
Colin Basnett
1384e9daf6 Update main.yml 2024-06-09 12:06:59 -07:00
Colin Basnett
615983aa78 Update main.yml 2024-06-09 11:10:23 -07:00
Colin Basnett
f0c2c9c6c2 Update main.yml 2024-06-09 11:07:48 -07:00
Colin Basnett
7ceaa88f1d Incremented version to 7.0.1 2024-03-31 14:23:35 -07:00
Colin Basnett
37e246bf3e Fixed ordering of panels in the PSA import dialog 2024-03-31 14:22:16 -07:00
Colin Basnett
db93314fbc Initial commit for multiple PSA import 2024-03-31 12:47:48 -07:00
17 changed files with 541 additions and 229 deletions

View File

@@ -1,4 +1,4 @@
name: Verify Addon name: Build Extension
on: on:
workflow_dispatch: workflow_dispatch:
@@ -11,22 +11,44 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
BLENDER_VERSION: blender-4.2.0-beta+v42.d19d23e91f65-linux.x86_64-release BLENDER_VERSION: blender-4.2.0-linux-x64
ADDON_NAME: io_scene_psk_psa
steps: steps:
- uses: actions/checkout@v3
- uses: SebRollen/toml-action@v1.2.0
id: read_manifest
with:
file: '${{ env.ADDON_NAME }}/blender_manifest.toml'
field: 'version'
- name: Set derived environment variables - name: Set derived environment variables
run: | run: |
echo "BLENDER_FILENAME=${{ env.BLENDER_VERSION }}.tar.xz" >> $GITHUB_ENV echo "BLENDER_FILENAME=${{ env.BLENDER_VERSION }}.tar.xz" >> $GITHUB_ENV
echo "BLENDER_URL=https://cdn.builder.blender.org/download/daily/${{ env.BLENDER_VERSION }}.tar.xz" >> $GITHUB_ENV echo "BLENDER_URL=https://mirrors.iu13.net/blender/release/Blender4.2/${{ env.BLENDER_VERSION }}.tar.xz" >> $GITHUB_ENV
- name: Install Blender Dependencies - name: Install Blender Dependencies
run: | run: |
apt install libxxf86vm-dev -y sudo apt-get install libxxf86vm-dev -y
apt install libxfixes3 -y sudo apt-get install libxfixes3 -y
apt install libxi-dev -y sudo apt-get install libxi-dev -y
apt install libxkbcommon-x11-0 -y sudo apt-get install libxkbcommon-x11-0 -y
apt install libgl1-mesa-glx -y sudo apt-get install libgl1-mesa-glx -y
- name: Download Blender - name: Download & Extract Blender
run: | run: |
wget $BLENDER_URL wget -q $BLENDER_URL
tar -xvzf $BLENDER_FILENAME tar -xf $BLENDER_FILENAME
rm -rf $BLENDER_FILENAME rm -rf $BLENDER_FILENAME
- uses: actions/checkout@v3 - name: Add Blender executable to path
run: |
echo "${{ github.workspace }}/${{ env.BLENDER_VERSION }}/" >> $GITHUB_PATH
- name: Build extension
run: |
pushd ./${{ env.ADDON_NAME }}
blender --command extension build
mkdir artifact
unzip -q ${{ env.ADDON_NAME }}-${{ steps.read_manifest.outputs.value }}.zip -d ./artifact
popd
- name: Archive addon
uses: actions/upload-artifact@v4
with:
name: ${{ env.ADDON_NAME }}-${{ github.ref_name }}-${{ github.sha }}
path: |
./${{ env.ADDON_NAME }}/artifact/*

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2019 Darklight Games
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,5 +1,6 @@
[![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->=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/) [![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/)
[![Build Extension](https://github.com/DarklightGames/io_scene_psk_psa/actions/workflows/main.yml/badge.svg)](https://github.com/DarklightGames/io_scene_psk_psa/actions/workflows/main.yml)
[![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)
@@ -7,35 +8,41 @@ This Blender addon allows you to import and export meshes and animations to and
This software is licensed under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) license. This software is licensed under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) license.
# Compatibility
| Blender Version | Addon Version | Long Term Support |
|-|-|-|
| [4.1](https://www.blender.org/download/releases/4-1/) | [latest](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | TBD |
| [4.0](https://www.blender.org/download/releases/4-0/) | [6.2.1](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/6.2.1) | TBD |
| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.6](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.6) | ✅️ 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 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).
* Fine-grained PSA sequence importing for efficient workflow when working with large PSA files. * Fine-grained PSA sequence importing for efficient workflow when working with large PSA files.
* PSA sequence metadata (e.g., frame rate, sequence name) is preserved on import, allowing this data to be reused on export. * PSA sequence metadata (e.g., frame rate) is preserved on import, allowing this data to be reused on export.
* Specific bone collections can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers). * Specific bone collections can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers).
* 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), pose markers, 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.
# Installation # Installation
1. Download the zip file for the latest version from the [releases](https://github.com/DarklightGames/io_export_psk_psa/releases) page. For Blender 4.2 and higher, it is recommended to download the latest version from the [Blender Extensions](https://extensions.blender.org/add-ons/io-scene-psk-psa/) platform.
2. Open Blender 4.0.0 or later.
For Blender 4.1 and lower, you can install the addon manually by following these steps:
1. Download the .zip file of the latest compatible version for your Blender version (see [Legacy Compatibility](#legacy-compatibility)).
2. Open Blender.
3. Navigate to the Blender Preferences (`Edit` > `Preferences`). 3. Navigate to the Blender Preferences (`Edit` > `Preferences`).
4. Select the `Add-ons` tab. 4. Select the `Add-ons` tab.
5. Click the `Install...` button. 5. Click the `Install...` button.
6. Select the .zip file that you downloaded earlier and click `Install Add-on`. 6. Select the .zip file that you downloaded earlier and click `Install Add-on`.
7. Enable the newly added `Import-Export: PSK/PSA Importer/Exporter` addon. 7. Enable the newly added `Import-Export: PSK/PSA Importer/Exporter` addon.
# Legacy Compatibility
Below is a table of the latest addon versions that are compatible with older versions of Blender. These versions are no longer maintained and may contain bugs that have been fixed in newer versions. It is recommended to use the latest version of the addon for the best experience.
Critical bug fixes may 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 the releases will still be available for download.
| Blender Version| Addon Version | Long Term Support |
|-|--------------------------------------------------------------------------------|-----------------|
| [4.1](https://www.blender.org/download/releases/4-1/) | [7.0.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/7.0.0) | No |
| [4.0](https://www.blender.org/download/releases/4-0/) | [6.2.1](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/6.2.1) | No |
| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.6](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.6) | 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 |
# Usage # Usage
## Exporting a PSK ## Exporting a PSK
1. Select the mesh objects you wish to export. 1. Select the mesh objects you wish to export.
@@ -70,3 +77,5 @@ The PSA importer creates [Actions](https://docs.blender.org/manual/en/latest/ani
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.
As a workaround, it is recommended to export [glTF](https://en.wikipedia.org/wiki/GlTF) meshes out of UE Viewer instead, since the glTF format has support for explicit normals and UE Viewer can correctly preserve the mesh normals on export. Note, however, that the imported glTF armature may have it's bones oriented incorrectly when imported into Blender. To mitigate this, you can combine the armature of PSK and the mesh of the glTF for best results. As a workaround, it is recommended to export [glTF](https://en.wikipedia.org/wiki/GlTF) meshes out of UE Viewer instead, since the glTF format has support for explicit normals and UE Viewer can correctly preserve the mesh normals on export. Note, however, that the imported glTF armature may have it's bones oriented incorrectly when imported into Blender. To mitigate this, you can combine the armature of PSK and the mesh of the glTF for best results.
There is also an open pull request to add support for exporting explicit normals from UE Viewer in the future: https://github.com/gildor2/UEViewer/pull/277.

View File

@@ -1,7 +1,7 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "io_scene_psk_psa" id = "io_scene_psk_psa"
version = "7.1.0" version = "7.1.2"
name = "Unreal Mesh & Animation (.psk/.psa)" name = "Unreal PSK/PSA (.psk/.psa)"
tagline = "Import and export PSK and PSA files used in Unreal Engine" tagline = "Import and export PSK and PSA files used in Unreal Engine"
maintainer = "Colin Basnett <cmbasnett@gmail.com>" maintainer = "Colin Basnett <cmbasnett@gmail.com>"
type = "add-on" type = "add-on"
@@ -22,3 +22,6 @@ paths_exclude_pattern = [
"/.github/", "/.github/",
".gitignore", ".gitignore",
] ]
[permissions]
files = "Import/export PSK and PSA files from/to disk"

View File

@@ -1,8 +1,6 @@
import re import re
from configparser import ConfigParser from configparser import ConfigParser
from typing import Dict from typing import Dict, List
from .reader import PsaReader
REMOVE_TRACK_LOCATION = (1 << 0) REMOVE_TRACK_LOCATION = (1 << 0)
REMOVE_TRACK_ROTATION = (1 << 1) REMOVE_TRACK_ROTATION = (1 << 1)
@@ -28,7 +26,7 @@ def _load_config_file(file_path: str) -> ConfigParser:
with open(file_path, 'r') as f: with open(file_path, 'r') as f:
lines = f.read().split('\n') lines = f.read().split('\n')
lines = [re.sub(r'^\s*(\w+)\s*$', r'\1=', line) for line in lines] lines = [re.sub(r'^\s*([^=]+)\s*$', r'\1=', line) for line in lines]
contents = '\n'.join(lines) contents = '\n'.join(lines)
@@ -50,7 +48,7 @@ def _get_bone_flags_from_value(value: str) -> int:
return 0 return 0
def read_psa_config(psa_reader: PsaReader, file_path: str) -> PsaConfig: def read_psa_config(psa_sequence_names: List[str], file_path: str) -> PsaConfig:
psa_config = PsaConfig() psa_config = PsaConfig()
config = _load_config_file(file_path) config = _load_config_file(file_path)
@@ -62,7 +60,6 @@ def read_psa_config(psa_reader: PsaReader, file_path: str) -> PsaConfig:
# Map the sequence name onto the actual sequence name in the PSA file. # Map the sequence name onto the actual sequence name in the PSA file.
try: try:
psa_sequence_names = list(psa_reader.sequences.keys())
lowercase_sequence_names = [sequence_name.lower() for sequence_name in psa_sequence_names] 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())] sequence_name = psa_sequence_names[lowercase_sequence_names.index(sequence_name.lower())]
except ValueError: except ValueError:

View File

@@ -214,7 +214,7 @@ class PSA_OT_export(Operator, ExportHelper):
bl_idname = 'psa_export.operator' bl_idname = 'psa_export.operator'
bl_label = 'Export' bl_label = 'Export'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
__doc__ = 'Export actions to PSA' bl_description = 'Export actions to PSA'
filename_ext = '.psa' filename_ext = '.psa'
filter_glob: StringProperty(default='*.psa', options={'HIDDEN'}) filter_glob: StringProperty(default='*.psa', options={'HIDDEN'})
filepath: StringProperty( filepath: StringProperty(
@@ -239,79 +239,94 @@ class PSA_OT_export(Operator, ExportHelper):
layout = self.layout layout = self.layout
pg = getattr(context.scene, 'psa_export') pg = getattr(context.scene, 'psa_export')
# FPS sequences_header, sequences_panel = layout.panel('Sequences', default_closed=False)
layout.prop(pg, 'fps_source', text='FPS') sequences_header.label(text='Sequences', icon='ACTION')
if pg.fps_source == 'CUSTOM':
layout.prop(pg, 'fps_custom', text='Custom')
# SOURCE if sequences_panel:
layout.prop(pg, 'sequence_source', text='Source') flow = sequences_panel.grid_flow()
if pg.sequence_source in {'TIMELINE_MARKERS', 'NLA_TRACK_STRIPS'}:
# ANIMDATA SOURCE
layout.prop(pg, 'should_override_animation_data')
if pg.should_override_animation_data:
layout.prop(pg, 'animation_data_override', text='')
if pg.sequence_source == 'NLA_TRACK_STRIPS':
flow = layout.grid_flow()
flow.use_property_split = True flow.use_property_split = True
flow.use_property_decorate = False flow.use_property_decorate = False
flow.prop(pg, 'nla_track') flow.prop(pg, 'sequence_source', text='Source')
# SELECT ALL/NONE if pg.sequence_source in {'TIMELINE_MARKERS', 'NLA_TRACK_STRIPS'}:
row = layout.row(align=True) # ANIMDATA SOURCE
row.label(text='Select') flow.prop(pg, 'should_override_animation_data')
row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') if pg.should_override_animation_data:
row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') flow.prop(pg, 'animation_data_override', text=' ')
# ACTIONS if pg.sequence_source == 'NLA_TRACK_STRIPS':
if pg.sequence_source == 'ACTIONS': flow = sequences_panel.grid_flow()
rows = max(3, min(len(pg.action_list), 10)) flow.use_property_split = True
layout.template_list('PSA_UL_export_sequences', '', pg, 'action_list', pg, 'action_list_index', rows=rows) flow.use_property_decorate = False
elif pg.sequence_source == 'TIMELINE_MARKERS': flow.prop(pg, 'nla_track')
rows = max(3, min(len(pg.marker_list), 10))
layout.template_list('PSA_UL_export_sequences', '', pg, 'marker_list', pg, 'marker_list_index', rows=rows)
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
rows = max(3, min(len(pg.nla_strip_list), 10))
layout.template_list('PSA_UL_export_sequences', '', pg, 'nla_strip_list', pg, 'nla_strip_list_index', rows=rows)
col = layout.column() # SELECT ALL/NONE
col.use_property_split = True row = sequences_panel.row(align=True)
col.use_property_decorate = False row.label(text='Select')
col.prop(pg, 'sequence_name_prefix') row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
col.prop(pg, 'sequence_name_suffix') row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
# Determine if there is going to be a naming conflict and display an error, if so. # ACTIONS
selected_items = [x for x in pg.action_list if x.is_selected] if pg.sequence_source == 'ACTIONS':
action_names = [x.name for x in selected_items] rows = max(3, min(len(pg.action_list), 10))
action_name_counts = Counter(action_names) sequences_panel.template_list('PSA_UL_export_sequences', '', pg, 'action_list', pg, 'action_list_index', rows=rows)
for action_name, count in action_name_counts.items(): elif pg.sequence_source == 'TIMELINE_MARKERS':
if count > 1: rows = max(3, min(len(pg.marker_list), 10))
layout.label(text=f'Duplicate action: {action_name}', icon='ERROR') sequences_panel.template_list('PSA_UL_export_sequences', '', pg, 'marker_list', pg, 'marker_list_index', rows=rows)
break elif pg.sequence_source == 'NLA_TRACK_STRIPS':
rows = max(3, min(len(pg.nla_strip_list), 10))
sequences_panel.template_list('PSA_UL_export_sequences', '', pg, 'nla_strip_list', pg, 'nla_strip_list_index', rows=rows)
layout.separator() flow = sequences_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'sequence_name_prefix', text='Name Prefix')
flow.prop(pg, 'sequence_name_suffix')
# Determine if there is going to be a naming conflict and display an error, if so.
selected_items = [x for x in pg.action_list if x.is_selected]
action_names = [x.name for x in selected_items]
action_name_counts = Counter(action_names)
for action_name, count in action_name_counts.items():
if count > 1:
layout.label(text=f'Duplicate action: {action_name}', icon='ERROR')
break
# FPS
flow.prop(pg, 'fps_source')
if pg.fps_source == 'CUSTOM':
flow.prop(pg, 'fps_custom', text='Custom')
# BONES # BONES
row = layout.row(align=True) bones_header, bones_panel = layout.panel('Bones', default_closed=False)
row.prop(pg, 'bone_filter_mode', text='Bones') bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel:
row = bones_panel.row(align=True)
row.prop(pg, 'bone_filter_mode', text='Bones')
if pg.bone_filter_mode == 'BONE_COLLECTIONS': if pg.bone_filter_mode == 'BONE_COLLECTIONS':
row = layout.row(align=True) row = bones_panel.row(align=True)
row.label(text='Select') row.label(text='Select')
row.operator(PSA_OT_export_bone_collections_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') row.operator(PSA_OT_export_bone_collections_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
row.operator(PSA_OT_export_bone_collections_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') row.operator(PSA_OT_export_bone_collections_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
rows = max(3, min(len(pg.bone_collection_list), 10)) rows = max(3, min(len(pg.bone_collection_list), 10))
layout.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', bones_panel.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index',
rows=rows) rows=rows)
layout.prop(pg, 'should_enforce_bone_name_restrictions') flow = bones_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'should_enforce_bone_name_restrictions')
layout.separator() # ADVANCED
advanced_header, advanced_panel = layout.panel('Advanced', default_closed=False)
advanced_header.label(text='Advanced')
# ROOT MOTION if advanced_panel:
layout.prop(pg, 'root_motion', text='Root Motion') flow = advanced_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'root_motion', text='Root Motion')
@classmethod @classmethod
def _check_context(cls, context): def _check_context(cls, context):

View File

@@ -125,7 +125,7 @@ class PSA_PG_export(PropertyGroup):
description='', description='',
items=( items=(
('SCENE', 'Scene', '', 'SCENE_DATA', 0), ('SCENE', 'Scene', '', 'SCENE_DATA', 0),
('ACTION_METADATA', 'Action Metadata', 'The frame rate will be determined by action\'s FPS property found in the PSA Export panel.\n\nIf the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used', 'PROPERTIES', 1), ('ACTION_METADATA', 'Action Metadata', 'The frame rate will be determined by action\'s FPS property found in the PSA Export panel.\n\nIf the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used', 'ACTION', 1),
('CUSTOM', 'Custom', '', 2) ('CUSTOM', 'Custom', '', 2)
) )
) )

View File

@@ -1,8 +1,9 @@
import os import os
from pathlib import Path from pathlib import Path
from typing import List
from bpy.props import StringProperty from bpy.props import StringProperty, CollectionProperty
from bpy.types import Operator, Event, Context, FileHandler from bpy.types import Operator, Event, Context, FileHandler, OperatorFileListElement, Object
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
@@ -108,11 +109,99 @@ def load_psa_file(context, filepath: str):
pg.psa_error = str(e) pg.psa_error = str(e)
def on_psa_file_path_updated(cls, context): def on_psa_file_path_updated(cls, context):
load_psa_file(context, cls.filepath) load_psa_file(context, cls.filepath)
class PSA_OT_import_multiple(Operator):
bl_idname = 'psa_import.import_multiple'
bl_label = 'Import PSA'
bl_description = 'Import multiple PSA files'
bl_options = {'INTERNAL', 'UNDO'}
directory: StringProperty(subtype='FILE_PATH', options={'SKIP_SAVE', 'HIDDEN'})
files: CollectionProperty(type=OperatorFileListElement, options={'SKIP_SAVE', 'HIDDEN'})
def execute(self, context):
pg = getattr(context.scene, 'psa_import')
warnings = []
for file in self.files:
psa_path = os.path.join(self.directory, file.name)
psa_reader = PsaReader(psa_path)
sequence_names = psa_reader.sequences.keys()
result = _import_psa(context, pg, psa_path, sequence_names, context.view_layer.objects.active)
result.warnings.extend(warnings)
if len(result.warnings) > 0:
message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n'
self.report({'INFO'}, message)
for warning in result.warnings:
self.report({'WARNING'}, warning)
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
return {'FINISHED'}
def invoke(self, context: Context, event):
# Make sure the selected object is an armature.
active_object = context.view_layer.objects.active
if active_object is None or active_object.type != 'ARMATURE':
self.report({'ERROR_INVALID_CONTEXT'}, 'The active object must be an armature')
return {'CANCELLED'}
# Show the import operator properties in a pop-up dialog (do not use the file selector).
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
pg = getattr(context.scene, 'psa_import')
draw_psa_import_options_no_panels(layout, pg)
def _import_psa(context,
pg,
filepath: str,
sequence_names: List[str],
armature_object: Object
):
options = PsaImportOptions()
options.sequence_names = sequence_names
options.should_use_fake_user = pg.should_use_fake_user
options.should_stash = pg.should_stash
options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else ''
options.should_overwrite = pg.should_overwrite
options.should_write_metadata = pg.should_write_metadata
options.should_write_keyframes = pg.should_write_keyframes
options.should_convert_to_samples = pg.should_convert_to_samples
options.bone_mapping_mode = pg.bone_mapping_mode
options.fps_source = pg.fps_source
options.fps_custom = pg.fps_custom
options.translation_scale = pg.translation_scale
warnings = []
if options.should_use_config_file:
# Read the PSA config file if it exists.
config_path = Path(filepath).with_suffix('.config')
if config_path.exists():
try:
options.psa_config = read_psa_config(sequence_names, str(config_path))
except Exception as e:
warnings.append(f'Failed to read PSA config file: {e}')
psa_reader = PsaReader(filepath)
result = import_psa(context, psa_reader, armature_object, options)
result.warnings.extend(warnings)
return result
class PSA_OT_import(Operator, ImportHelper): class PSA_OT_import(Operator, ImportHelper):
bl_idname = 'psa_import.import' bl_idname = 'psa_import.import'
bl_label = 'Import' bl_label = 'Import'
@@ -138,36 +227,13 @@ class PSA_OT_import(Operator, ImportHelper):
def execute(self, context): def execute(self, context):
pg = getattr(context.scene, 'psa_import') pg = getattr(context.scene, 'psa_import')
psa_reader = PsaReader(self.filepath)
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected] sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected]
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'}
options = PsaImportOptions() result = _import_psa(context, pg, self.filepath, sequence_names, context.view_layer.objects.active)
options.sequence_names = sequence_names
options.should_use_fake_user = pg.should_use_fake_user
options.should_stash = pg.should_stash
options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else ''
options.should_overwrite = pg.should_overwrite
options.should_write_metadata = pg.should_write_metadata
options.should_write_keyframes = pg.should_write_keyframes
options.should_convert_to_samples = pg.should_convert_to_samples
options.bone_mapping_mode = pg.bone_mapping_mode
options.fps_source = pg.fps_source
options.fps_custom = pg.fps_custom
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)
if len(result.warnings) > 0: if len(result.warnings) > 0:
message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n' message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n'
@@ -249,6 +315,11 @@ class PSA_OT_import(Operator, ImportHelper):
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'bone_mapping_mode') col.prop(pg, 'bone_mapping_mode')
col = advanced_panel.column()
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'translation_scale', text='Translation Scale')
col = advanced_panel.column(heading='Options') col = advanced_panel.column(heading='Options')
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
@@ -257,10 +328,49 @@ class PSA_OT_import(Operator, ImportHelper):
col.prop(pg, 'should_use_config_file') col.prop(pg, 'should_use_config_file')
def draw_psa_import_options_no_panels(layout, pg):
col = layout.column(heading='Sequences')
col.use_property_split = True
col.use_property_decorate = False
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='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_write_keyframes:
col = col.column(heading='Keyframes')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_convert_to_samples')
col = layout.column()
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'bone_mapping_mode')
col.prop(pg, 'translation_scale')
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')
class PSA_FH_import(FileHandler): class PSA_FH_import(FileHandler):
bl_idname = 'PSA_FH_import' bl_idname = 'PSA_FH_import'
bl_label = 'File handler for Unreal PSA import' bl_label = 'File handler for Unreal PSA import'
bl_import_operator = 'psa_import.import' bl_import_operator = 'psa_import.import_multiple'
bl_export_operator = 'psa_export.export'
bl_file_extensions = '.psa' bl_file_extensions = '.psa'
@classmethod @classmethod
@@ -273,5 +383,6 @@ classes = (
PSA_OT_import_sequences_deselect_all, PSA_OT_import_sequences_deselect_all,
PSA_OT_import_sequences_from_text, PSA_OT_import_sequences_from_text,
PSA_OT_import, PSA_OT_import,
PSA_OT_import_multiple,
PSA_FH_import, PSA_FH_import,
) )

View File

@@ -103,6 +103,11 @@ class PSA_PG_import(PropertyGroup):
soft_max=1.0, soft_max=1.0,
step=0.0625, step=0.0625,
) )
translation_scale: FloatProperty(
name='Translation Scale',
default=1.0,
description='Scale factor for bone translation values. Use this when the scale of the armature does not match the PSA file'
)
def filter_sequences(pg: PSA_PG_import, sequences) -> List[int]: def filter_sequences(pg: PSA_PG_import, sequences) -> List[int]:

View File

@@ -24,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.translation_scale: float = 1.0
self.should_use_config_file = True self.should_use_config_file = True
self.psa_config: PsaConfig = PsaConfig() self.psa_config: PsaConfig = PsaConfig()
@@ -88,6 +89,7 @@ def _get_sample_frame_times(source_frame_count: int, frame_step: float) -> typin
time += frame_step time += frame_step
yield source_frame_count - 1 yield source_frame_count - 1
def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step: float = 1.0) -> np.ndarray: 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. Resamples the sequence data matrix to the target frame count.
@@ -271,6 +273,10 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
# Read the sequence data matrix from the PSA. # Read the sequence data matrix from the PSA.
sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name) sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name)
if options.translation_scale != 1.0:
# Scale the translation data.
sequence_data_matrix[:, :, 4:] *= options.translation_scale
# Convert the sequence's data from world-space to local-space. # Convert the sequence's data from world-space to local-space.
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:

View File

@@ -3,7 +3,7 @@ from typing import Optional
import bmesh import bmesh
import bpy import bpy
import numpy as np import numpy as np
from bpy.types import Armature, Material from bpy.types import Armature, Material, Collection, Context
from .data import * from .data import *
from .properties import triangle_type_and_bit_flags_to_poly_flags from .properties import triangle_type_and_bit_flags_to_poly_flags
@@ -20,30 +20,31 @@ class PskBuildOptions(object):
def __init__(self): def __init__(self):
self.bone_filter_mode = 'ALL' self.bone_filter_mode = 'ALL'
self.bone_collection_indices: List[int] = [] self.bone_collection_indices: List[int] = []
self.use_raw_mesh_data = True self.object_eval_state = 'EVALUATED'
self.materials: List[Material] = [] self.materials: List[Material] = []
self.should_enforce_bone_name_restrictions = False self.should_enforce_bone_name_restrictions = False
def get_psk_input_objects(context) -> PskInputObjects: def get_mesh_objects_for_collection(collection: Collection, should_exclude_hidden_meshes: bool = True):
input_objects = PskInputObjects() for obj in collection.all_objects:
for selected_object in context.view_layer.objects.selected: if obj.type != 'MESH':
if selected_object.type == 'MESH': continue
input_objects.mesh_objects.append(selected_object) if should_exclude_hidden_meshes and obj.visible_get() is False:
continue
yield obj
if len(input_objects.mesh_objects) == 0:
raise RuntimeError('At least one mesh must be selected')
for mesh_object in input_objects.mesh_objects: def get_mesh_objects_for_context(context: Context):
if len(mesh_object.data.materials) == 0: for obj in context.view_layer.objects.selected:
raise RuntimeError(f'Mesh "{mesh_object.name}" must have at least one material') if obj.type == 'MESH':
yield obj
# Ensure that there are either no armature modifiers (static mesh)
# or that there is exactly one armature modifier object shared between def get_armature_for_mesh_objects(mesh_objects: List[Object]) -> Optional[Object]:
# all selected meshes # Ensure that there are either no armature modifiers (static mesh) or that there is exactly one armature modifier
# object shared between all meshes.
armature_modifier_objects = set() armature_modifier_objects = set()
for mesh_object in mesh_objects:
for mesh_object in input_objects.mesh_objects:
modifiers = [x for x in mesh_object.modifiers if x.type == 'ARMATURE'] modifiers = [x for x in mesh_object.modifiers if x.type == 'ARMATURE']
if len(modifiers) == 0: if len(modifiers) == 0:
continue continue
@@ -53,21 +54,46 @@ def get_psk_input_objects(context) -> PskInputObjects:
if len(armature_modifier_objects) > 1: if len(armature_modifier_objects) > 1:
armature_modifier_names = [x.name for x in armature_modifier_objects] armature_modifier_names = [x.name for x in armature_modifier_objects]
raise RuntimeError(f'All selected meshes must have the same armature modifier, encountered {len(armature_modifier_names)} ({", ".join(armature_modifier_names)})') raise RuntimeError(
f'All meshes must have the same armature modifier, encountered {len(armature_modifier_names)} ({", ".join(armature_modifier_names)})')
elif len(armature_modifier_objects) == 1: elif len(armature_modifier_objects) == 1:
input_objects.armature_object = list(armature_modifier_objects)[0] return list(armature_modifier_objects)[0]
else:
return None
def _get_psk_input_objects(mesh_objects: List[Object]) -> PskInputObjects:
if len(mesh_objects) == 0:
raise RuntimeError('At least one mesh must be selected')
for mesh_object in mesh_objects:
if len(mesh_object.data.materials) == 0:
raise RuntimeError(f'Mesh "{mesh_object.name}" must have at least one material')
input_objects = PskInputObjects()
input_objects.mesh_objects = mesh_objects
input_objects.armature_object = get_armature_for_mesh_objects(mesh_objects)
return input_objects return input_objects
def get_psk_input_objects_for_context(context: Context) -> PskInputObjects:
mesh_objects = list(get_mesh_objects_for_context(context))
return _get_psk_input_objects(mesh_objects)
def get_psk_input_objects_for_collection(collection: Collection, should_exclude_hidden_meshes: bool = True) -> PskInputObjects:
mesh_objects = list(get_mesh_objects_for_collection(collection, should_exclude_hidden_meshes))
return _get_psk_input_objects(mesh_objects)
class PskBuildResult(object): class PskBuildResult(object):
def __init__(self): def __init__(self):
self.psk = None self.psk = None
self.warnings: List[str] = [] self.warnings: List[str] = []
def build_psk(context, options: PskBuildOptions) -> PskBuildResult: def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult:
input_objects = get_psk_input_objects(context)
armature_object: bpy.types.Object = input_objects.armature_object armature_object: bpy.types.Object = input_objects.armature_object
result = PskBuildResult() result = PskBuildResult()
@@ -160,47 +186,48 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult:
material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots] material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots]
# MESH DATA # MESH DATA
if options.use_raw_mesh_data: match options.object_eval_state:
mesh_object = input_mesh_object case 'ORIGINAL':
mesh_data = input_mesh_object.data mesh_object = input_mesh_object
else: mesh_data = input_mesh_object.data
# Create a copy of the mesh object after non-armature modifiers are applied. case 'EVALUATED':
# Create a copy of the mesh object after non-armature modifiers are applied.
# Temporarily force the armature into the rest position. # Temporarily force the armature into the rest position.
# We will undo this later. # We will undo this later.
old_pose_position = None old_pose_position = None
if armature_object is not None: if armature_object is not None:
old_pose_position = armature_object.data.pose_position old_pose_position = armature_object.data.pose_position
armature_object.data.pose_position = 'REST' armature_object.data.pose_position = 'REST'
depsgraph = context.evaluated_depsgraph_get() depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new() bm = bmesh.new()
bm.from_object(input_mesh_object, depsgraph) bm.from_object(input_mesh_object, depsgraph)
mesh_data = bpy.data.meshes.new('') mesh_data = bpy.data.meshes.new('')
bm.to_mesh(mesh_data) bm.to_mesh(mesh_data)
del bm del bm
mesh_object = bpy.data.objects.new('', mesh_data) mesh_object = bpy.data.objects.new('', mesh_data)
mesh_object.matrix_world = input_mesh_object.matrix_world 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) scale = (input_mesh_object.scale.x, input_mesh_object.scale.y, input_mesh_object.scale.z)
# Negative scaling in Blender results in inverted normals after the scale is applied. However, if the scale # 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 # 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. # 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 # 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 # 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 # 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. # 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 should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1
# Copy the vertex groups # Copy the vertex groups
for vertex_group in input_mesh_object.vertex_groups: for vertex_group in input_mesh_object.vertex_groups:
mesh_object.vertex_groups.new(name=vertex_group.name) mesh_object.vertex_groups.new(name=vertex_group.name)
# Restore the previous pose position on the armature. # Restore the previous pose position on the armature.
if old_pose_position is not None: if old_pose_position is not None:
armature_object.data.pose_position = old_pose_position armature_object.data.pose_position = old_pose_position
vertex_offset = len(psk.points) vertex_offset = len(psk.points)
@@ -287,6 +314,12 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult:
break break
except ValueError: except ValueError:
bone = bone.parent bone = bone.parent
# Keep track of which vertices have been assigned weights.
# The ones that have not been assigned weights will be assigned to the root bone.
# Without this, some older versions of UnrealEd may have corrupted meshes.
vertices_assigned_weights = np.full(len(mesh_data.vertices), False)
for vertex_group_index, vertex_group in enumerate(mesh_object.vertex_groups): for vertex_group_index, vertex_group in enumerate(mesh_object.vertex_groups):
if vertex_group_index not in vertex_group_bone_indices: if vertex_group_index not in vertex_group_bone_indices:
# Vertex group has no associated bone, skip it. # Vertex group has no associated bone, skip it.
@@ -304,8 +337,18 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult:
w.point_index = vertex_offset + vertex_index w.point_index = vertex_offset + vertex_index
w.weight = weight w.weight = weight
psk.weights.append(w) psk.weights.append(w)
vertices_assigned_weights[vertex_index] = True
if not options.use_raw_mesh_data: # Assign vertices that have not been assigned weights to the root bone.
for vertex_index, assigned in enumerate(vertices_assigned_weights):
if not assigned:
w = Psk.Weight()
w.bone_index = 0
w.point_index = vertex_offset + vertex_index
w.weight = 1.0
psk.weights.append(w)
if options.object_eval_state == 'EVALUATED':
bpy.data.objects.remove(mesh_object) bpy.data.objects.remove(mesh_object)
bpy.data.meshes.remove(mesh_data) bpy.data.meshes.remove(mesh_data)
del mesh_data del mesh_data

View File

@@ -1,35 +1,40 @@
from bpy.props import StringProperty from typing import List
from bpy.types import Operator
import bpy
from bpy.props import StringProperty, BoolProperty, EnumProperty
from bpy.types import Operator, Context, Object
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from ..builder import build_psk, PskBuildOptions, get_psk_input_objects from .properties import object_eval_state_items
from ..builder import build_psk, PskBuildOptions, get_psk_input_objects_for_context, \
get_psk_input_objects_for_collection
from ..writer import write_psk from ..writer import write_psk
from ...shared.helpers import populate_bone_collection_list from ...shared.helpers import populate_bone_collection_list
def is_bone_filter_mode_item_available(context, identifier): def is_bone_filter_mode_item_available(context, identifier):
input_objects = get_psk_input_objects(context) input_objects = get_psk_input_objects_for_context(context)
armature_object = input_objects.armature_object armature_object = input_objects.armature_object
if identifier == 'BONE_COLLECTIONS': if identifier == 'BONE_COLLECTIONS':
if armature_object is None or armature_object.data is None or len(armature_object.data.collections) == 0: if armature_object is None or armature_object.data is None or len(armature_object.data.collections) == 0:
return False return False
# else if... you can set up other conditions if you add more options
return True return True
def populate_material_list(mesh_objects, material_list): def get_materials_for_mesh_objects(mesh_objects: List[Object]):
material_list.clear()
materials = [] materials = []
for mesh_object in mesh_objects: for mesh_object in mesh_objects:
for i, material_slot in enumerate(mesh_object.material_slots): for i, material_slot in enumerate(mesh_object.material_slots):
material = material_slot.material material = material_slot.material
# TODO: put this in the poll arg?
if material is None: if material is None:
raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')') raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')')
if material not in materials: if material not in materials:
materials.append(material) materials.append(material)
return materials
def populate_material_list(mesh_objects, material_list):
materials = get_materials_for_mesh_objects(mesh_objects)
material_list.clear()
for index, material in enumerate(materials): for index, material in enumerate(materials):
m = material_list.add() m = material_list.add()
m.material = material m.material = material
@@ -72,11 +77,95 @@ class PSK_OT_material_list_move_down(Operator):
return {'FINISHED'} return {'FINISHED'}
class PSK_OT_export_collection(Operator, ExportHelper):
bl_idname = 'export.psk_collection'
bl_label = 'Export'
bl_options = {'INTERNAL'}
filename_ext = '.psk'
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'})
filepath: StringProperty(
name='File Path',
description='File path used for exporting the PSK file',
maxlen=1024,
default='',
subtype='FILE_PATH')
collection: StringProperty(options={'HIDDEN'})
object_eval_state: EnumProperty(
items=object_eval_state_items,
name='Object Evaluation State',
default='EVALUATED'
)
should_enforce_bone_name_restrictions: BoolProperty(
default=False,
name='Enforce Bone Name Restrictions',
description='Enforce that bone names must only contain letters, numbers, spaces, hyphens and underscores.\n\n'
'Depending on the engine, improper bone names might not be referenced correctly by scripts'
)
should_exclude_hidden_meshes: BoolProperty(
default=True,
name='Visible Only',
description='Export only visible meshes'
)
def execute(self, context):
collection = bpy.data.collections.get(self.collection)
try:
input_objects = get_psk_input_objects_for_collection(collection, self.should_exclude_hidden_meshes)
except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'}
options = PskBuildOptions()
options.bone_filter_mode = 'ALL'
options.object_eval_state = self.object_eval_state
options.materials = get_materials_for_mesh_objects(input_objects.mesh_objects)
options.should_enforce_bone_name_restrictions = self.should_enforce_bone_name_restrictions
try:
result = build_psk(context, input_objects, options)
for warning in result.warnings:
self.report({'WARNING'}, warning)
write_psk(result.psk, self.filepath)
if len(result.warnings) > 0:
self.report({'WARNING'}, f'PSK export successful with {len(result.warnings)} warnings')
else:
self.report({'INFO'}, f'PSK export successful')
except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'}
return {'FINISHED'}
def draw(self, context: Context):
layout = self.layout
# MESH
mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False)
mesh_header.label(text='Mesh', icon='MESH_DATA')
if mesh_panel:
flow = mesh_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'object_eval_state', text='Data')
flow.prop(self, 'should_exclude_hidden_meshes')
# BONES
bones_header, bones_panel = layout.panel('Bones', default_closed=False)
bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel:
flow = bones_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'should_enforce_bone_name_restrictions')
class PSK_OT_export(Operator, ExportHelper): class PSK_OT_export(Operator, ExportHelper):
bl_idname = 'export.psk' bl_idname = 'export.psk'
bl_label = 'Export' bl_label = 'Export'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
__doc__ = 'Export mesh and armature to PSK' bl_description = 'Export mesh and armature to PSK'
filename_ext = '.psk' filename_ext = '.psk'
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'}) filter_glob: StringProperty(default='*.psk', options={'HIDDEN'})
@@ -88,7 +177,7 @@ class PSK_OT_export(Operator, ExportHelper):
def invoke(self, context, event): def invoke(self, context, event):
try: try:
input_objects = get_psk_input_objects(context) input_objects = get_psk_input_objects_for_context(context)
except RuntimeError as e: except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
@@ -110,7 +199,7 @@ class PSK_OT_export(Operator, ExportHelper):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
try: try:
get_psk_input_objects(context) get_psk_input_objects_for_context(context)
except RuntimeError as e: except RuntimeError as e:
cls.poll_message_set(str(e)) cls.poll_message_set(str(e))
return False return False
@@ -118,16 +207,20 @@ class PSK_OT_export(Operator, ExportHelper):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
pg = getattr(context.scene, 'psk_export') pg = getattr(context.scene, 'psk_export')
# MESH # MESH
mesh_header, mesh_panel = layout.panel('01_mesh', default_closed=False) mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False)
mesh_header.label(text='Mesh', icon='MESH_DATA') mesh_header.label(text='Mesh', icon='MESH_DATA')
if mesh_panel: if mesh_panel:
mesh_panel.prop(pg, 'use_raw_mesh_data') flow = mesh_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'object_eval_state', text='Data')
# BONES # BONES
bones_header, bones_panel = layout.panel('02_bones', default_closed=False) bones_header, bones_panel = layout.panel('Bones', default_closed=False)
bones_header.label(text='Bones', icon='BONE_DATA') bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel: if bones_panel:
bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static
@@ -146,7 +239,7 @@ class PSK_OT_export(Operator, ExportHelper):
bones_panel.prop(pg, 'should_enforce_bone_name_restrictions') bones_panel.prop(pg, 'should_enforce_bone_name_restrictions')
# MATERIALS # MATERIALS
materials_header, materials_panel = layout.panel('03_materials', default_closed=False) materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials', icon='MATERIAL') materials_header.label(text='Materials', icon='MATERIAL')
if materials_panel: if materials_panel:
row = materials_panel.row() row = materials_panel.row()
@@ -157,16 +250,19 @@ class PSK_OT_export(Operator, ExportHelper):
col.operator(PSK_OT_material_list_move_down.bl_idname, text='', icon='TRIA_DOWN') col.operator(PSK_OT_material_list_move_down.bl_idname, text='', icon='TRIA_DOWN')
def execute(self, context): def execute(self, context):
pg = context.scene.psk_export pg = getattr(context.scene, 'psk_export')
input_objects = get_psk_input_objects_for_context(context)
options = PskBuildOptions() options = PskBuildOptions()
options.bone_filter_mode = pg.bone_filter_mode 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.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.object_eval_state = pg.object_eval_state
options.materials = [m.material 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 options.should_enforce_bone_name_restrictions = pg.should_enforce_bone_name_restrictions
try: try:
result = build_psk(context, options) result = build_psk(context, input_objects, options)
for warning in result.warnings: for warning in result.warnings:
self.report({'WARNING'}, warning) self.report({'WARNING'}, warning)
write_psk(result.psk, self.filepath) write_psk(result.psk, self.filepath)
@@ -185,4 +281,5 @@ classes = (
PSK_OT_material_list_move_up, PSK_OT_material_list_move_up,
PSK_OT_material_list_move_down, PSK_OT_material_list_move_down,
PSK_OT_export, PSK_OT_export,
PSK_OT_export_collection,
) )

View File

@@ -5,6 +5,12 @@ from ...shared.types import PSX_PG_bone_collection_list_item
empty_set = set() empty_set = set()
object_eval_state_items = (
('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'),
('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'),
)
class PSK_PG_material_list_item(PropertyGroup): class PSK_PG_material_list_item(PropertyGroup):
material: PointerProperty(type=Material) material: PointerProperty(type=Material)
index: IntProperty() index: IntProperty()
@@ -23,7 +29,11 @@ class PSK_PG_export(PropertyGroup):
) )
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item) bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
bone_collection_list_index: IntProperty(default=0) bone_collection_list_index: IntProperty(default=0)
use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh') object_eval_state: EnumProperty(
items=object_eval_state_items,
name='Object Evaluation State',
default='EVALUATED'
)
material_list: CollectionProperty(type=PSK_PG_material_list_item) material_list: CollectionProperty(type=PSK_PG_material_list_item)
material_list_index: IntProperty(default=0) material_list_index: IntProperty(default=0)
should_enforce_bone_name_restrictions: BoolProperty( should_enforce_bone_name_restrictions: BoolProperty(

View File

@@ -13,8 +13,9 @@ empty_set = set()
class PSK_FH_import(FileHandler): class PSK_FH_import(FileHandler):
bl_idname = 'PSK_FH_import' bl_idname = 'PSK_FH_import'
bl_label = 'File handler for Unreal PSK/PSKX import' bl_label = 'Unreal PSK'
bl_import_operator = 'import_scene.psk' bl_import_operator = 'import_scene.psk'
bl_export_operator = 'export.psk_collection'
bl_file_extensions = '.psk;.pskx' bl_file_extensions = '.psk;.pskx'
@classmethod @classmethod
@@ -26,7 +27,7 @@ class PSK_OT_import(Operator, ImportHelper):
bl_idname = 'import_scene.psk' bl_idname = 'import_scene.psk'
bl_label = 'Import' bl_label = 'Import'
bl_options = {'INTERNAL', 'UNDO', 'PRESET'} bl_options = {'INTERNAL', 'UNDO', 'PRESET'}
__doc__ = 'Load a PSK file' bl_description = 'Import a PSK file'
filename_ext = '.psk' filename_ext = '.psk'
filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'}) filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'})
filepath: StringProperty( filepath: StringProperty(
@@ -101,6 +102,12 @@ class PSK_OT_import(Operator, ImportHelper):
default=1.0, default=1.0,
soft_min=0.0, soft_min=0.0,
) )
bdk_repository_id: StringProperty(
name='BDK Repository ID',
default='',
options=empty_set,
description='The ID of the BDK repository to use for loading materials'
)
def execute(self, context): def execute(self, context):
psk = read_psk(self.filepath) psk = read_psk(self.filepath)
@@ -118,6 +125,9 @@ class PSK_OT_import(Operator, ImportHelper):
options.should_import_shape_keys = self.should_import_shape_keys options.should_import_shape_keys = self.should_import_shape_keys
options.scale = self.scale options.scale = self.scale
if self.bdk_repository_id:
options.bdk_repository_id = self.bdk_repository_id
if not options.should_import_mesh and not options.should_import_skeleton: if not options.should_import_mesh and not options.should_import_skeleton:
self.report({'ERROR'}, 'Nothing to import') self.report({'ERROR'}, 'Nothing to import')
return {'CANCELLED'} return {'CANCELLED'}

View File

@@ -25,6 +25,7 @@ class PskImportOptions:
self.bone_length = 1.0 self.bone_length = 1.0
self.should_import_materials = True self.should_import_materials = True
self.scale = 1.0 self.scale = 1.0
self.bdk_repository_id = None
class ImportBone: class ImportBone:
@@ -130,7 +131,8 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
# Material does not yet exist, and we have the BDK addon installed. # Material does not yet exist, and we have the BDK addon installed.
# Attempt to load it using BDK addon's operator. # Attempt to load it using BDK addon's operator.
material_reference = psk.material_references[material_index] material_reference = psk.material_references[material_index]
if material_reference and bpy.ops.bdk.link_material(reference=material_reference) == {'FINISHED'}: repository_id = options.bdk_repository_id if options.bdk_repository_id is not None else ''
if material_reference and bpy.ops.bdk.link_material(reference=material_reference, repository_id=repository_id) == {'FINISHED'}:
material = bpy.data.materials[material_name] material = bpy.data.materials[material_name]
else: else:
# Just create a blank material. # Just create a blank material.

View File

@@ -1,3 +1,4 @@
import os
from ctypes import Structure, sizeof from ctypes import Structure, sizeof
from typing import Type from typing import Type
@@ -34,6 +35,9 @@ def write_psk(psk: Psk, path: str):
elif len(psk.bones) == 0: elif len(psk.bones) == 0:
raise RuntimeError(f'At least one bone must be marked for export') raise RuntimeError(f'At least one bone must be marked for export')
# Make the directory for the file if it doesn't exist.
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'wb') as fp: with open(path, 'wb') as fp:
_write_section(fp, b'ACTRHEAD') _write_section(fp, b'ACTRHEAD')
_write_section(fp, b'PNTS0000', Vector3, psk.points) _write_section(fp, b'PNTS0000', Vector3, psk.points)

View File

@@ -2,7 +2,6 @@ import re
import typing import typing
from typing import List, Iterable from typing import List, Iterable
import addon_utils
import bpy.types import bpy.types
from bpy.types import NlaStrip, Object, AnimData from bpy.types import NlaStrip, Object, AnimData
@@ -164,5 +163,5 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
return bone_names return bone_names
def is_bdk_addon_loaded(): def is_bdk_addon_loaded() -> bool:
return addon_utils.check('bdk_addon')[1] return bpy.ops.bdk is not None and bpy.ops.bdk.link_material is not None