Compare commits
1 Commits
7.1.1
...
blender-4.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
275f246458 |
46
.github/workflows/main.yml
vendored
46
.github/workflows/main.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Build Extension
|
name: Verify Addon
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -11,44 +11,22 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
BLENDER_VERSION: blender-4.2.0-linux-x64
|
BLENDER_VERSION: blender-4.2.0-beta+v42.d19d23e91f65-linux.x86_64-release
|
||||||
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://mirrors.iu13.net/blender/release/Blender4.2/${{ env.BLENDER_VERSION }}.tar.xz" >> $GITHUB_ENV
|
echo "BLENDER_URL=https://cdn.builder.blender.org/download/daily/${{ env.BLENDER_VERSION }}.tar.xz" >> $GITHUB_ENV
|
||||||
- name: Install Blender Dependencies
|
- name: Install Blender Dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get install libxxf86vm-dev -y
|
apt install libxxf86vm-dev -y
|
||||||
sudo apt-get install libxfixes3 -y
|
apt install libxfixes3 -y
|
||||||
sudo apt-get install libxi-dev -y
|
apt install libxi-dev -y
|
||||||
sudo apt-get install libxkbcommon-x11-0 -y
|
apt install libxkbcommon-x11-0 -y
|
||||||
sudo apt-get install libgl1-mesa-glx -y
|
apt install libgl1-mesa-glx -y
|
||||||
- name: Download & Extract Blender
|
- name: Download Blender
|
||||||
run: |
|
run: |
|
||||||
wget -q $BLENDER_URL
|
wget $BLENDER_URL
|
||||||
tar -xf $BLENDER_FILENAME
|
tar -xvzf $BLENDER_FILENAME
|
||||||
rm -rf $BLENDER_FILENAME
|
rm -rf $BLENDER_FILENAME
|
||||||
- name: Add Blender executable to path
|
- uses: actions/checkout@v3
|
||||||
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
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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.
|
||||||
39
README.md
39
README.md
@@ -1,6 +1,5 @@
|
|||||||
[](https://www.blender.org/download/ "Download Blender")
|
[](https://www.blender.org/download/ "Download Blender")
|
||||||
[](https://github.com/DarklightGames/io_scene_psk_psa/releases/)
|
[](https://github.com/DarklightGames/io_scene_psk_psa/releases/)
|
||||||
[](https://github.com/DarklightGames/io_scene_psk_psa/actions/workflows/main.yml)
|
|
||||||
|
|
||||||
[](https://ko-fi.com/L4L3853VR)
|
[](https://ko-fi.com/L4L3853VR)
|
||||||
|
|
||||||
@@ -8,41 +7,35 @@ 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) is preserved on import, allowing this data to be reused on export.
|
* PSA sequence metadata (e.g., frame rate, sequence name) 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), 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.
|
* 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.
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
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.
|
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.
|
||||||
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.
|
||||||
@@ -77,5 +70,3 @@ 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.
|
|
||||||
|
|||||||
@@ -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.1"
|
version = "7.1.0"
|
||||||
name = "Unreal PSK/PSA (.psk/.psa)"
|
name = "Unreal Mesh & Animation (.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,6 +22,3 @@ paths_exclude_pattern = [
|
|||||||
"/.github/",
|
"/.github/",
|
||||||
".gitignore",
|
".gitignore",
|
||||||
]
|
]
|
||||||
|
|
||||||
[permissions]
|
|
||||||
files = "Import/export PSK and PSA files from/to disk"
|
|
||||||
|
|||||||
@@ -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'}
|
||||||
bl_description = 'Export actions to PSA'
|
__doc__ = '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,94 +239,79 @@ class PSA_OT_export(Operator, ExportHelper):
|
|||||||
layout = self.layout
|
layout = self.layout
|
||||||
pg = getattr(context.scene, 'psa_export')
|
pg = getattr(context.scene, 'psa_export')
|
||||||
|
|
||||||
sequences_header, sequences_panel = layout.panel('Sequences', default_closed=False)
|
# FPS
|
||||||
sequences_header.label(text='Sequences', icon='ACTION')
|
layout.prop(pg, 'fps_source', text='FPS')
|
||||||
|
if pg.fps_source == 'CUSTOM':
|
||||||
|
layout.prop(pg, 'fps_custom', text='Custom')
|
||||||
|
|
||||||
if sequences_panel:
|
# SOURCE
|
||||||
flow = sequences_panel.grid_flow()
|
layout.prop(pg, 'sequence_source', text='Source')
|
||||||
|
|
||||||
|
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, 'sequence_source', text='Source')
|
flow.prop(pg, 'nla_track')
|
||||||
|
|
||||||
if pg.sequence_source in {'TIMELINE_MARKERS', 'NLA_TRACK_STRIPS'}:
|
# SELECT ALL/NONE
|
||||||
# ANIMDATA SOURCE
|
row = layout.row(align=True)
|
||||||
flow.prop(pg, 'should_override_animation_data')
|
row.label(text='Select')
|
||||||
if pg.should_override_animation_data:
|
row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||||
flow.prop(pg, 'animation_data_override', text=' ')
|
row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||||
|
|
||||||
if pg.sequence_source == 'NLA_TRACK_STRIPS':
|
# ACTIONS
|
||||||
flow = sequences_panel.grid_flow()
|
if pg.sequence_source == 'ACTIONS':
|
||||||
flow.use_property_split = True
|
rows = max(3, min(len(pg.action_list), 10))
|
||||||
flow.use_property_decorate = False
|
layout.template_list('PSA_UL_export_sequences', '', pg, 'action_list', pg, 'action_list_index', rows=rows)
|
||||||
flow.prop(pg, 'nla_track')
|
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||||
|
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)
|
||||||
|
|
||||||
# SELECT ALL/NONE
|
col = layout.column()
|
||||||
row = sequences_panel.row(align=True)
|
col.use_property_split = True
|
||||||
row.label(text='Select')
|
col.use_property_decorate = False
|
||||||
row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
|
col.prop(pg, 'sequence_name_prefix')
|
||||||
row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
col.prop(pg, 'sequence_name_suffix')
|
||||||
|
|
||||||
# ACTIONS
|
# Determine if there is going to be a naming conflict and display an error, if so.
|
||||||
if pg.sequence_source == 'ACTIONS':
|
selected_items = [x for x in pg.action_list if x.is_selected]
|
||||||
rows = max(3, min(len(pg.action_list), 10))
|
action_names = [x.name for x in selected_items]
|
||||||
sequences_panel.template_list('PSA_UL_export_sequences', '', pg, 'action_list', pg, 'action_list_index', rows=rows)
|
action_name_counts = Counter(action_names)
|
||||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
for action_name, count in action_name_counts.items():
|
||||||
rows = max(3, min(len(pg.marker_list), 10))
|
if count > 1:
|
||||||
sequences_panel.template_list('PSA_UL_export_sequences', '', pg, 'marker_list', pg, 'marker_list_index', rows=rows)
|
layout.label(text=f'Duplicate action: {action_name}', icon='ERROR')
|
||||||
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
|
break
|
||||||
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)
|
|
||||||
|
|
||||||
flow = sequences_panel.grid_flow()
|
layout.separator()
|
||||||
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
|
||||||
bones_header, bones_panel = layout.panel('Bones', default_closed=False)
|
row = layout.row(align=True)
|
||||||
bones_header.label(text='Bones', icon='BONE_DATA')
|
row.prop(pg, 'bone_filter_mode', text='Bones')
|
||||||
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 = bones_panel.row(align=True)
|
row = layout.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))
|
||||||
bones_panel.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index',
|
layout.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index',
|
||||||
rows=rows)
|
rows=rows)
|
||||||
|
|
||||||
flow = bones_panel.grid_flow()
|
layout.prop(pg, 'should_enforce_bone_name_restrictions')
|
||||||
flow.use_property_split = True
|
|
||||||
flow.use_property_decorate = False
|
|
||||||
flow.prop(pg, 'should_enforce_bone_name_restrictions')
|
|
||||||
|
|
||||||
# ADVANCED
|
layout.separator()
|
||||||
advanced_header, advanced_panel = layout.panel('Advanced', default_closed=False)
|
|
||||||
advanced_header.label(text='Advanced')
|
|
||||||
|
|
||||||
if advanced_panel:
|
# ROOT MOTION
|
||||||
flow = advanced_panel.grid_flow()
|
layout.prop(pg, 'root_motion', text='Root Motion')
|
||||||
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):
|
||||||
|
|||||||
@@ -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', 'ACTION', 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', 'PROPERTIES', 1),
|
||||||
('CUSTOM', 'Custom', '', 2)
|
('CUSTOM', 'Custom', '', 2)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ 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)
|
||||||
|
|
||||||
@@ -260,7 +261,6 @@ 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'
|
||||||
bl_export_operator = 'psa_export.export'
|
|
||||||
bl_file_extensions = '.psa'
|
bl_file_extensions = '.psa'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -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, Collection, Context
|
from bpy.types import Armature, Material
|
||||||
|
|
||||||
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,28 +20,30 @@ 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.object_eval_state = 'EVALUATED'
|
self.use_raw_mesh_data = True
|
||||||
self.materials: List[Material] = []
|
self.materials: List[Material] = []
|
||||||
self.should_enforce_bone_name_restrictions = False
|
self.should_enforce_bone_name_restrictions = False
|
||||||
|
|
||||||
|
|
||||||
def get_mesh_objects_for_collection(collection: Collection):
|
def get_psk_input_objects(context) -> PskInputObjects:
|
||||||
for obj in collection.all_objects:
|
input_objects = PskInputObjects()
|
||||||
if obj.type == 'MESH':
|
for selected_object in context.view_layer.objects.selected:
|
||||||
yield obj
|
if selected_object.type == 'MESH':
|
||||||
|
input_objects.mesh_objects.append(selected_object)
|
||||||
|
|
||||||
|
if len(input_objects.mesh_objects) == 0:
|
||||||
|
raise RuntimeError('At least one mesh must be selected')
|
||||||
|
|
||||||
def get_mesh_objects_for_context(context: Context):
|
for mesh_object in input_objects.mesh_objects:
|
||||||
for obj in context.view_layer.objects.selected:
|
if len(mesh_object.data.materials) == 0:
|
||||||
if obj.type == 'MESH':
|
raise RuntimeError(f'Mesh "{mesh_object.name}" must have at least one material')
|
||||||
yield obj
|
|
||||||
|
|
||||||
|
# Ensure that there are either no armature modifiers (static mesh)
|
||||||
def get_armature_for_mesh_objects(mesh_objects: List[Object]) -> Optional[Object]:
|
# or that there is exactly one armature modifier object shared between
|
||||||
# Ensure that there are either no armature modifiers (static mesh) or that there is exactly one armature modifier
|
# all selected meshes
|
||||||
# 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
|
||||||
@@ -51,46 +53,21 @@ def get_armature_for_mesh_objects(mesh_objects: List[Object]) -> Optional[Object
|
|||||||
|
|
||||||
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(
|
raise RuntimeError(f'All selected meshes must have the same armature modifier, encountered {len(armature_modifier_names)} ({", ".join(armature_modifier_names)})')
|
||||||
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:
|
||||||
return list(armature_modifier_objects)[0]
|
input_objects.armature_object = 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) -> PskInputObjects:
|
|
||||||
mesh_objects = list(get_mesh_objects_for_collection(collection))
|
|
||||||
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, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult:
|
def build_psk(context, 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()
|
||||||
@@ -183,48 +160,47 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
|
|||||||
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
|
||||||
match options.object_eval_state:
|
if options.use_raw_mesh_data:
|
||||||
case 'ORIGINAL':
|
mesh_object = input_mesh_object
|
||||||
mesh_object = input_mesh_object
|
mesh_data = input_mesh_object.data
|
||||||
mesh_data = input_mesh_object.data
|
else:
|
||||||
case 'EVALUATED':
|
# Create a copy of the mesh object after non-armature modifiers are applied.
|
||||||
# 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)
|
||||||
|
|
||||||
@@ -311,12 +287,6 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
|
|||||||
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.
|
||||||
@@ -334,18 +304,8 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
|
|||||||
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
|
|
||||||
|
|
||||||
# Assign vertices that have not been assigned weights to the root bone.
|
if not options.use_raw_mesh_data:
|
||||||
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
|
||||||
|
|||||||
@@ -1,40 +1,35 @@
|
|||||||
from typing import List
|
from bpy.props import StringProperty
|
||||||
|
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 .properties import object_eval_state_items
|
from ..builder import build_psk, PskBuildOptions, get_psk_input_objects
|
||||||
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_for_context(context)
|
input_objects = get_psk_input_objects(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 get_materials_for_mesh_objects(mesh_objects: List[Object]):
|
def populate_material_list(mesh_objects, material_list):
|
||||||
|
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
|
||||||
@@ -77,89 +72,11 @@ 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'
|
|
||||||
)
|
|
||||||
|
|
||||||
def execute(self, context):
|
|
||||||
collection = bpy.data.collections.get(self.collection)
|
|
||||||
|
|
||||||
try:
|
|
||||||
input_objects = get_psk_input_objects_for_collection(collection)
|
|
||||||
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')
|
|
||||||
|
|
||||||
# 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'}
|
||||||
bl_description = 'Export mesh and armature to PSK'
|
__doc__ = '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'})
|
||||||
|
|
||||||
@@ -171,7 +88,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_for_context(context)
|
input_objects = get_psk_input_objects(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'}
|
||||||
@@ -193,7 +110,7 @@ class PSK_OT_export(Operator, ExportHelper):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
try:
|
try:
|
||||||
get_psk_input_objects_for_context(context)
|
get_psk_input_objects(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
|
||||||
@@ -201,20 +118,16 @@ 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('Mesh', default_closed=False)
|
mesh_header, mesh_panel = layout.panel('01_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:
|
||||||
flow = mesh_panel.grid_flow(row_major=True)
|
mesh_panel.prop(pg, 'use_raw_mesh_data')
|
||||||
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('Bones', default_closed=False)
|
bones_header, bones_panel = layout.panel('02_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
|
||||||
@@ -233,7 +146,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('Materials', default_closed=False)
|
materials_header, materials_panel = layout.panel('03_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()
|
||||||
@@ -244,19 +157,16 @@ 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 = getattr(context.scene, 'psk_export')
|
pg = 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.object_eval_state = pg.object_eval_state
|
options.use_raw_mesh_data = pg.use_raw_mesh_data
|
||||||
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, input_objects, options)
|
result = build_psk(context, 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)
|
||||||
@@ -275,5 +185,4 @@ 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,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ 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()
|
||||||
@@ -29,11 +23,7 @@ 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)
|
||||||
object_eval_state: EnumProperty(
|
use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh')
|
||||||
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(
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ 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 = 'Unreal PSK'
|
bl_label = 'File handler for Unreal PSK/PSKX import'
|
||||||
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
|
||||||
@@ -27,7 +26,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'}
|
||||||
bl_description = 'Import a PSK file'
|
__doc__ = 'Load 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(
|
||||||
@@ -102,12 +101,6 @@ 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)
|
||||||
@@ -125,9 +118,6 @@ 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'}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ 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:
|
||||||
@@ -131,7 +130,7 @@ 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, repository_id=options.bdk_repository_id) == {'FINISHED'}:
|
if material_reference and bpy.ops.bdk.link_material(reference=material_reference) == {'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.
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import os
|
|
||||||
from ctypes import Structure, sizeof
|
from ctypes import Structure, sizeof
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
@@ -35,9 +34,6 @@ 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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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,6 +165,4 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
|
|||||||
|
|
||||||
|
|
||||||
def is_bdk_addon_loaded():
|
def is_bdk_addon_loaded():
|
||||||
# TODO: this does not work anymore for *reasons*. Just check if bpy.ops.bdk.link_material exists.
|
return addon_utils.check('bdk_addon')[1]
|
||||||
# return addon_utils.check('bdk_addon')[1]
|
|
||||||
return bpy.ops.bdk.link_material is not None
|
|
||||||
|
|||||||
Reference in New Issue
Block a user