Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c51a8f21df | ||
|
|
06b1dd10a8 | ||
|
|
ff090baffb | ||
|
|
7e1693dbb3 | ||
|
|
56c847e791 | ||
|
|
d48534f1a5 | ||
|
|
ea539d542f | ||
|
|
fb02742381 | ||
|
|
d4d46bea66 | ||
|
|
a93450eab9 | ||
|
|
c65fdaa6a4 | ||
|
|
6b8088225a | ||
|
|
e27b078866 | ||
|
|
b67c734687 | ||
|
|
226e403925 | ||
|
|
d81477673b | ||
|
|
4d41f1af83 | ||
|
|
5d3c7cc570 | ||
|
|
15e2c6ccdd | ||
|
|
14116963bb | ||
|
|
ead1e3c793 | ||
|
|
ce1a411200 | ||
|
|
11bf205fe2 | ||
|
|
f7bbe911ea | ||
|
|
8c49c8f34e | ||
|
|
82eaddf1cb | ||
|
|
eb8cee6973 | ||
|
|
c2d7eecb4f | ||
|
|
44100a50f0 | ||
|
|
9125606bc4 | ||
|
|
0a7804b5ab | ||
|
|
ced03afafe | ||
|
|
9438a35cd1 | ||
|
|
5a870104f1 | ||
|
|
564f7ec221 | ||
|
|
b2f5985681 | ||
|
|
3d3bbb9296 | ||
|
|
fda976d083 | ||
|
|
7e6911c709 | ||
|
|
30586fa8bb | ||
|
|
361a7f0218 | ||
|
|
b471229584 | ||
|
|
d0f64a6546 | ||
|
|
82310d695c | ||
|
|
e9ba117fa9 | ||
|
|
0c11b326af | ||
|
|
d0fe7d9786 | ||
|
|
68c7d93d6a | ||
|
|
e5fa255899 | ||
|
|
3cf10abe91 | ||
|
|
b1a6fb9dea | ||
|
|
89767569ec | ||
|
|
b23677f291 | ||
|
|
9fe5aa0388 | ||
|
|
c8e5b8eb47 | ||
|
|
76440affdb | ||
|
|
403e9a5cca | ||
|
|
7dce3b4920 | ||
|
|
2a5a4b2a3d | ||
|
|
a1d5fdd464 | ||
|
|
122e765bca | ||
|
|
7adf817673 | ||
|
|
f92231091c | ||
|
|
94658ac221 | ||
|
|
c18ac85eed | ||
|
|
afcfebe84a | ||
|
|
cf13aade98 | ||
|
|
14c2f585dc | ||
|
|
7907341a22 | ||
|
|
46443162a7 | ||
|
|
10fe75c2aa | ||
|
|
32e0d89dbd | ||
|
|
4db8764677 | ||
|
|
f185ffbe16 | ||
|
|
3d460a15e3 | ||
|
|
da39c14464 | ||
|
|
214f19ff8c | ||
|
|
ea5d0c6ac2 | ||
|
|
b13bcfee0a | ||
|
|
83e65687ac | ||
|
|
63fb6f7d09 | ||
|
|
3de1f075dd | ||
|
|
560ec8fecd | ||
|
|
8c74987f5b | ||
|
|
60c7f2125a | ||
|
|
07ccc8c650 | ||
|
|
5bbb1512e0 | ||
|
|
c4c00ca49e | ||
|
|
b6e5a13e5f | ||
|
|
d823af9526 | ||
|
|
b20d19d072 | ||
|
|
66fa656b12 | ||
|
|
a73655fc98 | ||
|
|
38a8dab100 |
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Versions**
|
||||
Blender: (example: 3.6.2)
|
||||
io_scene_psk_psa: (example: 5.0.0)
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Files**
|
||||
Please post attachments of the PSK and/or PSA files that you are using to encounter the bug, if any.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "extern/io_scene_psk_psa-translations"]
|
||||
path = extern/io_scene_psk_psa-translations
|
||||
url = https://github.com/DarklightGames/io_scene_psk_psa-translations.git
|
||||
84
README-JP.md
Normal file
84
README-JP.md
Normal file
@@ -0,0 +1,84 @@
|
||||
[](https://www.blender.org/download/ "Download Blender")
|
||||
[](https://github.com/DarklightGames/io_scene_psk_psa/releases/)
|
||||
|
||||
[](https://ko-fi.com/L4L3853VR)
|
||||
|
||||
この Blender アドオンを使用すると、Unreal Engine の多くのバージョンで使用されている [PSK および PSA ファイル形式](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) との間でメッシュとアニメーションをインポートおよびエクスポートできます。
|
||||
|
||||
# 互換性
|
||||
|
||||
| Blenderのバージョン | アドオンのバージョン | Long Term Support |
|
||||
|--------------------------------------------------------------|--------------------------------------------------------------------------------|-------------------|
|
||||
| 4.0+ | [最新](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | 未定 |
|
||||
| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.5](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.5) | ✅️ 2025年6月 |
|
||||
| [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) | ✅️ 202年9月 |
|
||||
|
||||
バグ修正は、[Blender の LTS メンテナンス期間](https://www.blender.org/download/lts/)中のレガシー アドオン バージョンに対して発行されます。 LTS 期間が終了すると、レガシー アドオン バージョンはこのリポジトリのメンテナによってサポートされなくなりますが、バグ修正のためのプル リクエストは受け付けます。
|
||||
|
||||
# 機能
|
||||
* PSK および PSA ファイルをインポートする。
|
||||
* 非標準のファイル セクション データ (頂点法線、追加の UV チャンネル、頂点カラー、シェイプ キーなど) をインポートします。
|
||||
* 多数のシーケンスを含む PSA ファイルを操作する場合の効率的なワークフローのためのきめ細かい PSA シーケンス インポート。
|
||||
* PSA シーケンスのメタデータ (フレーム レートなど) はインポート時に保存されるため、このデータをエクスポート時に再利用できます。
|
||||
* ボーン コレクションは PSK/PSA エクスポートから除外できます。 これは、IK コントローラなどの寄与しないボーンを除外する場合に便利です。
|
||||
* PSA シーケンスは、アクションから直接エクスポートすることも、シーンの [タイムライン マーカー](https://docs.blender.org/manual/ja/latest/animation/markers.html) または NLA トラック ストリップを使用して描写することもでき、[NLA](https://docs.blender.org/manual/ja/latest/editors/nla/index.html) シーケンスを作成するとき。
|
||||
* 複数のメッシュ オブジェクトをエクスポートするときにマテリアル スロットを手動で並べ替えます。
|
||||
|
||||
# 取り付け
|
||||
1. 最新バージョンの zip ファイルを [Releases](https://github.com/DarklightGames/io_export_psk_psa/releases) ページからダウンロードします。
|
||||
2. Blender 4.0.0 以降を開きます。
|
||||
3. Blender の設定に移動します (`編集` > `プリファレンス`)。
|
||||
4. `アドオン`タブを選択します。
|
||||
5. `インストール...`ボタンをクリックします。
|
||||
6. 先ほどダウンロードした `.zip` ファイルを選択し、`アドオンのインストール`をクリックします。
|
||||
7. 新しく追加された`インポート-エクスポート: PSK/PSA Importer/Exporter`アドオンを有効にします。
|
||||
|
||||
# 使用方法
|
||||
## PSKのエクスポート
|
||||
1. エクスポートするアーマチュア オブジェクトを選択します。
|
||||
2. `ファイル` > `エクスポート` > `Unreal PSK (.psk)`に移動します.
|
||||
3. ファイル名を入力して`エクスポート`をクリックします.
|
||||
|
||||
## PSK/PSKXのインポート
|
||||
1. `ファイル` > `インポート` > `Unreal PSK (.psk/.pskx)`に移動します.
|
||||
2. インポートしたい PSK ファイルを選択し、`インポート`をクリックします。
|
||||
|
||||
## PSAのエクスポート
|
||||
1. エクスポートするアーマチュア オブジェクトを選択します。
|
||||
2. `ファイル` > `エクスポート` > `Unreal PSA (.psa)`に移動します.
|
||||
3. ファイル名を入力し、`エクスポート`をクリックします。
|
||||
|
||||
## PSAのインポート
|
||||
1. アニメーションを読み込みたいアーマチュアを選択します。
|
||||
2. `ファイル` > `インポート` > `Unreal PSA (.psa)`に移動します。
|
||||
3. アニメーションのインポート元となる PSA ファイルを選択します.
|
||||
4. インポートしたいアニメーションを選択し、`インポート`をクリックします。
|
||||
|
||||
> アーマチュアに適用されたインポートされたアクションを確認するには、[ドープ シート](https://docs.blender.org/manual/ja/latest/editors/dope_sheet/introduction.html) エディタまたは [ノンリニアアニメーション](https://docs.blender.org/manual/ja/latest/editors/nla/introduction.html) エディタを使用する必要があります。
|
||||
|
||||
## ローカライゼーション
|
||||
|
||||
現在、次の言語がサポートされています。
|
||||
|
||||
| 言語 | 地位 | 方法 |
|
||||
|-|-|-|
|
||||
| 英語 (English) | ✅️ | 🧑 |
|
||||
| 日本語 | ✅️ | 🤖 |
|
||||
|
||||
翻訳に問題がある場合は、お気軽に [Weblate](https://weblate.darklightgames.com/projects/io_scene_psk_psa/) に修正を送信してください。
|
||||
|
||||
自分の母国語に対するサポートの追加をご希望の場合は、[問題を報告して](http://github.com/DarklightGames/io_scene_psk_psa/issues)ください。
|
||||
|
||||
# FAQ
|
||||
|
||||
## PSA をインポートした後にアニメーションが表示されないのはなぜですか?
|
||||
|
||||
PSA アニメーションをインポートしても、アクションはアーマチュアに自動的に適用されません。これは、PSA には複数のシーケンスをインポートできるため、また、インポーターが必要のないときにシーンを変更するのは悪い形式であるためです。
|
||||
|
||||
PSA インポーターは、PSA 内の選択されたシーケンスごとに[アクション](https://docs.blender.org/manual/ja/latest/animation/actions.html)を作成します。これらのアクションは、[アクション エディター](https://docs.blender.org/manual/ja/latest/editors/dope_sheet/action.html)または [NLA エディター](https://docs.blender.org/manual/ja/latest/editors/nla/index.html)を介してアーマチュアに適用できます。
|
||||
|
||||
## [UE Viewer](https://www.gildor.org/en/projects/umodel) から抽出した PSK をインポートすると、メッシュ面の法線が不正確になるのはなぜですか?
|
||||
|
||||
モデルのメッシュ法線を保持することがワークフローにとって重要な場合、UE Viewer から PSK ファイルをエクスポートすることはお勧めできません。これは、UE Viewer が元の[スムージング グループ](https://en.wikipedia.org/wiki/Smoothing_group)を再構築しようとしないためです。その結果、インポートされた PSK ファイルの法線は、Blender にインポートされたときに不正確になるため、手動で修正する必要があります。
|
||||
|
||||
回避策として、[glTF](https://en.wikipedia.org/wiki/GlTF) 形式は明示的な法線をサポートしており、UE Viewer はエクスポート時にメッシュ法線を正しく保存できるため、代わりに UE Viewer から glTF メッシュをエクスポートすることをお勧めします。ただし、インポートされた glTF アーマチュアは、Blender にインポートされたときにボーンの方向が間違っている可能性があります。これを軽減するには、PSK のアーマチュアと glTF のメッシュを組み合わせて最良の結果を得ることができます。
|
||||
41
README.md
41
README.md
@@ -1,24 +1,32 @@
|
||||
[](https://www.blender.org/download/ "Download Blender")
|
||||
[](https://www.blender.org/download/ "Download Blender")
|
||||
[](https://github.com/DarklightGames/io_scene_psk_psa/releases/)
|
||||
|
||||
[](https://ko-fi.com/L4L3853VR)
|
||||
|
||||
This Blender add-on allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many versions of the Unreal Engine.
|
||||
This Blender addon allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many versions of the Unreal Engine.
|
||||
|
||||
> **NOTE**: This addon requires Blender 3.4+. If this is not available to you, install version [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0), as it has a minimum Blender version of 2.9, but is no longer receiving new features.
|
||||
# Compatibility
|
||||
|
||||
| Blender Version | Addon Version | Long Term Support |
|
||||
|--------------------------------------------------------------|--------------------------------------------------------------------------------|-------------------|
|
||||
| 4.0+ | [latest](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | TBD |
|
||||
| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.5](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.5) | ✅️ June 2025 |
|
||||
| [2.93 - 3.3](https://www.blender.org/download/releases/3-3/) | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | ✅️ September 2024 |
|
||||
|
||||
Bug fixes will be issued for legacy addon versions that are under [Blender's LTS maintenance period](https://www.blender.org/download/lts/). Once the LTS period has ended, legacy addon versions will no longer be supported by the maintainers of this repository, although we will accept pull requests for bug fixes.
|
||||
|
||||
# Features
|
||||
* Full PSK/PSA import and export capabilities.
|
||||
* 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.
|
||||
* PSA sequence metadata (e.g., frame rate, sequence name) is preserved on import, allowing this data to be reused on export.
|
||||
* Specific [bone groups](https://docs.blender.org/manual/en/latest/animation/armatures/properties/bone_groups.html) 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), allowing direct use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences.
|
||||
* 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.
|
||||
* Manual re-ordering of material slots when exporting multiple mesh objects.
|
||||
|
||||
# Installation
|
||||
1. Download the zip file for the latest version from the [releases](https://github.com/DarklightGames/io_export_psk_psa/releases) page.
|
||||
2. Open Blender 3.4.0 or later.
|
||||
2. Open Blender 4.0.0 or later.
|
||||
3. Navigate to the Blender Preferences (`Edit` > `Preferences`).
|
||||
4. Select the `Add-ons` tab.
|
||||
5. Click the `Install...` button.
|
||||
@@ -46,7 +54,28 @@ This Blender add-on allows you to import and export meshes and animations to and
|
||||
3. Select the PSA file you want to import.
|
||||
4. Select the sequences that you want to import and click `Import`.
|
||||
|
||||
> Note that in order to see the imported actions applied to your armature, you must use the [Dope Sheet](https://docs.blender.org/manual/en/latest/editors/dope_sheet/introduction.html) or [Nonlinear Animation](https://docs.blender.org/manual/en/latest/editors/nla/introduction.html) editors.
|
||||
|
||||
## Localization
|
||||
|
||||
The following languages are currently supported:
|
||||
|
||||
| Language | Status | Method |
|
||||
|----------------|--------|--------|
|
||||
| English | ✅️ | 🧑 |
|
||||
| Japanese (日本語) | ✅️ | 🤖 |
|
||||
|
||||
If you notice any issues with the translations, please feel free to submit a correction on [Weblate](https://weblate.darklightgames.com/projects/io_scene_psk_psa/).
|
||||
|
||||
If you would like to see support add for your own native language, please [open an issue](http://github.com/DarklightGames/io_scene_psk_psa/issues) and we will consider adding it.
|
||||
|
||||
# FAQ
|
||||
|
||||
## Why can't I see the animations imported from my PSA?
|
||||
Simply importing an animation into the scene will not automatically apply the action to the armature. This is in part because a PSA can have multiple sequences imported from it, and also that it's generally bad form for importers to modify the scene when they don't need to.
|
||||
|
||||
The PSA importer creates [Actions](https://docs.blender.org/manual/en/latest/animation/actions.html) for each of the selected sequences in the PSA. These actions can be applied to your armature via the [Action Editor](https://docs.blender.org/manual/en/latest/editors/dope_sheet/action.html) or [NLA Editor](https://docs.blender.org/manual/en/latest/editors/nla/index.html).
|
||||
|
||||
## Why are the mesh normals not accurate when importing a PSK extracted from [UE Viewer](https://www.gildor.org/en/projects/umodel)?
|
||||
If preserving the mesh normals of models is important for your workflow, it is *not recommended* to export PSK files from UE Viewer. This is because UE Viewer makes no attempt to reconstruct the original [smoothing groups](https://en.wikipedia.org/wiki/Smoothing_group). As a result, the normals of imported PSK files will be incorrect when imported into Blender and will need to be manually fixed.
|
||||
|
||||
|
||||
1
extern/io_scene_psk_psa-translations
vendored
Submodule
1
extern/io_scene_psk_psa-translations
vendored
Submodule
Submodule extern/io_scene_psk_psa-translations added at f1d4159ca3
@@ -1,13 +1,15 @@
|
||||
from bpy.app.handlers import persistent
|
||||
|
||||
bl_info = {
|
||||
"name": "PSK/PSA Importer/Exporter",
|
||||
"author": "Colin Basnett, Yurii Ti",
|
||||
"version": (5, 0, 3),
|
||||
"blender": (3, 4, 0),
|
||||
"description": "PSK/PSA Import/Export (.psk/.psa)",
|
||||
"warning": "",
|
||||
"doc_url": "https://github.com/DarklightGames/io_scene_psk_psa",
|
||||
"tracker_url": "https://github.com/DarklightGames/io_scene_psk_psa/issues",
|
||||
"category": "Import-Export"
|
||||
'name': 'PSK/PSA Importer/Exporter',
|
||||
'author': 'Colin Basnett, Yurii Ti',
|
||||
'version': (6, 2, 0),
|
||||
'blender': (4, 0, 0),
|
||||
'description': 'PSK/PSA Import/Export (.psk/.psa)',
|
||||
'warning': '',
|
||||
'doc_url': 'https://github.com/DarklightGames/io_scene_psk_psa',
|
||||
'tracker_url': 'https://github.com/DarklightGames/io_scene_psk_psa/issues',
|
||||
'category': 'Import-Export'
|
||||
}
|
||||
|
||||
if 'bpy' in locals():
|
||||
@@ -16,21 +18,26 @@ if 'bpy' in locals():
|
||||
importlib.reload(psx_data)
|
||||
importlib.reload(psx_helpers)
|
||||
importlib.reload(psx_types)
|
||||
importlib.reload(psx_i18n)
|
||||
|
||||
importlib.reload(psk_data)
|
||||
importlib.reload(psk_reader)
|
||||
importlib.reload(psk_writer)
|
||||
importlib.reload(psk_builder)
|
||||
importlib.reload(psk_importer)
|
||||
importlib.reload(psk_properties)
|
||||
importlib.reload(psk_ui)
|
||||
importlib.reload(psk_export_properties)
|
||||
importlib.reload(psk_export_operators)
|
||||
importlib.reload(psk_export_ui)
|
||||
importlib.reload(psk_import_operators)
|
||||
|
||||
importlib.reload(psa_data)
|
||||
importlib.reload(psa_config)
|
||||
importlib.reload(psa_reader)
|
||||
importlib.reload(psa_writer)
|
||||
importlib.reload(psa_builder)
|
||||
importlib.reload(psa_importer)
|
||||
importlib.reload(psa_export_properties)
|
||||
importlib.reload(psa_export_operators)
|
||||
importlib.reload(psa_export_ui)
|
||||
@@ -42,17 +49,21 @@ else:
|
||||
from . import data as psx_data
|
||||
from . import helpers as psx_helpers
|
||||
from . import types as psx_types
|
||||
from . import i18n as psx_i18n
|
||||
from .psk import data as psk_data
|
||||
from .psk import reader as psk_reader
|
||||
from .psk import writer as psk_writer
|
||||
from .psk import builder as psk_builder
|
||||
from .psk import importer as psk_importer
|
||||
from .psk import properties as psk_properties
|
||||
from .psk import ui as psk_ui
|
||||
from .psk.export import properties as psk_export_properties
|
||||
from .psk.export import operators as psk_export_operators
|
||||
from .psk.export import ui as psk_export_ui
|
||||
from .psk.import_ import operators as psk_import_operators
|
||||
|
||||
from .psa import data as psa_data
|
||||
from .psa import config as psa_config
|
||||
from .psa import reader as psa_reader
|
||||
from .psa import writer as psa_writer
|
||||
from .psa import builder as psa_builder
|
||||
@@ -68,6 +79,8 @@ import bpy
|
||||
from bpy.props import PointerProperty
|
||||
|
||||
classes = psx_types.classes +\
|
||||
psk_properties.classes +\
|
||||
psk_ui.classes +\
|
||||
psk_import_operators.classes +\
|
||||
psk_export_properties.classes +\
|
||||
psk_export_operators.classes +\
|
||||
@@ -99,17 +112,23 @@ def psa_import_menu_func(self, context):
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
bpy.types.TOPBAR_MT_file_export.append(psk_export_menu_func)
|
||||
bpy.types.TOPBAR_MT_file_import.append(psk_import_menu_func)
|
||||
bpy.types.TOPBAR_MT_file_export.append(psa_export_menu_func)
|
||||
bpy.types.TOPBAR_MT_file_import.append(psa_import_menu_func)
|
||||
bpy.types.Material.psk = PointerProperty(type=psk_properties.PSX_PG_material)
|
||||
bpy.types.Scene.psa_import = PointerProperty(type=psa_import_properties.PSA_PG_import)
|
||||
bpy.types.Scene.psa_export = PointerProperty(type=psa_export_properties.PSA_PG_export)
|
||||
bpy.types.Scene.psk_export = PointerProperty(type=psk_export_properties.PSK_PG_export)
|
||||
bpy.types.Action.psa_export = PointerProperty(type=psx_types.PSX_PG_action_export)
|
||||
|
||||
bpy.app.translations.register(__name__, psx_i18n.langs)
|
||||
|
||||
|
||||
def unregister():
|
||||
bpy.app.translations.unregister(__name__)
|
||||
del bpy.types.Material.psk
|
||||
del bpy.types.Scene.psa_import
|
||||
del bpy.types.Scene.psa_export
|
||||
del bpy.types.Scene.psk_export
|
||||
@@ -124,3 +143,16 @@ def unregister():
|
||||
|
||||
if __name__ == '__main__':
|
||||
register()
|
||||
|
||||
|
||||
@persistent
|
||||
def load_handler(dummy):
|
||||
# Convert old `psa_sequence_fps` property to new `psa_export.fps` property.
|
||||
# This is only needed for backwards compatibility with files that may have used older versions of the addon.
|
||||
for action in bpy.data.actions:
|
||||
if 'psa_sequence_fps' in action:
|
||||
action.psa_export.fps = action['psa_sequence_fps']
|
||||
del action['psa_sequence_fps']
|
||||
|
||||
|
||||
bpy.app.handlers.load_post.append(load_handler)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import datetime
|
||||
import re
|
||||
import typing
|
||||
from collections import Counter
|
||||
from typing import List, Iterable
|
||||
|
||||
import addon_utils
|
||||
@@ -9,24 +7,6 @@ import bpy.types
|
||||
from bpy.types import NlaStrip, Object, AnimData
|
||||
|
||||
|
||||
class Timer:
|
||||
def __enter__(self):
|
||||
self.start = datetime.datetime.now()
|
||||
self.interval = None
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.end = datetime.datetime.now()
|
||||
self.interval = self.end - self.start
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
if self.interval is not None:
|
||||
return self.interval
|
||||
else:
|
||||
return datetime.datetime.now() - self.start
|
||||
|
||||
|
||||
def rgb_to_srgb(c: float):
|
||||
if c > 0.0031308:
|
||||
return 1.055 * (pow(c, (1.0 / 2.4))) - 0.055
|
||||
@@ -34,7 +14,7 @@ def rgb_to_srgb(c: float):
|
||||
return 12.92 * c
|
||||
|
||||
|
||||
def get_nla_strips_in_timeframe(animation_data: AnimData, frame_min: float, frame_max: float) -> List[NlaStrip]:
|
||||
def get_nla_strips_in_frame_range(animation_data: AnimData, frame_min: float, frame_max: float) -> List[NlaStrip]:
|
||||
if animation_data is None:
|
||||
return []
|
||||
strips = []
|
||||
@@ -49,80 +29,94 @@ def get_nla_strips_in_timeframe(animation_data: AnimData, frame_min: float, fram
|
||||
return strips
|
||||
|
||||
|
||||
def populate_bone_group_list(armature_object: Object, bone_group_list: bpy.props.CollectionProperty) -> None:
|
||||
"""
|
||||
Updates the bone group collection.
|
||||
def populate_bone_collection_list(armature_object: Object, bone_collection_list: bpy.props.CollectionProperty) -> None:
|
||||
'''
|
||||
Updates the bone collections collection.
|
||||
|
||||
Bone group selections are preserved between updates unless none of the groups were previously selected;
|
||||
otherwise, all groups are selected by default.
|
||||
"""
|
||||
has_selected_groups = any([g.is_selected for g in bone_group_list])
|
||||
unassigned_group_is_selected, selected_assigned_group_names = True, []
|
||||
Bone collection selections are preserved between updates unless none of the groups were previously selected;
|
||||
otherwise, all collections are selected by default.
|
||||
'''
|
||||
has_selected_collections = any([g.is_selected for g in bone_collection_list])
|
||||
unassigned_collection_is_selected, selected_assigned_collection_names = True, []
|
||||
|
||||
if has_selected_groups:
|
||||
if armature_object is None:
|
||||
return
|
||||
|
||||
if has_selected_collections:
|
||||
# Preserve group selections before clearing the list.
|
||||
# We handle selections for the unassigned group separately to cover the edge case
|
||||
# where there might be an actual group with 'Unassigned' as its name.
|
||||
unassigned_group_idx, unassigned_group_is_selected = next(iter([
|
||||
(i, g.is_selected) for i, g in enumerate(bone_group_list) if g.index == -1]), (-1, False))
|
||||
unassigned_collection_idx, unassigned_collection_is_selected = next(iter([
|
||||
(i, g.is_selected) for i, g in enumerate(bone_collection_list) if g.index == -1]), (-1, False))
|
||||
|
||||
selected_assigned_group_names = [
|
||||
g.name for i, g in enumerate(bone_group_list) if i != unassigned_group_idx and g.is_selected]
|
||||
selected_assigned_collection_names = [
|
||||
g.name for i, g in enumerate(bone_collection_list) if i != unassigned_collection_idx and g.is_selected]
|
||||
|
||||
bone_group_list.clear()
|
||||
bone_collection_list.clear()
|
||||
|
||||
if armature_object and armature_object.pose:
|
||||
bone_group_counts = Counter(map(lambda x: x.bone_group, armature_object.pose.bones))
|
||||
armature = armature_object.data
|
||||
|
||||
item = bone_group_list.add()
|
||||
item.name = 'Unassigned'
|
||||
if armature is None:
|
||||
return
|
||||
|
||||
item = bone_collection_list.add()
|
||||
item.name = bpy.app.translations.pgettext_iface('Unassigned')
|
||||
item.index = -1
|
||||
item.count = 0 if None not in bone_group_counts else bone_group_counts[None]
|
||||
item.is_selected = unassigned_group_is_selected
|
||||
# Count the number of bones without an assigned bone collection
|
||||
item.count = sum(map(lambda bone: 1 if len(bone.collections) == 0 else 0, armature.bones))
|
||||
item.is_selected = unassigned_collection_is_selected
|
||||
|
||||
for bone_group_index, bone_group in enumerate(armature_object.pose.bone_groups):
|
||||
item = bone_group_list.add()
|
||||
item.name = bone_group.name
|
||||
item.index = bone_group_index
|
||||
item.count = 0 if bone_group not in bone_group_counts else bone_group_counts[bone_group]
|
||||
item.is_selected = bone_group.name in selected_assigned_group_names if has_selected_groups else True
|
||||
for bone_collection_index, bone_collection in enumerate(armature.collections):
|
||||
item = bone_collection_list.add()
|
||||
item.name = bone_collection.name
|
||||
item.index = bone_collection_index
|
||||
item.count = len(bone_collection.bones)
|
||||
item.is_selected = bone_collection.name in selected_assigned_collection_names if has_selected_collections else True
|
||||
|
||||
|
||||
def check_bone_names(bone_names: Iterable[str]):
|
||||
pattern = re.compile(r'^[a-zA-Z\d_\- ]+$')
|
||||
invalid_bone_names = [x for x in bone_names if pattern.match(x) is None]
|
||||
if len(invalid_bone_names) > 0:
|
||||
raise RuntimeError(f'The following bone names are invalid: {invalid_bone_names}.\n'
|
||||
f'Bone names must only contain letters, numbers, spaces, hyphens and underscores.\n'
|
||||
f'You can bypass this by disabling "Enforce Bone Name Restrictions" in the export settings.')
|
||||
message = bpy.app.translations.pgettext_iface(
|
||||
'The following bone names are invalid: {invalid_bone_names}.\n'
|
||||
'Bone names must only contain letters, numbers, spaces, hyphens and underscores.\n'
|
||||
'You can bypass this by disabling "Enforce Bone Name Restrictions" in the export settings'
|
||||
)
|
||||
raise RuntimeError(message.format(invalid_bone_names=str(invalid_bone_names)))
|
||||
|
||||
|
||||
def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_group_indices: List[int]) -> List[str]:
|
||||
"""
|
||||
Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone groups.
|
||||
def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_collection_indices: List[int]) -> List[str]:
|
||||
'''
|
||||
Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone collections.
|
||||
|
||||
Note that the ancestors of bones within the bone groups will also be present in the returned list.
|
||||
Note that the ancestors of bones within the bone collections will also be present in the returned list.
|
||||
|
||||
:param armature_object: Blender object with type 'ARMATURE'
|
||||
:param bone_filter_mode: One of ['ALL', 'BONE_GROUPS']
|
||||
:param bone_group_indices: List of bone group indices to be exported.
|
||||
:param bone_filter_mode: One of ['ALL', 'BONE_COLLECTIONS']
|
||||
:param bone_collection_indices: List of bone collection indices to be exported.
|
||||
:return: A sorted list of bone indices that should be exported.
|
||||
"""
|
||||
'''
|
||||
if armature_object is None or armature_object.type != 'ARMATURE':
|
||||
raise ValueError('An armature object must be supplied')
|
||||
|
||||
armature_data = typing.cast(bpy.types.Armature, armature_object.data)
|
||||
bones = armature_data.bones
|
||||
pose_bones = armature_object.pose.bones
|
||||
bone_names = [x.name for x in bones]
|
||||
|
||||
# Get a list of the bone indices that we are explicitly including.
|
||||
bone_index_stack = []
|
||||
is_exporting_none_bone_groups = -1 in bone_group_indices
|
||||
for bone_index, pose_bone in enumerate(pose_bones):
|
||||
is_exporting_unassigned_bone_collections = -1 in bone_collection_indices
|
||||
bone_collections = list(armature_data.collections)
|
||||
|
||||
for bone_index, bone in enumerate(bones):
|
||||
# Check if this bone is in any of the collections in the bone collection indices list.
|
||||
this_bone_collection_indices = set(bone_collections.index(x) for x in bone.collections)
|
||||
is_in_exported_bone_collections = len(set(bone_collection_indices).intersection(this_bone_collection_indices)) > 0
|
||||
|
||||
if bone_filter_mode == 'ALL' or \
|
||||
(pose_bone.bone_group is None and is_exporting_none_bone_groups) or \
|
||||
(pose_bone.bone_group is not None and pose_bone.bone_group_index in bone_group_indices):
|
||||
(len(bone.collections) == 0 and is_exporting_unassigned_bone_collections) or \
|
||||
is_in_exported_bone_collections:
|
||||
bone_index_stack.append((bone_index, None))
|
||||
|
||||
# For each bone that is explicitly being added, recursively walk up the hierarchy and ensure that all of
|
||||
@@ -143,7 +137,7 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_g
|
||||
|
||||
# Split out the bone indices and the instigator bone names into separate lists.
|
||||
# We use the bone names for the return values because the bone name is a more universal way of referencing them.
|
||||
# For example, users of this function may modify bone lists, which would invalidate the indices and require a
|
||||
# For example, users of this function may modify bone lists, which would invalidate the indices and require an
|
||||
# index mapping scheme to resolve it. Using strings is more comfy and results in less code downstream.
|
||||
instigator_bone_names = [bones[x[1]].name if x[1] is not None else None for x in bone_indices]
|
||||
bone_names = [bones[x[0]].name for x in bone_indices]
|
||||
@@ -162,13 +156,18 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_g
|
||||
# TODO: in future, it would be preferential to have a readout of *all* instigator bones.
|
||||
instigator_bone_name = instigator_bone_names[bone_names.index(bone_name)]
|
||||
if instigator_bone_name is None:
|
||||
print(f'Root bone "{root_bone_name}" was included because {bone_name} was marked for export')
|
||||
message = bpy.app.translations.pgettext_iface('Root bone "{root_bone_name}" was included because {bone_name} was marked for export')
|
||||
message = message.format(root_bone_name=root_bone_name, bone_name=bone_name)
|
||||
print(message)
|
||||
break
|
||||
bone_name = instigator_bone_name
|
||||
|
||||
raise RuntimeError('Exported bone hierarchy must have a single root bone.\n'
|
||||
f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}.\n'
|
||||
f'Additional debugging information has been written to the console.')
|
||||
message = bpy.app.translations.pgettext_iface(
|
||||
'Exported bone hierarchy must have a single root bone.\n'
|
||||
'The bone hierarchy marked for export has {root_bone_count} root bones: {root_bone_names}.\n'
|
||||
'Additional debugging information has been written to the console.'
|
||||
)
|
||||
raise RuntimeError(message.format(root_bone_count=len(root_bones), root_bone_names=str(root_bone_names)))
|
||||
|
||||
return bone_names
|
||||
|
||||
|
||||
216
io_scene_psk_psa/i18n.py
Normal file
216
io_scene_psk_psa/i18n.py
Normal file
@@ -0,0 +1,216 @@
|
||||
langs = {'ja': {('*', 'Action Metadata'): 'アクションメタデータ',
|
||||
('*', 'Additional data will be written to the properties of the Action (e.g., frame rate)'): 'アクションのプロパティに追加データが書き込まれます '
|
||||
'(フレームレートなど)',
|
||||
('*', 'All bones will be exported'): 'すべてのボーンがエクスポートされます',
|
||||
('*', 'All selected meshes must have the same armature modifier, encountered {count} ({names})'): '選択したすべてのメッシュには同じアーマチュア修飾子が必要です。検出されたアーマチュア修飾子は '
|
||||
'{count} '
|
||||
'({names})',
|
||||
('*', 'An armature must be selected'): 'アーマチュアを選択する必要があります',
|
||||
('*', 'An armature object must be supplied'): 'アーマチュアオブジェクトを指定する必要があります',
|
||||
('*', 'Assign each imported action a fake user so that the data block is always saved'): 'インポートされた各アクションにフェイクユーザーを割り当てて、データブロックが常に保存されるようにします',
|
||||
('*', 'At least one bone must be marked for export'): '少なくとも 1 '
|
||||
'つのボーンをエクスポート対象としてマークする必要があります',
|
||||
('*', 'At least one mesh must be selected'): '少なくとも 1 '
|
||||
'つのメッシュを選択する必要があります',
|
||||
('*', 'Bone Collections'): 'ボーンコレクション',
|
||||
('*', 'Bone Filter'): 'ボーンフィルター',
|
||||
('*', 'Bone Length'): 'ボーンの長さ',
|
||||
('*', 'Bone Name Mapping'): 'ボーン名マッピング',
|
||||
('*', 'Bone name "{name}" contains characters that cannot be encoded in the Windows-1252 codepage'): 'ボーン名「{name}」には、Windows-1252 '
|
||||
'コードページではエンコードできない文字が含まれています',
|
||||
('*', 'Bone names must match exactly'): 'ボーン名は正確に一致する必要があります',
|
||||
('*', 'Bone names restrictions will be enforced. Note that bone names without properly formatted names may not be able to be referenced by some versions of the Unreal Engine'): 'ボーン名の制限が適用されます。ボーン名が正しくフォーマットされていないと、Unreal '
|
||||
'Engine '
|
||||
'のバージョンによっては参照できない場合があることに注意してください',
|
||||
('*', 'Bones'): 'ボーンズ',
|
||||
('*', "Bones names must match, ignoring case (e.g., the PSA bone 'aBcDeF' can be mapped to the armature bone 'ABCDEF')"): 'ボーンの名前は大文字と小文字を区別しないで一致する必要があります '
|
||||
'(例:PSAボーン '
|
||||
"'abcDef' "
|
||||
'はアーマチュアボーン '
|
||||
"'ABCDEF' "
|
||||
'にマッピングできます)',
|
||||
('*', 'Case Insensitive'): '大文字と小文字を区別しない',
|
||||
('*', 'Compression Ratio'): '圧縮率',
|
||||
('*', 'Context'): 'コンテキスト',
|
||||
('*', 'Convert keyframes to read-only samples. Recommended if you do not plan on editing the actions directly'): 'キーフレームを読み取り専用サンプルに変換します。アクションを直接編集する予定がない場合におすすめです',
|
||||
('*', 'Convert to Samples'): 'サンプルに変換',
|
||||
('*', 'Custom FPS'): 'カスタムFPS',
|
||||
('*', 'Deselect All'): 'すべて選択解除',
|
||||
('*', 'Deselect all bone collections'): 'すべてのボーンコレクションを選択解除',
|
||||
('*', 'Deselect all visible sequences'): '表示されているシーケンスをすべて選択解除',
|
||||
('*', 'Discarded {count} invalid face(s)'): '{count}個の無効なフェースが廃棄されました',
|
||||
('*', 'Duplicate action: {action_name}'): 'アクションが重複しています:{action_name}',
|
||||
('*', 'Each sequence name should be on a new line'): '各シーケンス名は新しい行にする必要があります',
|
||||
('*', 'Enforce Bone Name Restrictions'): 'ボーン名制限を強制',
|
||||
('*', 'Enforce that bone names must only contain letters, numbers, spaces, hyphens and underscores.\n\nDepending on the engine, improper bone names might not be referenced correctly by scripts'): 'ボーン名には文字、数字、スペース、ハイフン、アンダースコアのみを含めるようにしてください。\n'
|
||||
'\n'
|
||||
'エンジンによっては、不適切なボーン名がスクリプトで正しく参照されない場合があります',
|
||||
('*', 'Exact'): '正確',
|
||||
('*', 'Export'): 'エクスポート',
|
||||
('*', 'Export actions to PSA'): 'アクションをPSAにエクスポート',
|
||||
('*', 'Export mesh and armature to PSK'): 'メッシュとアーマチュアを PSK にエクスポート',
|
||||
('*', 'Exported bone hierarchy must have a single root bone.\nThe bone hierarchy marked for export has {root_bone_count} root bones: {root_bone_names}.\nAdditional debugging information has been written to the console.'): 'エクスポートされたボーン階層にはルートボーンが '
|
||||
'1 '
|
||||
'つ必要です。\n'
|
||||
'エクスポート対象としてマークされたボーン階層には '
|
||||
'{root_bone_count} '
|
||||
'個のルートボーン '
|
||||
'({root_bone_names}) '
|
||||
'があります。\n'
|
||||
'追加のデバッグ情報がコンソールに書き込まれました。',
|
||||
('*', 'Extra UVs'): '追加のUV',
|
||||
('*', 'FPS Source'): 'FPSソース',
|
||||
('*', 'Failed to read PSA config file: {error}'): 'PSA構成ファイルを読み込めませんでした:{error}',
|
||||
('*', 'Fake User'): 'フェイクユーザー',
|
||||
('*', 'File Path'): 'ファイルパス',
|
||||
('*', 'File path used for exporting the PSA file'): 'PSAファイルのエクスポートに使用するファイルパス',
|
||||
('*', 'File path used for exporting the PSK file'): 'PSKファイルのエクスポートに使用するファイルパス',
|
||||
('*', 'File path used for importing the PSA file'): 'PSAファイルのインポートに使用するファイルパス',
|
||||
('*', 'Filter by Name'): '名前で絞り込む',
|
||||
('*', 'Filter using regular expressions'): '正規表現を使用してフィルタリングする',
|
||||
('*', 'Flags'): '国旗',
|
||||
('*', "If an action with a matching name already exists, the existing action will have it's data overwritten instead of a new action being created"): '一致する名前のアクションが既に存在する場合、新しいアクションが作成されるのではなく、既存のアクションのデータが上書きされます',
|
||||
('*', 'Import'): 'インポート',
|
||||
('*', 'Import Extra UVs'): '追加のUVをインポート',
|
||||
('*', 'Import Shape Keys'): 'シェイプキーをインポート',
|
||||
('*', 'Import Vertex Colors'): '頂点カラーをインポート',
|
||||
('*', 'Import Vertex Normals'): '頂点法線をインポート',
|
||||
('*', 'Import extra UVs, if available'): '追加の UV (可能な場合) をインポートする',
|
||||
('*', 'Import shape keys, if available'): 'シェイプキーをインポートする (可能な場合)',
|
||||
('*', 'Import the selected animations into the scene as actions'): '選択したアニメーションをアクションとしてシーンにインポートします',
|
||||
('*', 'Import vertex colors, if available'): '頂点カラーをインポート (可能な場合)',
|
||||
('*', 'Import vertex normals, if available'): '頂点法線 (可能な場合) をインポートする',
|
||||
('*', 'Imported {action_count} action(s)'): 'インポートされた{action_count}アクション',
|
||||
('*', 'Imported {action_count} action(s) with {warning_count} warning(s)'): '{action_count}個のアクションが{warning_count}個の警告付きでインポートされました',
|
||||
('*', 'Invalid FPS source: {fps_source}'): 'FPSソースが無効です:{fps_source}',
|
||||
('*', 'Invert filtering (show hidden items, and vice versa)'): '反転フィルタリング '
|
||||
'(隠しアイテムを表示、その逆)',
|
||||
('*', 'Keyframe Quota'): 'キーフレームクォータ',
|
||||
('*', 'Load a PSK file'): 'PSKファイルを読み込む',
|
||||
('*', 'Material name "{name}" contains characters that cannot be encoded in the Windows-1252 codepage'): 'マテリアル名「{name}」には、Windows-1252 '
|
||||
'コードページではエンコードできない文字が含まれています',
|
||||
('*', 'Material slot cannot be empty (index {index})'): 'マテリアルスロットを空にすることはできません '
|
||||
'(インデックス{index})',
|
||||
('*', 'Materials'): 'マテリアル',
|
||||
('*', 'Mesh "{object_name}" must have at least one material'): 'メッシュ「{object_name}」には少なくとも1つのマテリアルが必要です',
|
||||
('*', 'Mesh "{object_name}" must have only one armature modifier'): 'メッシュ「{object_name}」にはアーマチュアモディファイヤが '
|
||||
'1 '
|
||||
'つだけ必要です',
|
||||
('*', 'Metadata'): 'メタデータ',
|
||||
('*', 'Modulate'): '変調する',
|
||||
('*', 'Move the selected material down one slot'): '選択したマテリアルを1スロット下に移動',
|
||||
('*', 'Move the selected material up one slot'): '選択したマテリアルを1つ上のスロットに移動',
|
||||
('*', 'NLA Track'): 'NLAトラック',
|
||||
('*', 'NLA Track Index'): 'NLAトラックインデックス',
|
||||
('*', 'NLA Track Strips'): 'NLAトラックストリップ',
|
||||
('*', 'No NLA track strips were selected for export'): 'エクスポート対象のNLAトラックストリップが選択されていません',
|
||||
('*', 'No Smooth'): 'スムーズなし',
|
||||
('*', 'No actions were selected for export'): 'エクスポートするアクションが選択されていません',
|
||||
('*', 'No animation data for object "{name}"'): 'オブジェクト「{name}」のアニメーションデータはありません',
|
||||
('*', 'No bones available for export'): 'エクスポートできるボーンはありません',
|
||||
('*', 'No modifiers will be evaluated as part of the exported mesh'): 'エクスポートされたメッシュの一部として評価されるモディファイヤはありません',
|
||||
('*', 'No sequences selected'): 'シーケンスが選択されていません',
|
||||
('*', 'No text block selected'): 'テキストブロックが選択されていません',
|
||||
('*', 'No timeline markers were selected for export'): 'エクスポートするタイムラインマーカーが選択されていません',
|
||||
('*', 'Normal Two-Sided'): 'ノーマル両面',
|
||||
('*', 'Nothing to import'): 'インポートするものはありません',
|
||||
('*', 'Number of bones ({bone_count}) exceeds limit of {MAX_BONE_COUNT}'): 'ボーンの数({bone_count})が{MAX_BONE_COUNT}の制限を超えています',
|
||||
('*', 'Number of materials ({material_count}) exceeds limit of {MAX_MATERIAL_COUNT}'): 'マテリアルの数({material_count})が{MAX_MATERIAL_COUNT}の制限を超えています',
|
||||
('*', 'Number of wedges ({wedge_count}) exceeds limit of {MAX_WEDGE_COUNT}'): 'ウェッジの数({wedge_count})が{MAX_WEDGE_COUNT}の制限を超えています',
|
||||
('*', 'Numbers of vertices ({point_count}) exceeds limit of {MAX_POINT_COUNT}'): '頂点の数({point_count})が{MAX_POINT_COUNT}の制限を超えています',
|
||||
('*', 'Only Show Selected'): '選択したものだけを表示',
|
||||
('*', 'Only bones belonging to the selected bone collections and their ancestors will be exported'): '選択したボーンコレクションに属するボーンとその祖先だけがエクスポートされます',
|
||||
('*', "Only show items matching this name (use '*' as wildcard)"): 'この名前に一致するアイテムのみを表示 '
|
||||
'(ワイルドカードとして「*」を使用)',
|
||||
('*', 'Only show selected sequences'): '選択したシーケンスのみを表示',
|
||||
('*', 'Override Animation Data'): 'アニメーションデータをオーバーライドする',
|
||||
('*', 'PSA Export'): 'PSAエクスポート',
|
||||
('*', 'PSA bone {bone_index} ({bone_name}) could not be mapped to armature bone {armature_bone_index} ({armature_bone_name}) because the armature bone is already mapped to PSA bone {mapped_psa_bone_index} ({mapped_psa_bone_name})'): 'アーマチュアボーンは既に '
|
||||
'PSA '
|
||||
'ボーン{mapped_psa_bone_index}({mapped_psa_bone_name})にマップされているため、PSA '
|
||||
'ボーン{bone_index}({bone_name})をアーマチュアボーン{armature_bone_index}({mapped_psa_bone_name})にマッピングできませんでした',
|
||||
('*', 'PSA export successful'): 'PSAのエクスポートが成功しました',
|
||||
('*', 'PSK Material'): 'PSKマテリアル',
|
||||
('*', 'PSK export successful'): 'PSKのエクスポートが成功しました',
|
||||
('*', 'PSK export successful with {warning_count} warnings'): 'PSKエクスポートは成功しましたが、警告が{warning_count}件ありました',
|
||||
('*', 'PSK imported ({name})'): 'PSKがインポートされました ({name})',
|
||||
('*', 'PSK imported with {count} warning(s)'): 'PSKをインポートすると{count}つの警告が発生しました',
|
||||
('*', 'PSK/PSA Import/Export (.psk/.psa)'): 'PSK/PSA インポート/エクスポート '
|
||||
'(.psk/.psa)',
|
||||
('*', 'PSK/PSA Importer/Exporter'): 'PSK/PSA インポーター/エクスポーター',
|
||||
('*', 'Prefix Action Name'): 'プレフィックスアクション名',
|
||||
('*', 'Raw Mesh Data'): '未加工メッシュデータ',
|
||||
('*', 'Regular Expression'): '正規表現',
|
||||
('*', 'RemoveTracks'): 'RemoveTracks',
|
||||
('*', 'Reversed'): '逆転しました',
|
||||
('*', 'Root Motion'): 'ルートモーション',
|
||||
('*', 'Root bone "{root_bone_name}" was included because {bone_name} was marked for export'): 'ルートボーン「{root_bone_name}」が含まれたのは、「{bone_name}」がエクスポート対象としてマークされているためです',
|
||||
('*', 'Select All'): 'すべて選択',
|
||||
('*', 'Select By Text List'): 'テキストリストで選択',
|
||||
('*', 'Select a PSA file'): 'PSAファイルを選択',
|
||||
('*', 'Select all bone collections'): 'すべてのボーンコレクションを選択',
|
||||
('*', 'Select all visible sequences'): '表示されているシーケンスをすべて選択',
|
||||
('*', 'Select sequences by name from text list'): 'テキストリストから名前でシーケンスを選択',
|
||||
('*', 'Selected object "{object_name}" is not a mesh'): '選択したオブジェクト「{object_name}」はメッシュではありません',
|
||||
('*', 'Selected object must be an Armature'): '選択したオブジェクトはアーマチュアでなければなりません',
|
||||
('*', 'Selected {count} sequence(s)'): '選択した{count}シーケンス',
|
||||
('*', 'Sequence'): 'シーケンス',
|
||||
('*', 'Sequence name "{name}" contains characters that cannot be encoded in the Windows-1252 codepage'): 'シーケンス名「{name}」には、Windows-1252 '
|
||||
'コードページではエンコードできない文字が含まれています',
|
||||
('*', 'Sequences'): 'シーケンス',
|
||||
('*', 'Sequences are delineated by scene timeline markers'): 'シーケンスはシーンのタイムラインマーカーによって描かれます',
|
||||
('*', 'Sequences are delineated by the start & end times of strips on the selected NLA track'): 'シーケンスは、選択した '
|
||||
'NLA '
|
||||
'トラックのストリップの開始時間と終了時間によって示されます',
|
||||
('*', 'Sequences will be exported using actions'): 'シーケンスはアクションを使用してエクスポートされます',
|
||||
('*', 'Shape Keys'): 'シェイプキー',
|
||||
('*', 'Show actions that belong to an asset library'): 'アセットライブラリに属するアクションを表示',
|
||||
('*', 'Show reversed sequences'): '逆のシーケンスを表示',
|
||||
('*', 'Source'): 'ソース',
|
||||
('*', 'Stash'): '保留',
|
||||
('*', 'Stash each imported action as a strip on a new non-contributing NLA track'): 'インポートした各アクションを、コントリビューションされていない新しい '
|
||||
'NLA '
|
||||
'トラックにストリップとして保存する',
|
||||
('*', 'The active object must be an armature'): 'アクティブオブジェクトはアーマチュアでなければなりません',
|
||||
('*', "The armature '{armature_name}' is missing {count} bones that exist in the PSA:\n{missing_bone_names}"): 'アーマチュア「{armature_name}」にはPSAに存在する{count}個のボーンがありません:\n'
|
||||
'{missing_bone_names}',
|
||||
('*', 'The following bone names are invalid: {invalid_bone_names}.\nBone names must only contain letters, numbers, spaces, hyphens and underscores.\nYou can bypass this by disabling "Enforce Bone Name Restrictions" in the export settings.'): '次のボーン名は無効です:{invalid_bone_names}\n'
|
||||
'ボーン名に使用できるのは、文字、数字、スペース、ハイフン、アンダースコアのみです。\n'
|
||||
'エクスポート設定で「ボーン名制限を強制」を無効にすることで、これを回避できます',
|
||||
('*', 'The frame rate of the exported sequence'): 'エクスポートされたシーケンスのフレームレート',
|
||||
('*', 'The frame rate to which the imported sequences will be resampled to'): 'インポートしたシーケンスをリサンプリングするときのフレームレート',
|
||||
('*', "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"): 'フレームレートは、PSAエクスポートのパネルにあるアクションのFPSプロパティによって決まります。\n'
|
||||
'\n'
|
||||
'シーケンスソースがタイムラインマーカーの場合、関連するすべてのアクションのうち最も低い値が使用されます',
|
||||
('*', 'The keyframe sampling ratio of the exported sequence.\n\nA compression ratio of 1.0 will export all frames, while a compression ratio of 0.5 will export half of the frames'): 'エクスポートされたシーケンスのキーフレームサンプリング比。\n'
|
||||
'\n'
|
||||
'圧縮率が1.0の場合はすべてのフレームがエクスポートされ、圧縮率が0.5の場合はフレームの半分がエクスポートされます',
|
||||
('*', 'The method by which bones from the PSA file are mapped to the bones of the armature'): 'PSAファイルのボーンをアーマチュアのボーンにマッピングする方法',
|
||||
('*', 'The minimum number of keyframes to be exported'): 'エクスポートするキーフレームの最小数',
|
||||
('*', 'The selected object must be an armature'): '選択したオブジェクトはアーマチュアでなければなりません',
|
||||
('*', 'The sequence frame rate matches the original frame rate'): 'シーケンスのフレームレートは元のフレームレートと一致します',
|
||||
('*', 'The sequence is resampled to a custom frame rate'): 'シーケンスはカスタムフレームレートにリサンプリングされます',
|
||||
('*', 'The sequence is resampled to the frame rate of the scene'): 'シーケンスはシーンのフレームレートに合わせてリサンプリングされます',
|
||||
('*', 'The source vertex color space'): 'ソース頂点カラースペース',
|
||||
('*', 'Translucent'): '半透明',
|
||||
('*', 'Triangle Bit Flags'): 'トライアングルビットフラグ',
|
||||
('*', 'Triangle Type'): 'トライアングルタイプ',
|
||||
('*', 'Unhandled section "{section_name}" at position {position}'): '{position}の位置にある未処理のセクション「{section_name}」',
|
||||
('*', 'Unhandled sequence source: {sequence_source}'): '未処理のシーケンスソース:{sequence_source}',
|
||||
('*', 'Unlit'): '明かりなし',
|
||||
('*', 'Unreal PSA (.psa)'): 'アンリアルPSA (.psa)',
|
||||
('*', 'Unreal PSK (.psk)'): 'アンリアルPSK (.psk)',
|
||||
('*', 'Unreal PSK (.psk/.pskx)'): 'アンリアルPSK (.psk/.pskx)',
|
||||
('*', 'Unrecognized wedge format'): '認識されないウェッジフォーマット',
|
||||
('*', 'Use Config File'): '設定ファイルを使用',
|
||||
('*', 'Use the .config file that is sometimes generated when the PSA file is exported from UEViewer. This file contains options that can be used to filter out certain bones tracks from the imported actions'): 'PSA '
|
||||
'ファイルをUEViewerからエクスポートしたときに生成されることがある.configファイルを使用してください。このファイルには、インポートしたアクションから特定のボーントラックを除外するためのオプションが含まれています',
|
||||
('*', 'Use the animation data from a different object instead of the selected object'): '選択したオブジェクトの代わりに別のオブジェクトのアニメーションデータを使用する',
|
||||
('*', 'Vertex Color Space'): '頂点カラースペース',
|
||||
('*', 'Vertex Colors'): '頂点カラー',
|
||||
('*', 'Vertex Normals'): '頂点法線',
|
||||
('*', 'When enabled, the root bone will be transformed as it appears in the scene.\n\nYou might want to disable this if you are exporting an animation for an armature that is attached to another object, such as a weapon or a shield'): '有効にすると、ルートボーンはシーンに表示されているとおりにトランスフォームされます。\n'
|
||||
'\n'
|
||||
'武器や盾など、別のオブジェクトにアタッチされているアーマチュアのアニメーションをエクスポートする場合は、これを無効にすると良いかもしれません',
|
||||
('*', 'Write'): '書き込み',
|
||||
('*', 'sRGBA'): 'sRGBA'}}
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
import bpy.app.translations
|
||||
from bpy.types import Armature, Bone, Action, PoseBone
|
||||
|
||||
from .data import *
|
||||
@@ -26,7 +27,7 @@ class PsaBuildOptions:
|
||||
self.animation_data: Optional[AnimData] = None
|
||||
self.sequences: List[PsaBuildSequence] = []
|
||||
self.bone_filter_mode: str = 'ALL'
|
||||
self.bone_group_indices: List[int] = []
|
||||
self.bone_collection_indices: List[int] = []
|
||||
self.should_enforce_bone_name_restrictions: bool = False
|
||||
self.sequence_name_prefix: str = ''
|
||||
self.sequence_name_suffix: str = ''
|
||||
@@ -73,7 +74,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
pose_bones = [x[1] for x in pose_bones]
|
||||
|
||||
# Get a list of all the bone indices and instigator bones for the bone filter settings.
|
||||
export_bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices)
|
||||
export_bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_indices)
|
||||
bone_indices = [bone_names.index(x) for x in export_bone_names]
|
||||
|
||||
# Make the bone lists contain only the bones that are going to be exported.
|
||||
@@ -91,7 +92,12 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
# Build list of PSA bones.
|
||||
for bone in bones:
|
||||
psa_bone = Psa.Bone()
|
||||
|
||||
try:
|
||||
psa_bone.name = bytes(bone.name, encoding='windows-1252')
|
||||
except UnicodeEncodeError:
|
||||
message = bpy.app.translations.pgettext_iface('Bone name "{name}" contains characters that cannot be encoded in the Windows-1252 codepage')
|
||||
raise RuntimeError(message.format(name=bone.name))
|
||||
|
||||
try:
|
||||
parent_index = bones.index(bone.parent)
|
||||
@@ -165,7 +171,12 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
frame_step = -frame_step
|
||||
|
||||
psa_sequence = Psa.Sequence()
|
||||
try:
|
||||
psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252')
|
||||
except UnicodeEncodeError:
|
||||
message = bpy.app.translations.pgettext_iface('Sequence name "{name}" contains characters that cannot be encoded in the Windows-1252 codepage')
|
||||
message = message.format(name=export_sequence.name)
|
||||
raise RuntimeError(message)
|
||||
psa_sequence.frame_count = frame_count
|
||||
psa_sequence.frame_start_index = frame_start_index
|
||||
psa_sequence.fps = frame_count / sequence_duration
|
||||
|
||||
78
io_scene_psk_psa/psa/config.py
Normal file
78
io_scene_psk_psa/psa/config.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import re
|
||||
from configparser import ConfigParser
|
||||
from typing import Dict
|
||||
|
||||
from .reader import PsaReader
|
||||
|
||||
REMOVE_TRACK_LOCATION = (1 << 0)
|
||||
REMOVE_TRACK_ROTATION = (1 << 1)
|
||||
|
||||
|
||||
class PsaConfig:
|
||||
def __init__(self):
|
||||
self.sequence_bone_flags: Dict[str, Dict[int, int]] = dict()
|
||||
|
||||
|
||||
def _load_config_file(file_path: str) -> ConfigParser:
|
||||
"""
|
||||
UEViewer exports a dialect of INI files that is not compatible with Python's ConfigParser.
|
||||
Specifically, it allows values in this format:
|
||||
|
||||
[Section]
|
||||
Key1
|
||||
Key2
|
||||
|
||||
This is not allowed in Python's ConfigParser, which requires a '=' character after each key name.
|
||||
To work around this, we'll modify the file to add the '=' character after each key name if it is missing.
|
||||
"""
|
||||
with open(file_path, 'r') as f:
|
||||
lines = f.read().split('\n')
|
||||
|
||||
lines = [re.sub(r'^\s*(\w+)\s*$', r'\1=', line) for line in lines]
|
||||
|
||||
contents = '\n'.join(lines)
|
||||
|
||||
config = ConfigParser()
|
||||
config.read_string(contents)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _get_bone_flags_from_value(value: str) -> int:
|
||||
match value:
|
||||
case 'all':
|
||||
return REMOVE_TRACK_LOCATION | REMOVE_TRACK_ROTATION
|
||||
case 'trans':
|
||||
return REMOVE_TRACK_LOCATION
|
||||
case 'rot':
|
||||
return REMOVE_TRACK_ROTATION
|
||||
case _:
|
||||
return 0
|
||||
|
||||
|
||||
def read_psa_config(psa_reader: PsaReader, file_path: str) -> PsaConfig:
|
||||
psa_config = PsaConfig()
|
||||
|
||||
config = _load_config_file(file_path)
|
||||
|
||||
if config.has_section('RemoveTracks'):
|
||||
for key, value in config.items('RemoveTracks'):
|
||||
match = re.match(f'^(.+)\.(\d+)$', key)
|
||||
sequence_name = match.group(1)
|
||||
|
||||
# Map the sequence name onto the actual sequence name in the PSA file.
|
||||
try:
|
||||
psa_sequence_names = list(psa_reader.sequences.keys())
|
||||
lowercase_sequence_names = [sequence_name.lower() for sequence_name in psa_sequence_names]
|
||||
sequence_name = psa_sequence_names[lowercase_sequence_names.index(sequence_name.lower())]
|
||||
except ValueError:
|
||||
# Sequence name is not in the PSA file.
|
||||
continue
|
||||
|
||||
if sequence_name not in psa_config.sequence_bone_flags:
|
||||
psa_config.sequence_bone_flags[sequence_name] = dict()
|
||||
|
||||
bone_index = int(match.group(2))
|
||||
psa_config.sequence_bone_flags[sequence_name][bone_index] = _get_bone_flags_from_value(value)
|
||||
|
||||
return psa_config
|
||||
@@ -4,10 +4,10 @@ from typing import List
|
||||
|
||||
from ..data import *
|
||||
|
||||
"""
|
||||
'''
|
||||
Note that keys are not stored within the Psa object.
|
||||
Use the PsaReader::get_sequence_keys to get the keys for a sequence.
|
||||
"""
|
||||
'''
|
||||
|
||||
|
||||
class Psa:
|
||||
|
||||
@@ -8,10 +8,10 @@ from bpy.types import Context, Armature, Action, Object, AnimData, TimelineMarke
|
||||
from bpy_extras.io_utils import ExportHelper
|
||||
from bpy_types import Operator
|
||||
|
||||
from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences
|
||||
from ..builder import build_psa, PsaBuildSequence, PsaBuildOptions
|
||||
from ..export.properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences
|
||||
from ..writer import write_psa
|
||||
from ...helpers import populate_bone_group_list, get_nla_strips_in_timeframe
|
||||
from ...helpers import populate_bone_collection_list, get_nla_strips_in_frame_range
|
||||
|
||||
|
||||
def is_action_for_armature(armature: Armature, action: Action):
|
||||
@@ -47,7 +47,7 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature):
|
||||
if not is_action_for_armature(armature, action):
|
||||
continue
|
||||
|
||||
if not action.name.startswith('#'):
|
||||
if action.name != '' and not action.name.startswith('#'):
|
||||
for (name, frame_start, frame_end) in get_sequences_from_action(action):
|
||||
item = pg.action_list.add()
|
||||
item.action = action
|
||||
@@ -60,7 +60,7 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature):
|
||||
# Pose markers are not guaranteed to be in frame-order, so make sure that they are.
|
||||
pose_markers = sorted(action.pose_markers, key=lambda x: x.frame)
|
||||
for pose_marker_index, pose_marker in enumerate(pose_markers):
|
||||
if pose_marker.name.startswith('#'):
|
||||
if pose_marker.name.strip() == '' or pose_marker.name.startswith('#'):
|
||||
continue
|
||||
for (name, frame_start, frame_end) in get_sequences_from_action_pose_marker(action, pose_markers, pose_marker, pose_marker_index):
|
||||
item = pg.action_list.add()
|
||||
@@ -78,35 +78,31 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature):
|
||||
for marker_name in marker_names:
|
||||
if marker_name not in sequence_frame_ranges:
|
||||
continue
|
||||
if marker_name.startswith('#'):
|
||||
if marker_name.strip() == '' or marker_name.startswith('#'):
|
||||
continue
|
||||
item = pg.marker_list.add()
|
||||
item.name = marker_name
|
||||
item.is_selected = False
|
||||
frame_start, frame_end = sequence_frame_ranges[marker_name]
|
||||
sequences = get_sequences_from_name_and_frame_range(marker_name, frame_start, frame_end)
|
||||
for (sequence_name, frame_start, frame_end) in sequences:
|
||||
item = pg.marker_list.add()
|
||||
item.name = sequence_name
|
||||
item.is_selected = False
|
||||
item.frame_start = frame_start
|
||||
item.frame_end = frame_end
|
||||
|
||||
|
||||
def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float:
|
||||
if fps_source == 'SCENE':
|
||||
match fps_source:
|
||||
case 'SCENE':
|
||||
return context.scene.render.fps
|
||||
elif fps_source == 'CUSTOM':
|
||||
case 'CUSTOM':
|
||||
return fps_custom
|
||||
elif fps_source == 'ACTION_METADATA':
|
||||
case 'ACTION_METADATA':
|
||||
# Get the minimum value of action metadata FPS values.
|
||||
fps_list = []
|
||||
for action in filter(lambda x: 'psa_sequence_fps' in x, actions):
|
||||
fps = action['psa_sequence_fps']
|
||||
if type(fps) == int or type(fps) == float:
|
||||
fps_list.append(fps)
|
||||
if len(fps_list) > 0:
|
||||
return min(fps_list)
|
||||
else:
|
||||
# No valid action metadata to use, fallback to scene FPS
|
||||
return context.scene.render.fps
|
||||
else:
|
||||
raise RuntimeError(f'Invalid FPS source "{fps_source}"')
|
||||
return min([action.psa_export.fps for action in actions])
|
||||
case _:
|
||||
message = bpy.app.translations.pgettext_iface('Invalid FPS source: {fps_source}')
|
||||
message = message.format(fps_source=fps_source)
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def get_animation_data_object(context: Context) -> Object:
|
||||
@@ -117,7 +113,7 @@ def get_animation_data_object(context: Context) -> Object:
|
||||
if active_object.type != 'ARMATURE':
|
||||
raise RuntimeError('Selected object must be an Armature')
|
||||
|
||||
if pg.should_override_animation_data:
|
||||
if pg.sequence_source != 'ACTIONS' and pg.should_override_animation_data:
|
||||
animation_data_object = pg.animation_data_override
|
||||
else:
|
||||
animation_data_object = active_object
|
||||
@@ -126,9 +122,9 @@ def get_animation_data_object(context: Context) -> Object:
|
||||
|
||||
|
||||
def is_bone_filter_mode_item_available(context, identifier):
|
||||
if identifier == 'BONE_GROUPS':
|
||||
obj = context.active_object
|
||||
if not obj.pose or not obj.pose.bone_groups:
|
||||
if identifier == 'BONE_COLLECTIONS':
|
||||
armature = context.active_object.data
|
||||
if len(armature.collections) == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -150,7 +146,7 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context:
|
||||
if next_marker_index < len(sorted_timeline_markers):
|
||||
# There is a next marker. Use that next marker's frame position as the last frame of this sequence.
|
||||
frame_end = sorted_timeline_markers[next_marker_index].frame
|
||||
nla_strips = get_nla_strips_in_timeframe(animation_data, marker.frame, frame_end)
|
||||
nla_strips = get_nla_strips_in_frame_range(animation_data, marker.frame, frame_end)
|
||||
if len(nla_strips) > 0:
|
||||
frame_end = min(frame_end, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips)))
|
||||
frame_start = max(frame_start, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips)))
|
||||
@@ -174,11 +170,9 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context:
|
||||
return sequence_frame_ranges
|
||||
|
||||
|
||||
def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]:
|
||||
frame_start = int(action.frame_range[0])
|
||||
frame_end = int(action.frame_range[1])
|
||||
def get_sequences_from_name_and_frame_range(name: str, frame_start: int, frame_end: int) -> List[Tuple[str, int, int]]:
|
||||
reversed_pattern = r'(.+)/(.+)'
|
||||
reversed_match = re.match(reversed_pattern, action.name)
|
||||
reversed_match = re.match(reversed_pattern, name)
|
||||
if reversed_match:
|
||||
forward_name = reversed_match.group(1)
|
||||
backwards_name = reversed_match.group(2)
|
||||
@@ -187,7 +181,13 @@ def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]:
|
||||
(backwards_name, frame_end, frame_start)
|
||||
]
|
||||
else:
|
||||
return [(action.name, frame_start, frame_end)]
|
||||
return [(name, frame_start, frame_end)]
|
||||
|
||||
|
||||
def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]:
|
||||
frame_start = int(action.frame_range[0])
|
||||
frame_end = int(action.frame_range[1])
|
||||
return get_sequences_from_name_and_frame_range(action.name, frame_start, frame_end)
|
||||
|
||||
|
||||
def get_sequences_from_action_pose_marker(action: Action, pose_markers: List[TimelineMarker], pose_marker: TimelineMarker, pose_marker_index: int) -> List[Tuple[str, int, int]]:
|
||||
@@ -196,17 +196,7 @@ def get_sequences_from_action_pose_marker(action: Action, pose_markers: List[Tim
|
||||
frame_end = pose_markers[pose_marker_index + 1].frame
|
||||
else:
|
||||
frame_end = int(action.frame_range[1])
|
||||
reversed_pattern = r'(.+)/(.+)'
|
||||
reversed_match = re.match(reversed_pattern, pose_marker.name)
|
||||
if reversed_match:
|
||||
forward_name = reversed_match.group(1)
|
||||
backwards_name = reversed_match.group(2)
|
||||
return [
|
||||
(forward_name, frame_start, frame_end),
|
||||
(backwards_name, frame_end, frame_start)
|
||||
]
|
||||
else:
|
||||
return [(pose_marker.name, frame_start, frame_end)]
|
||||
return get_sequences_from_name_and_frame_range(pose_marker.name, frame_start, frame_end)
|
||||
|
||||
|
||||
def get_visible_sequences(pg: PSA_PG_export, sequences) -> List[PSA_PG_export_action_list_item]:
|
||||
@@ -246,22 +236,37 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
layout = self.layout
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
|
||||
sequences_header, sequences_panel = layout.panel('sequences_panel_id', default_closed=False)
|
||||
sequences_header.label(text='Sequences', icon='ACTION')
|
||||
|
||||
if sequences_panel is not None:
|
||||
flow = sequences_panel.grid_flow()
|
||||
flow.use_property_split = True
|
||||
flow.use_property_decorate = False
|
||||
|
||||
# FPS
|
||||
layout.prop(pg, 'fps_source', text='FPS')
|
||||
fps_row = flow.row(align=True)
|
||||
fps_row.prop(pg, 'fps_source', text='FPS')
|
||||
if pg.fps_source == 'CUSTOM':
|
||||
layout.prop(pg, 'fps_custom', text='Custom')
|
||||
fps_row.prop(pg, 'fps_custom', text='')
|
||||
|
||||
# SOURCE
|
||||
layout.prop(pg, 'sequence_source', text='Source')
|
||||
flow.prop(pg, 'sequence_source', text='Source')
|
||||
|
||||
if pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
if pg.sequence_source in {'TIMELINE_MARKERS', 'NLA_TRACK_STRIPS'}:
|
||||
# ANIMDATA SOURCE
|
||||
layout.prop(pg, 'should_override_animation_data')
|
||||
flow.prop(pg, 'should_override_animation_data')
|
||||
if pg.should_override_animation_data:
|
||||
layout.prop(pg, 'animation_data_override', text='')
|
||||
flow.prop(pg, 'animation_data_override', text='')
|
||||
|
||||
if pg.sequence_source == 'NLA_TRACK_STRIPS':
|
||||
flow = sequences_panel.grid_flow()
|
||||
flow.use_property_split = True
|
||||
flow.use_property_decorate = False
|
||||
flow.prop(pg, 'nla_track')
|
||||
|
||||
# SELECT ALL/NONE
|
||||
row = layout.row(align=True)
|
||||
row = sequences_panel.row(align=True)
|
||||
row.label(text='Select')
|
||||
row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
@@ -269,21 +274,15 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
# ACTIONS
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
rows = max(3, min(len(pg.action_list), 10))
|
||||
|
||||
layout.template_list('PSA_UL_export_sequences', '', pg, 'action_list', pg, 'action_list_index', rows=rows)
|
||||
|
||||
col = layout.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'sequence_name_prefix')
|
||||
col.prop(pg, 'sequence_name_suffix')
|
||||
|
||||
sequences_panel.template_list('PSA_UL_export_sequences', '', pg, 'action_list', pg, 'action_list_index', rows=rows)
|
||||
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)
|
||||
sequences_panel.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))
|
||||
sequences_panel.template_list('PSA_UL_export_sequences', '', pg, 'nla_strip_list', pg, 'nla_strip_list_index', rows=rows)
|
||||
|
||||
col = layout.column()
|
||||
col = sequences_panel.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'sequence_name_prefix')
|
||||
@@ -295,30 +294,36 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
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')
|
||||
text = bpy.app.translations.pgettext_iface('Duplicate action: {action_name}')
|
||||
text = text.format(action_name=action_name)
|
||||
sequences_panel.label(text, icon='ERROR')
|
||||
break
|
||||
|
||||
layout.separator()
|
||||
|
||||
# BONES
|
||||
row = layout.row(align=True)
|
||||
bones_header, bones_panel = layout.panel('bones_panel_id', default_closed=False)
|
||||
bones_header.label(text='Bones', icon='BONE_DATA')
|
||||
|
||||
if bones_panel is not None:
|
||||
row = bones_panel.row(align=True)
|
||||
row.prop(pg, 'bone_filter_mode', text='Bones')
|
||||
|
||||
if pg.bone_filter_mode == 'BONE_GROUPS':
|
||||
row = layout.row(align=True)
|
||||
if pg.bone_filter_mode == 'BONE_COLLECTIONS':
|
||||
row = bones_panel.row(align=True)
|
||||
row.label(text='Select')
|
||||
row.operator(PSA_OT_export_bone_groups_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row.operator(PSA_OT_export_bone_groups_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||
layout.template_list('PSX_UL_bone_group_list', '', pg, 'bone_group_list', pg, 'bone_group_list_index',
|
||||
row.operator(PSA_OT_export_bone_collections_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row.operator(PSA_OT_export_bone_collections_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
rows = max(3, min(len(pg.bone_collection_list), 10))
|
||||
bones_panel.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index',
|
||||
rows=rows)
|
||||
|
||||
layout.prop(pg, 'should_enforce_bone_name_restrictions')
|
||||
|
||||
layout.separator()
|
||||
bones_panel.prop(pg, 'should_enforce_bone_name_restrictions')
|
||||
|
||||
# ROOT MOTION
|
||||
layout.prop(pg, 'root_motion', text='Root Motion')
|
||||
advanced_header, advanced_panel = layout.panel('advanced_panel_id', default_closed=False)
|
||||
advanced_header.label(text='Advanced')
|
||||
|
||||
if advanced_panel is not None:
|
||||
advanced_panel.prop(pg, 'root_motion', text='Root Motion')
|
||||
|
||||
@classmethod
|
||||
def _check_context(cls, context):
|
||||
@@ -345,8 +350,7 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
|
||||
update_actions_and_timeline_markers(context, self.armature_object.data)
|
||||
|
||||
# Populate bone groups list.
|
||||
populate_bone_group_list(self.armature_object, pg.bone_group_list)
|
||||
populate_bone_collection_list(self.armature_object, pg.bone_collection_list)
|
||||
|
||||
context.window_manager.fileselect_add(self)
|
||||
|
||||
@@ -358,50 +362,64 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
# Ensure that we actually have items that we are going to be exporting.
|
||||
if pg.sequence_source == 'ACTIONS' and len(pg.action_list) == 0:
|
||||
raise RuntimeError('No actions were selected for export')
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS' and len(pg.marker_names) == 0:
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS' and len(pg.marker_list) == 0:
|
||||
raise RuntimeError('No timeline markers were selected for export')
|
||||
elif pg.sequence_source == 'NLA_TRACK_STRIPS' and len(pg.nla_strip_list) == 0:
|
||||
raise RuntimeError('No NLA track strips were selected for export')
|
||||
|
||||
# Populate the export sequence list.
|
||||
animation_data_object = get_animation_data_object(context)
|
||||
animation_data = animation_data_object.animation_data
|
||||
|
||||
if animation_data is None:
|
||||
raise RuntimeError(f'No animation data for object \'{animation_data_object.name}\'')
|
||||
message = bpy.app.translations.pgettext_iface('No animation data for object "{name}"')
|
||||
message = message.format(name=animation_data_object.name)
|
||||
raise RuntimeError(message)
|
||||
|
||||
export_sequences: List[PsaBuildSequence] = []
|
||||
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
for action in filter(lambda x: x.is_selected, pg.action_list):
|
||||
if len(action.action.fcurves) == 0:
|
||||
for action_item in filter(lambda x: x.is_selected, pg.action_list):
|
||||
if len(action_item.action.fcurves) == 0:
|
||||
continue
|
||||
export_sequence = PsaBuildSequence()
|
||||
export_sequence.nla_state.action = action.action
|
||||
export_sequence.name = action.name
|
||||
export_sequence.nla_state.frame_start = action.frame_start
|
||||
export_sequence.nla_state.frame_end = action.frame_end
|
||||
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action.action])
|
||||
export_sequence.compression_ratio = action.action.psa_export.compression_ratio
|
||||
export_sequence.key_quota = action.action.psa_export.key_quota
|
||||
export_sequence.nla_state.action = action_item.action
|
||||
export_sequence.name = action_item.name
|
||||
export_sequence.nla_state.frame_start = action_item.frame_start
|
||||
export_sequence.nla_state.frame_end = action_item.frame_end
|
||||
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action_item.action])
|
||||
export_sequence.compression_ratio = action_item.action.psa_export.compression_ratio
|
||||
export_sequence.key_quota = action_item.action.psa_export.key_quota
|
||||
export_sequences.append(export_sequence)
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
for marker in pg.marker_list:
|
||||
for marker_item in filter(lambda x: x.is_selected, pg.marker_list):
|
||||
export_sequence = PsaBuildSequence()
|
||||
export_sequence.name = marker.name
|
||||
export_sequence.name = marker_item.name
|
||||
export_sequence.nla_state.action = None
|
||||
export_sequence.nla_state.frame_start = marker.frame_start
|
||||
export_sequence.nla_state.frame_end = marker.frame_end
|
||||
export_sequence.nla_state.frame_start = marker_item.frame_start
|
||||
export_sequence.nla_state.frame_end = marker_item.frame_end
|
||||
nla_strips_actions = set(
|
||||
map(lambda x: x.action, get_nla_strips_in_timeframe(animation_data, marker.frame_start, marker.frame_end)))
|
||||
map(lambda x: x.action, get_nla_strips_in_frame_range(animation_data, marker_item.frame_start, marker_item.frame_end)))
|
||||
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, nla_strips_actions)
|
||||
export_sequences.append(export_sequence)
|
||||
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
|
||||
for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list):
|
||||
export_sequence = PsaBuildSequence()
|
||||
export_sequence.name = nla_strip_item.name
|
||||
export_sequence.nla_state.action = None
|
||||
export_sequence.nla_state.frame_start = nla_strip_item.frame_start
|
||||
export_sequence.nla_state.frame_end = nla_strip_item.frame_end
|
||||
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [nla_strip_item.action])
|
||||
export_sequences.append(export_sequence)
|
||||
else:
|
||||
raise ValueError(f'Unhandled sequence source: {pg.sequence_source}')
|
||||
message = bpy.app.translations.pgettext_iface('Unhandled sequence source: {sequence_source}')
|
||||
raise ValueError(message.format(sequence_source=pg.sequence_source))
|
||||
|
||||
options = PsaBuildOptions()
|
||||
options.animation_data = animation_data
|
||||
options.sequences = export_sequences
|
||||
options.bone_filter_mode = pg.bone_filter_mode
|
||||
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
|
||||
options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected]
|
||||
options.should_ignore_bone_name_restrictions = pg.should_enforce_bone_name_restrictions
|
||||
options.sequence_name_prefix = pg.sequence_name_prefix
|
||||
options.sequence_name_suffix = pg.sequence_name_suffix
|
||||
@@ -432,6 +450,8 @@ class PSA_OT_export_actions_select_all(Operator):
|
||||
return pg.action_list
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
return pg.marker_list
|
||||
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
|
||||
return pg.nla_strip_list
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -463,6 +483,8 @@ class PSA_OT_export_actions_deselect_all(Operator):
|
||||
return pg.action_list
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
return pg.marker_list
|
||||
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
|
||||
return pg.nla_strip_list
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -479,42 +501,42 @@ class PSA_OT_export_actions_deselect_all(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSA_OT_export_bone_groups_select_all(Operator):
|
||||
bl_idname = 'psa_export.bone_groups_select_all'
|
||||
class PSA_OT_export_bone_collections_select_all(Operator):
|
||||
bl_idname = 'psa_export.bone_collections_select_all'
|
||||
bl_label = 'Select All'
|
||||
bl_description = 'Select all bone groups'
|
||||
bl_description = 'Select all bone collections'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
item_list = pg.bone_group_list
|
||||
item_list = pg.bone_collection_list
|
||||
has_unselected_items = any(map(lambda action: not action.is_selected, item_list))
|
||||
return len(item_list) > 0 and has_unselected_items
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
for item in pg.bone_group_list:
|
||||
for item in pg.bone_collection_list:
|
||||
item.is_selected = True
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSA_OT_export_bone_groups_deselect_all(Operator):
|
||||
bl_idname = 'psa_export.bone_groups_deselect_all'
|
||||
class PSA_OT_export_bone_collections_deselect_all(Operator):
|
||||
bl_idname = 'psa_export.bone_collections_deselect_all'
|
||||
bl_label = 'Deselect All'
|
||||
bl_description = 'Deselect all bone groups'
|
||||
bl_description = 'Deselect all bone collections'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
item_list = pg.bone_group_list
|
||||
item_list = pg.bone_collection_list
|
||||
has_selected_actions = any(map(lambda action: action.is_selected, item_list))
|
||||
return len(item_list) > 0 and has_selected_actions
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
for action in pg.bone_group_list:
|
||||
for action in pg.bone_collection_list:
|
||||
action.is_selected = False
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -523,6 +545,6 @@ classes = (
|
||||
PSA_OT_export,
|
||||
PSA_OT_export_actions_select_all,
|
||||
PSA_OT_export_actions_deselect_all,
|
||||
PSA_OT_export_bone_groups_select_all,
|
||||
PSA_OT_export_bone_groups_deselect_all,
|
||||
PSA_OT_export_bone_collections_select_all,
|
||||
PSA_OT_export_bone_collections_deselect_all,
|
||||
)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import re
|
||||
import sys
|
||||
from fnmatch import fnmatch
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \
|
||||
StringProperty
|
||||
from bpy.types import PropertyGroup, Object, Action
|
||||
from bpy.types import PropertyGroup, Object, Action, AnimData, Context
|
||||
|
||||
from ...types import PSX_PG_bone_group_list_item
|
||||
from ...types import PSX_PG_bone_collection_list_item
|
||||
|
||||
|
||||
def psa_export_property_group_animation_data_override_poll(_context, obj):
|
||||
@@ -19,13 +20,13 @@ empty_set = set()
|
||||
class PSA_PG_export_action_list_item(PropertyGroup):
|
||||
action: PointerProperty(type=Action)
|
||||
name: StringProperty()
|
||||
is_selected: BoolProperty(default=False)
|
||||
is_selected: BoolProperty(default=True)
|
||||
frame_start: IntProperty(options={'HIDDEN'})
|
||||
frame_end: IntProperty(options={'HIDDEN'})
|
||||
is_pose_marker: BoolProperty(options={'HIDDEN'})
|
||||
|
||||
|
||||
class PSA_PG_export_timeline_markers(PropertyGroup):
|
||||
class PSA_PG_export_timeline_markers(PropertyGroup): # TODO: rename this to singular
|
||||
marker_index: IntProperty()
|
||||
name: StringProperty()
|
||||
is_selected: BoolProperty(default=True)
|
||||
@@ -33,6 +34,51 @@ class PSA_PG_export_timeline_markers(PropertyGroup):
|
||||
frame_end: IntProperty(options={'HIDDEN'})
|
||||
|
||||
|
||||
class PSA_PG_export_nla_strip_list_item(PropertyGroup):
|
||||
name: StringProperty()
|
||||
action: PointerProperty(type=Action)
|
||||
frame_start: FloatProperty()
|
||||
frame_end: FloatProperty()
|
||||
is_selected: BoolProperty(default=True)
|
||||
|
||||
|
||||
def nla_track_update_cb(self: 'PSA_PG_export', context: Context) -> None:
|
||||
self.nla_strip_list.clear()
|
||||
if context.object is None or context.object.animation_data is None:
|
||||
return
|
||||
match = re.match(r'^(\d+).+$', self.nla_track)
|
||||
self.nla_track_index = int(match.group(1)) if match else -1
|
||||
if self.nla_track_index >= 0:
|
||||
nla_track = context.object.animation_data.nla_tracks[self.nla_track_index]
|
||||
for nla_strip in nla_track.strips:
|
||||
strip: PSA_PG_export_nla_strip_list_item = self.nla_strip_list.add()
|
||||
strip.action = nla_strip.action
|
||||
strip.name = nla_strip.name
|
||||
strip.frame_start = nla_strip.frame_start
|
||||
strip.frame_end = nla_strip.frame_end
|
||||
|
||||
|
||||
def get_animation_data(pg: 'PSA_PG_export', context: Context) -> Optional[AnimData]:
|
||||
animation_data_object = context.object
|
||||
if pg.should_override_animation_data:
|
||||
animation_data_object = pg.animation_data_override
|
||||
return animation_data_object.animation_data if animation_data_object else None
|
||||
|
||||
|
||||
def nla_track_search_cb(self, context: Context, edit_text: str):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
animation_data = get_animation_data(pg, context)
|
||||
if animation_data is None:
|
||||
return
|
||||
for index, nla_track in enumerate(animation_data.nla_tracks):
|
||||
yield f'{index} - {nla_track.name}'
|
||||
|
||||
|
||||
def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context):
|
||||
# Reset NLA track selection
|
||||
self.nla_track = ''
|
||||
|
||||
|
||||
class PSA_PG_export(PropertyGroup):
|
||||
root_motion: BoolProperty(
|
||||
name='Root Motion',
|
||||
@@ -46,10 +92,12 @@ class PSA_PG_export(PropertyGroup):
|
||||
name='Override Animation Data',
|
||||
options=empty_set,
|
||||
default=False,
|
||||
description='Use the animation data from a different object instead of the selected object'
|
||||
description='Use the animation data from a different object instead of the selected object',
|
||||
update=animation_data_override_update_cb,
|
||||
)
|
||||
animation_data_override: PointerProperty(
|
||||
type=Object,
|
||||
update=animation_data_override_update_cb,
|
||||
poll=psa_export_property_group_animation_data_override_poll
|
||||
)
|
||||
sequence_source: EnumProperty(
|
||||
@@ -58,19 +106,25 @@ class PSA_PG_export(PropertyGroup):
|
||||
description='',
|
||||
items=(
|
||||
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
|
||||
('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers', 'MARKER_HLT',
|
||||
1),
|
||||
('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences are delineated by scene timeline markers', 'MARKER_HLT', 1),
|
||||
('NLA_TRACK_STRIPS', 'NLA Track Strips', 'Sequences are delineated by the start & end times of strips on the selected NLA track', 'NLA', 2)
|
||||
)
|
||||
)
|
||||
nla_track: StringProperty(
|
||||
name='NLA Track',
|
||||
options=empty_set,
|
||||
description='',
|
||||
search=nla_track_search_cb,
|
||||
update=nla_track_update_cb
|
||||
)
|
||||
nla_track_index: IntProperty(name='NLA Track Index', default=-1)
|
||||
fps_source: EnumProperty(
|
||||
name='FPS Source',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('SCENE', 'Scene', '', 'SCENE_DATA', 0),
|
||||
('ACTION_METADATA', 'Action Metadata',
|
||||
'The frame rate will be determined by action\'s "psa_sequence_fps" custom property, if it exists. If the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used. If no metadata is available, the scene\'s frame rate 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', 'PROPERTIES', 1),
|
||||
('CUSTOM', 'Custom', '', 2)
|
||||
)
|
||||
)
|
||||
@@ -80,23 +134,25 @@ class PSA_PG_export(PropertyGroup):
|
||||
action_list_index: IntProperty(default=0)
|
||||
marker_list: CollectionProperty(type=PSA_PG_export_timeline_markers)
|
||||
marker_list_index: IntProperty(default=0)
|
||||
nla_strip_list: CollectionProperty(type=PSA_PG_export_nla_strip_list_item)
|
||||
nla_strip_list_index: IntProperty(default=0)
|
||||
bone_filter_mode: EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('ALL', 'All', 'All bones will be exported.'),
|
||||
('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will '
|
||||
'be exported.'),
|
||||
('ALL', 'All', 'All bones will be exported'),
|
||||
('BONE_COLLECTIONS', 'Bone Collections', 'Only bones belonging to the selected bone collections and their '
|
||||
'ancestors will be exported'),
|
||||
)
|
||||
)
|
||||
bone_group_list: CollectionProperty(type=PSX_PG_bone_group_list_item)
|
||||
bone_group_list_index: IntProperty(default=0, name='', description='')
|
||||
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
|
||||
bone_collection_list_index: IntProperty(default=0, name='', description='')
|
||||
should_enforce_bone_name_restrictions: BoolProperty(
|
||||
default=False,
|
||||
name='Enforce Bone Name Restrictions',
|
||||
description='Bone names restrictions will be enforced. Note that bone names without properly formatted names '
|
||||
'cannot be referenced in scripts'
|
||||
'may not be able to be referenced by some versions of the Unreal Engine'
|
||||
)
|
||||
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
|
||||
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)
|
||||
@@ -112,18 +168,18 @@ class PSA_PG_export(PropertyGroup):
|
||||
description='Invert filtering (show hidden items, and vice versa)')
|
||||
sequence_filter_asset: BoolProperty(
|
||||
default=False,
|
||||
name='Show assets',
|
||||
name='Assets',
|
||||
options=empty_set,
|
||||
description='Show actions that belong to an asset library')
|
||||
sequence_filter_pose_marker: BoolProperty(
|
||||
default=True,
|
||||
name='Show pose markers',
|
||||
name='Pose Markers',
|
||||
options=empty_set)
|
||||
sequence_use_filter_sort_reverse: BoolProperty(default=True, options=empty_set)
|
||||
sequence_filter_reversed: BoolProperty(
|
||||
default=True,
|
||||
options=empty_set,
|
||||
name='Show Reversed',
|
||||
name='Reversed',
|
||||
description='Show reversed sequences'
|
||||
)
|
||||
|
||||
@@ -145,7 +201,7 @@ def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:
|
||||
|
||||
if not pg.sequence_filter_asset:
|
||||
for i, sequence in enumerate(sequences):
|
||||
if hasattr(sequence, 'action') and sequence.action.asset_data is not None:
|
||||
if hasattr(sequence, 'action') and sequence.action is not None and sequence.action.asset_data is not None:
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if not pg.sequence_filter_pose_marker:
|
||||
@@ -164,5 +220,6 @@ def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:
|
||||
classes = (
|
||||
PSA_PG_export_action_list_item,
|
||||
PSA_PG_export_timeline_markers,
|
||||
PSA_PG_export_nla_strip_list_item,
|
||||
PSA_PG_export,
|
||||
)
|
||||
|
||||
@@ -15,8 +15,8 @@ class PSA_UL_export_sequences(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
item = cast(PSA_PG_export_action_list_item, item)
|
||||
is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker
|
||||
layout.prop(item, 'is_selected', icon_only=True, text=item.name)
|
||||
if hasattr(item, 'action') and item.action.asset_data is not None:
|
||||
layout.prop(item, 'is_selected', icon_only=True, text=item.name, translate=False)
|
||||
if hasattr(item, 'action') and item.action is not None and item.action.asset_data is not None:
|
||||
layout.label(text='', icon='ASSET_MANAGER')
|
||||
|
||||
row = layout.row(align=True)
|
||||
@@ -24,27 +24,25 @@ class PSA_UL_export_sequences(UIList):
|
||||
if item.frame_end < item.frame_start:
|
||||
row.label(text='', icon='FRAME_PREV')
|
||||
if is_pose_marker:
|
||||
row.label(text=item.action.name, icon='PMARKER')
|
||||
row.label(text=item.action.name, icon='PMARKER', translate=False)
|
||||
|
||||
def draw_filter(self, context, layout):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
row = layout.row()
|
||||
subrow = row.row(align=True)
|
||||
subrow.prop(pg, 'sequence_filter_name', text="")
|
||||
subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
||||
# subrow.prop(pg, 'sequence_use_filter_sort_reverse', text='', icon='SORT_ASC')
|
||||
subrow.prop(pg, 'sequence_filter_name', text='')
|
||||
subrow.prop(pg, 'sequence_use_filter_invert', text='', icon='ARROW_LEFTRIGHT')
|
||||
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
subrow = row.row(align=True)
|
||||
subrow.prop(pg, 'sequence_filter_asset', icon_only=True, icon='ASSET_MANAGER')
|
||||
subrow.prop(pg, 'sequence_filter_pose_marker', icon_only=True, icon='PMARKER')
|
||||
subrow.prop(pg, 'sequence_filter_reversed', text="", icon='FRAME_PREV')
|
||||
subrow.prop(pg, 'sequence_filter_reversed', text='', icon='FRAME_PREV')
|
||||
|
||||
def filter_items(self, context, data, prop):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
actions = getattr(data, prop)
|
||||
flt_flags = filter_sequences(pg, actions)
|
||||
# flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name')
|
||||
flt_neworder = list(range(len(actions)))
|
||||
return flt_flags, flt_neworder
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Operator, Event, Context
|
||||
from bpy.types import Operator, Event, Context, FileHandler
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
|
||||
from .properties import get_visible_sequences
|
||||
from ..config import read_psa_config
|
||||
from ..importer import import_psa, PsaImportOptions
|
||||
from ..reader import PsaReader
|
||||
|
||||
@@ -26,7 +29,7 @@ class PSA_OT_import_sequences_from_text(Operator):
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
layout.label(icon='INFO', text='Each sequence name should be on a new line.')
|
||||
layout.label(icon='INFO', text='Each sequence name should be on a new line')
|
||||
layout.prop(pg, 'select_text', text='')
|
||||
|
||||
def execute(self, context):
|
||||
@@ -41,14 +44,16 @@ class PSA_OT_import_sequences_from_text(Operator):
|
||||
if sequence.action_name == line:
|
||||
sequence.is_selected = True
|
||||
count += 1
|
||||
self.report({'INFO'}, f'Selected {count} sequence(s)')
|
||||
message = bpy.app.translations.pgettext('Selected {count} sequence(s)')
|
||||
message = message.format(count=count)
|
||||
self.report({'INFO'}, message)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSA_OT_import_sequences_select_all(Operator):
|
||||
bl_idname = 'psa_import.sequences_select_all'
|
||||
bl_label = 'All'
|
||||
bl_description = 'Select all sequences'
|
||||
bl_description = 'Select all visible sequences'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
@@ -87,23 +92,6 @@ class PSA_OT_import_sequences_deselect_all(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSA_OT_import_select_file(Operator):
|
||||
bl_idname = 'psa_import.select_file'
|
||||
bl_label = 'Select'
|
||||
bl_options = {'INTERNAL'}
|
||||
bl_description = 'Select a PSA file from which to import animations'
|
||||
filepath: StringProperty(subtype='FILE_PATH')
|
||||
filter_glob: StringProperty(default="*.psa", options={'HIDDEN'})
|
||||
|
||||
def execute(self, context):
|
||||
getattr(context.scene, 'psa_import').psa_file_path = self.filepath
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
def load_psa_file(context, filepath: str):
|
||||
pg = context.scene.psa_import
|
||||
pg.sequence_list.clear()
|
||||
@@ -156,6 +144,10 @@ class PSA_OT_import(Operator, ImportHelper):
|
||||
psa_reader = PsaReader(self.filepath)
|
||||
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected]
|
||||
|
||||
if len(sequence_names) == 0:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected')
|
||||
return {'CANCELLED'}
|
||||
|
||||
options = PsaImportOptions()
|
||||
options.sequence_names = sequence_names
|
||||
options.should_use_fake_user = pg.should_use_fake_user
|
||||
@@ -166,15 +158,32 @@ class PSA_OT_import(Operator, ImportHelper):
|
||||
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:
|
||||
message = bpy.app.translations.pgettext_iface('Failed to read PSA config file: {error}')
|
||||
message = message.format(error=str(e))
|
||||
self.report({'WARNING'}, message)
|
||||
|
||||
result = import_psa(context, psa_reader, context.view_layer.objects.active, options)
|
||||
|
||||
if len(result.warnings) > 0:
|
||||
message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n'
|
||||
message += '\n'.join(result.warnings)
|
||||
message = bpy.app.translations.pgettext_iface('Imported {action_count} action(s) with {warning_count} warning(s)')
|
||||
message = message.format(action_count=len(sequence_names), warning_count=len(result.warnings))
|
||||
self.report({'WARNING'}, message)
|
||||
for warning in result.warnings:
|
||||
self.report({'WARNING'}, warning)
|
||||
else:
|
||||
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
|
||||
message = bpy.app.translations.pgettext_iface('Imported {action_count} action(s)')
|
||||
message = message.format(action_count=len(sequence_names))
|
||||
self.report({'INFO'}, message)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -189,18 +198,18 @@ class PSA_OT_import(Operator, ImportHelper):
|
||||
layout = self.layout
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
|
||||
sequences_header, sequences_panel = layout.panel('sequences_panel_id', default_closed=False)
|
||||
sequences_header.label(text='Sequences')
|
||||
|
||||
if sequences_panel:
|
||||
if pg.psa_error:
|
||||
row = layout.row()
|
||||
row = sequences_panel.row()
|
||||
row.label(text='Select a PSA file', icon='ERROR')
|
||||
else:
|
||||
box = layout.box()
|
||||
|
||||
box.label(text=f'Sequences ({len(pg.sequence_list)})', icon='ARMATURE_DATA')
|
||||
|
||||
# Select buttons.
|
||||
rows = max(3, min(len(pg.sequence_list), 10))
|
||||
|
||||
row = box.row()
|
||||
row = sequences_panel.row()
|
||||
col = row.column()
|
||||
|
||||
row2 = col.row(align=True)
|
||||
@@ -212,38 +221,59 @@ class PSA_OT_import(Operator, ImportHelper):
|
||||
col = col.row()
|
||||
col.template_list('PSA_UL_import_sequences', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows)
|
||||
|
||||
col = layout.column(heading='')
|
||||
col = sequences_panel.column(heading='')
|
||||
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')
|
||||
data_header, data_panel = layout.panel('data_panel_id', default_closed=False)
|
||||
data_header.label(text='Data')
|
||||
|
||||
if data_panel:
|
||||
col = data_panel.column(heading='Write')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_write_keyframes')
|
||||
col.prop(pg, 'should_write_metadata')
|
||||
|
||||
col = layout.column()
|
||||
if pg.should_write_keyframes:
|
||||
col = col.column(heading='Keyframes')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_convert_to_samples')
|
||||
|
||||
advanced_header, advanced_panel = layout.panel('advanced_panel_id', default_closed=True)
|
||||
advanced_header.label(text='Advanced')
|
||||
|
||||
if advanced_panel:
|
||||
col = advanced_panel.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'bone_mapping_mode')
|
||||
|
||||
if pg.should_write_keyframes:
|
||||
col = layout.column(heading='Keyframes')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_convert_to_samples')
|
||||
col.separator()
|
||||
|
||||
col = layout.column(heading='Options')
|
||||
col = advanced_panel.column(heading='Options')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_use_fake_user')
|
||||
col.prop(pg, 'should_stash')
|
||||
col.prop(pg, 'should_use_action_name_prefix')
|
||||
col.prop(pg, 'should_use_config_file')
|
||||
|
||||
if pg.should_use_action_name_prefix:
|
||||
col.prop(pg, 'action_name_prefix')
|
||||
|
||||
class PSA_FH_import(FileHandler):
|
||||
bl_idname = 'PSA_FH_import'
|
||||
bl_label = ''
|
||||
bl_import_operator = 'psa_import.import'
|
||||
bl_file_extensions = '.psa'
|
||||
|
||||
@classmethod
|
||||
def poll_drop(cls, context: Context):
|
||||
return context.area and context.area.type == 'VIEW_3D'
|
||||
|
||||
|
||||
classes = (
|
||||
@@ -251,5 +281,5 @@ classes = (
|
||||
PSA_OT_import_sequences_deselect_all,
|
||||
PSA_OT_import_sequences_from_text,
|
||||
PSA_OT_import,
|
||||
PSA_OT_import_select_file,
|
||||
PSA_FH_import,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,8 @@ import re
|
||||
from fnmatch import fnmatch
|
||||
from typing import List
|
||||
|
||||
from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty, PointerProperty, EnumProperty
|
||||
from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty, PointerProperty, EnumProperty, \
|
||||
FloatProperty
|
||||
from bpy.types import PropertyGroup, Text
|
||||
|
||||
empty_set = set()
|
||||
@@ -10,7 +11,7 @@ empty_set = set()
|
||||
|
||||
class PSA_PG_import_action_list_item(PropertyGroup):
|
||||
action_name: StringProperty(options=empty_set)
|
||||
is_selected: BoolProperty(default=False, options=empty_set)
|
||||
is_selected: BoolProperty(default=True, options=empty_set)
|
||||
|
||||
|
||||
class PSA_PG_bone(PropertyGroup):
|
||||
@@ -29,7 +30,13 @@ class PSA_PG_import(PropertyGroup):
|
||||
sequence_list_index: IntProperty(name='', default=0)
|
||||
should_use_fake_user: BoolProperty(default=True, name='Fake User',
|
||||
description='Assign each imported action a fake user so that the data block is '
|
||||
'saved even it has no users',
|
||||
'always saved',
|
||||
options=empty_set)
|
||||
should_use_config_file: BoolProperty(default=True, name='Use Config File',
|
||||
description='Use the .config file that is sometimes generated when the PSA '
|
||||
'file is exported from UEViewer. This file contains '
|
||||
'options that can be used to filter out certain bones tracks '
|
||||
'from the imported actions',
|
||||
options=empty_set)
|
||||
should_stash: BoolProperty(default=False, name='Stash',
|
||||
description='Stash each imported action as a strip on a new non-contributing NLA track',
|
||||
@@ -41,8 +48,8 @@ class PSA_PG_import(PropertyGroup):
|
||||
'will have it\'s data overwritten instead of a new action being created')
|
||||
should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=empty_set)
|
||||
should_write_metadata: BoolProperty(default=True, name='Metadata', options=empty_set,
|
||||
description='Additional data will be written to the custom properties of the '
|
||||
'Action (e.g., frame rate)')
|
||||
description='Additional data will be written to the properties of the Action '
|
||||
'(e.g., frame rate)')
|
||||
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
|
||||
sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected',
|
||||
description='Only show selected sequences')
|
||||
@@ -57,15 +64,30 @@ class PSA_PG_import(PropertyGroup):
|
||||
'Recommended if you do not plan on editing the actions directly'
|
||||
)
|
||||
bone_mapping_mode: EnumProperty(
|
||||
name='Bone Mapping',
|
||||
name='Bone Name Mapping',
|
||||
options=empty_set,
|
||||
description='The method by which bones from the incoming PSA file are mapped to the armature',
|
||||
description='The method by which bones from the PSA file are mapped to the bones of the armature',
|
||||
items=(
|
||||
('EXACT', 'Exact', 'Bone names must match exactly.', 'EXACT', 0),
|
||||
('CASE_INSENSITIVE', 'Case Insensitive', 'Bones names must match, ignoring case (e.g., the bone PSA bone '
|
||||
'\'root\' can be mapped to the armature bone \'Root\')', 'CASE_INSENSITIVE', 1),
|
||||
('EXACT', 'Exact', 'Bone names must match exactly', 'EXACT', 0),
|
||||
('CASE_INSENSITIVE', 'Case Insensitive', 'Bones names must match, ignoring case (e.g., the PSA bone '
|
||||
'\'aBcDeF\' can be mapped to the armature bone \'ABCDEF\')', 'CASE_INSENSITIVE', 1),
|
||||
)
|
||||
)
|
||||
fps_source: EnumProperty(name='FPS Source', items=(
|
||||
('SEQUENCE', 'Sequence', 'The sequence frame rate matches the original frame rate', 'ACTION', 0),
|
||||
('SCENE', 'Scene', 'The sequence is resampled to the frame rate of the scene', 'SCENE_DATA', 1),
|
||||
('CUSTOM', 'Custom', 'The sequence is resampled to a custom frame rate', 2),
|
||||
))
|
||||
fps_custom: FloatProperty(
|
||||
default=30.0,
|
||||
name='Custom FPS',
|
||||
description='The frame rate to which the imported sequences will be resampled to',
|
||||
options=empty_set,
|
||||
min=1.0,
|
||||
soft_min=1.0,
|
||||
soft_max=60.0,
|
||||
step=100,
|
||||
)
|
||||
|
||||
|
||||
def filter_sequences(pg: PSA_PG_import, sequences) -> List[int]:
|
||||
|
||||
@@ -10,17 +10,16 @@ class PSA_UL_sequences(UIList):
|
||||
split = row.split(align=True, factor=0.75)
|
||||
column = split.row(align=True)
|
||||
column.alignment = 'LEFT'
|
||||
column.prop(item, 'is_selected', icon_only=True)
|
||||
column.label(text=getattr(item, 'action_name'))
|
||||
column.prop(item, 'is_selected', text=getattr(item, 'action_name'), translate=False)
|
||||
|
||||
def draw_filter(self, context, layout):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
row = layout.row()
|
||||
sub_row = row.row(align=True)
|
||||
sub_row.prop(pg, 'sequence_filter_name', text="")
|
||||
sub_row.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
||||
sub_row.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT')
|
||||
sub_row.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT')
|
||||
sub_row.prop(pg, 'sequence_filter_name', text='')
|
||||
sub_row.prop(pg, 'sequence_use_filter_invert', text='', icon='ARROW_LEFTRIGHT')
|
||||
sub_row.prop(pg, 'sequence_use_filter_regex', text='', icon='SORTBYEXT')
|
||||
sub_row.prop(pg, 'sequence_filter_is_selected', text='', icon='CHECKBOX_HLT')
|
||||
|
||||
def filter_items(self, context, data, property_):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
|
||||
@@ -2,10 +2,11 @@ import typing
|
||||
from typing import List, Optional
|
||||
|
||||
import bpy
|
||||
import numpy
|
||||
import numpy as np
|
||||
from bpy.types import FCurve, Object, Context
|
||||
from mathutils import Vector, Quaternion
|
||||
|
||||
from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION
|
||||
from .data import Psa
|
||||
from .reader import PsaReader
|
||||
|
||||
@@ -21,6 +22,10 @@ class PsaImportOptions(object):
|
||||
self.action_name_prefix = ''
|
||||
self.should_convert_to_samples = False
|
||||
self.bone_mapping_mode = 'CASE_INSENSITIVE'
|
||||
self.fps_source = 'SEQUENCE'
|
||||
self.fps_custom: float = 30.0
|
||||
self.should_use_config_file = True
|
||||
self.psa_config: PsaConfig = PsaConfig()
|
||||
|
||||
|
||||
class ImportBone(object):
|
||||
@@ -29,9 +34,9 @@ class ImportBone(object):
|
||||
self.parent: Optional[ImportBone] = None
|
||||
self.armature_bone = None
|
||||
self.pose_bone = None
|
||||
self.orig_loc: Vector = Vector()
|
||||
self.orig_quat: Quaternion = Quaternion()
|
||||
self.post_quat: Quaternion = Quaternion()
|
||||
self.original_location: Vector = Vector()
|
||||
self.original_rotation: Quaternion = Quaternion()
|
||||
self.post_rotation: Quaternion = Quaternion()
|
||||
self.fcurves: List[FCurve] = []
|
||||
|
||||
|
||||
@@ -39,18 +44,18 @@ def _calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[fl
|
||||
# Convert world-space transforms to local-space transforms.
|
||||
key_rotation = Quaternion(key_data[0:4])
|
||||
key_location = Vector(key_data[4:])
|
||||
q = import_bone.post_quat.copy()
|
||||
q.rotate(import_bone.orig_quat)
|
||||
quat = q
|
||||
q = import_bone.post_quat.copy()
|
||||
q = import_bone.post_rotation.copy()
|
||||
q.rotate(import_bone.original_rotation)
|
||||
rotation = q
|
||||
q = import_bone.post_rotation.copy()
|
||||
if import_bone.parent is None:
|
||||
q.rotate(key_rotation.conjugated())
|
||||
else:
|
||||
q.rotate(key_rotation)
|
||||
quat.rotate(q.conjugated())
|
||||
loc = key_location - import_bone.orig_loc
|
||||
loc.rotate(import_bone.post_quat.conjugated())
|
||||
return quat.w, quat.x, quat.y, quat.z, loc.x, loc.y, loc.z
|
||||
rotation.rotate(q.conjugated())
|
||||
location = key_location - import_bone.original_location
|
||||
location.rotate(import_bone.post_rotation.conjugated())
|
||||
return rotation.w, rotation.x, rotation.y, rotation.z, location.x, location.y, location.z
|
||||
|
||||
|
||||
class PsaImportResult:
|
||||
@@ -74,6 +79,51 @@ def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_name
|
||||
return armature_bone_index
|
||||
return None
|
||||
|
||||
def _get_sample_frame_times(source_frame_count: int, frame_step: float) -> typing.Iterable[float]:
|
||||
# TODO: for correctness, we should also emit the target frame time as well (because the last frame can be a
|
||||
# fractional frame).
|
||||
time = 0.0
|
||||
while time < source_frame_count - 1:
|
||||
yield time
|
||||
time += frame_step
|
||||
yield source_frame_count - 1
|
||||
|
||||
def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step: float = 1.0) -> np.ndarray:
|
||||
"""
|
||||
Resamples the sequence data matrix to the target frame count.
|
||||
@param sequence_data_matrix: FxBx7 matrix where F is the number of frames, B is the number of bones, and X is the
|
||||
number of data elements per bone.
|
||||
@param frame_step: The step between frames in the resampled sequence.
|
||||
@return: The resampled sequence data matrix, or sequence_data_matrix if no resampling is necessary.
|
||||
"""
|
||||
if frame_step == 1.0:
|
||||
# No resampling is necessary.
|
||||
return sequence_data_matrix
|
||||
|
||||
source_frame_count, bone_count = sequence_data_matrix.shape[:2]
|
||||
sample_frame_times = list(_get_sample_frame_times(source_frame_count, frame_step))
|
||||
target_frame_count = len(sample_frame_times)
|
||||
resampled_sequence_data_matrix = np.zeros((target_frame_count, bone_count, 7), dtype=float)
|
||||
|
||||
for sample_frame_index, sample_frame_time in enumerate(sample_frame_times):
|
||||
frame_index = int(sample_frame_time)
|
||||
if sample_frame_time % 1.0 == 0.0:
|
||||
# Sample time has no fractional part, so just copy the frame.
|
||||
resampled_sequence_data_matrix[sample_frame_index, :, :] = sequence_data_matrix[frame_index, :, :]
|
||||
else:
|
||||
# Sample time has a fractional part, so interpolate between two frames.
|
||||
next_frame_index = frame_index + 1
|
||||
for bone_index in range(bone_count):
|
||||
source_frame_1_data = sequence_data_matrix[frame_index, bone_index, :]
|
||||
source_frame_2_data = sequence_data_matrix[next_frame_index, bone_index, :]
|
||||
factor = sample_frame_time - frame_index
|
||||
q = Quaternion((source_frame_1_data[:4])).slerp(Quaternion((source_frame_2_data[:4])), factor)
|
||||
q.normalize()
|
||||
l = Vector(source_frame_1_data[4:]).lerp(Vector(source_frame_2_data[4:]), factor)
|
||||
resampled_sequence_data_matrix[sample_frame_index, bone_index, :] = q.w, q.x, q.y, q.z, l.x, l.y, l.z
|
||||
|
||||
return resampled_sequence_data_matrix
|
||||
|
||||
|
||||
def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult:
|
||||
result = PsaImportResult()
|
||||
@@ -108,16 +158,16 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
|
||||
psa_bone_name = psa_bone_names[psa_bone_index]
|
||||
armature_bone_name = armature_bone_names[armature_bone_index]
|
||||
mapped_psa_bone_name = psa_bone_names[mapped_psa_bone_index]
|
||||
result.warnings.append(f'PSA bone {psa_bone_index} ({psa_bone_name}) could not be mapped to armature bone {armature_bone_index} ({armature_bone_name}) because the armature bone is already mapped to PSA bone {mapped_psa_bone_index} ({mapped_psa_bone_name})')
|
||||
message = bpy.app.translations.pgettext_iface('PSA bone {bone_index} ({bone_name}) could not be mapped to armature bone {armature_bone_index} ({armature_bone_name}) because the armature bone is already mapped to PSA bone {mapped_psa_bone_index} ({mapped_psa_bone_name})')
|
||||
message = message.format(bone_index=psa_bone_index, bone_name=psa_bone_name, armature_bone_index=armature_bone_index, armature_bone_name=armature_bone_name, mapped_psa_bone_index=mapped_psa_bone_index, mapped_psa_bone_name=mapped_psa_bone_name)
|
||||
result.warnings.append(message)
|
||||
|
||||
# Report if there are missing bones in the target armature.
|
||||
missing_bone_names = set(psa_bone_names).difference(set(armature_bone_names))
|
||||
if len(missing_bone_names) > 0:
|
||||
result.warnings.append(
|
||||
f'The armature \'{armature_object.name}\' is missing {len(missing_bone_names)} bones that exist in '
|
||||
'the PSA:\n' +
|
||||
str(list(sorted(missing_bone_names)))
|
||||
)
|
||||
message = bpy.app.translations.pgettext_iface('The armature \'{armature_name}\' is missing {count} bones that exist in the PSA:\n{missing_bone_names}')
|
||||
message = message.format(armature_name=armature_object.name, count=len(missing_bone_names), missing_bone_names=str(list(sorted(missing_bone_names))))
|
||||
result.warnings.append(message)
|
||||
del armature_bone_names
|
||||
|
||||
# Create intermediate bone data for import operations.
|
||||
@@ -137,26 +187,22 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
|
||||
|
||||
for import_bone in filter(lambda x: x is not None, import_bones):
|
||||
armature_bone = import_bone.armature_bone
|
||||
|
||||
if armature_bone.parent is not None and armature_bone.parent.name in psa_bone_names:
|
||||
import_bone.parent = import_bones_dict[armature_bone.parent.name]
|
||||
|
||||
# Calculate the original location & rotation of each bone (in world-space maybe?)
|
||||
if armature_bone.get('orig_quat') is not None:
|
||||
# TODO: ideally we don't rely on bone auxiliary data like this, the non-aux data path is incorrect
|
||||
# (animations are flipped 180 around Z)
|
||||
import_bone.orig_quat = Quaternion(armature_bone['orig_quat'])
|
||||
import_bone.orig_loc = Vector(armature_bone['orig_loc'])
|
||||
import_bone.post_quat = Quaternion(armature_bone['post_quat'])
|
||||
else:
|
||||
if import_bone.parent is not None:
|
||||
import_bone.orig_loc = armature_bone.matrix_local.translation - armature_bone.parent.matrix_local.translation
|
||||
import_bone.orig_loc.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
|
||||
import_bone.orig_quat = armature_bone.matrix_local.to_quaternion()
|
||||
import_bone.orig_quat.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
|
||||
import_bone.orig_quat.conjugate()
|
||||
import_bone.original_location = armature_bone.matrix_local.translation - armature_bone.parent.matrix_local.translation
|
||||
import_bone.original_location.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
|
||||
import_bone.original_rotation = armature_bone.matrix_local.to_quaternion()
|
||||
import_bone.original_rotation.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
|
||||
import_bone.original_rotation.conjugate()
|
||||
else:
|
||||
import_bone.orig_loc = armature_bone.matrix_local.translation.copy()
|
||||
import_bone.orig_quat = armature_bone.matrix_local.to_quaternion()
|
||||
import_bone.post_quat = import_bone.orig_quat.conjugated()
|
||||
import_bone.original_location = armature_bone.matrix_local.translation.copy()
|
||||
import_bone.original_rotation = armature_bone.matrix_local.to_quaternion().conjugated()
|
||||
|
||||
import_bone.post_rotation = import_bone.original_rotation.conjugated()
|
||||
|
||||
context.window_manager.progress_begin(0, len(sequences))
|
||||
|
||||
@@ -167,30 +213,50 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
|
||||
sequence_name = sequence.name.decode('windows-1252')
|
||||
action_name = options.action_name_prefix + sequence_name
|
||||
|
||||
# Get the bone track flags for this sequence, or an empty dictionary if none exist.
|
||||
sequence_bone_track_flags = dict()
|
||||
if sequence_name in options.psa_config.sequence_bone_flags.keys():
|
||||
sequence_bone_track_flags = options.psa_config.sequence_bone_flags[sequence_name]
|
||||
|
||||
if options.should_overwrite and action_name in bpy.data.actions:
|
||||
action = bpy.data.actions[action_name]
|
||||
else:
|
||||
action = bpy.data.actions.new(name=action_name)
|
||||
|
||||
# Calculate the target FPS.
|
||||
match options.fps_source:
|
||||
case 'CUSTOM':
|
||||
target_fps = options.fps_custom
|
||||
case 'SCENE':
|
||||
target_fps = context.scene.render.fps
|
||||
case 'SEQUENCE':
|
||||
target_fps = sequence.fps
|
||||
case _:
|
||||
message = bpy.app.translations.pgettext_iface('Invalid FPS source: {fps_source}')
|
||||
message = message.format(fps_source=options.fps_source)
|
||||
raise ValueError(message)
|
||||
|
||||
if options.should_write_keyframes:
|
||||
# Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2)
|
||||
while len(action.fcurves) > 0:
|
||||
action.fcurves.remove(action.fcurves[-1])
|
||||
# Remove existing f-curves.
|
||||
action.fcurves.clear()
|
||||
|
||||
# Create f-curves for the rotation and location of each bone.
|
||||
for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items():
|
||||
bone_track_flags = sequence_bone_track_flags.get(psa_bone_index, 0)
|
||||
import_bone = import_bones[psa_bone_index]
|
||||
pose_bone = import_bone.pose_bone
|
||||
rotation_data_path = pose_bone.path_from_id('rotation_quaternion')
|
||||
location_data_path = pose_bone.path_from_id('location')
|
||||
add_rotation_fcurves = (bone_track_flags & REMOVE_TRACK_ROTATION) == 0
|
||||
add_location_fcurves = (bone_track_flags & REMOVE_TRACK_LOCATION) == 0
|
||||
import_bone.fcurves = [
|
||||
action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name), # Qw
|
||||
action.fcurves.new(rotation_data_path, index=1, action_group=pose_bone.name), # Qx
|
||||
action.fcurves.new(rotation_data_path, index=2, action_group=pose_bone.name), # Qy
|
||||
action.fcurves.new(rotation_data_path, index=3, action_group=pose_bone.name), # Qz
|
||||
action.fcurves.new(location_data_path, index=0, action_group=pose_bone.name), # Lx
|
||||
action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name), # Ly
|
||||
action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name), # Lz
|
||||
action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qw
|
||||
action.fcurves.new(rotation_data_path, index=1, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qx
|
||||
action.fcurves.new(rotation_data_path, index=2, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qy
|
||||
action.fcurves.new(rotation_data_path, index=3, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qz
|
||||
action.fcurves.new(location_data_path, index=0, action_group=pose_bone.name) if add_location_fcurves else None, # Lx
|
||||
action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name) if add_location_fcurves else None, # Ly
|
||||
action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name) if add_location_fcurves else None, # Lz
|
||||
]
|
||||
|
||||
# Read the sequence data matrix from the PSA.
|
||||
@@ -206,16 +272,28 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
|
||||
# Calculate the local-space key data for the bone.
|
||||
sequence_data_matrix[frame_index, bone_index] = _calculate_fcurve_data(import_bone, key_data)
|
||||
|
||||
# Resample the sequence data to the target FPS.
|
||||
# If the target frame count is the same as the source frame count, this will be a no-op.
|
||||
resampled_sequence_data_matrix = _resample_sequence_data_matrix(sequence_data_matrix,
|
||||
frame_step=sequence.fps / target_fps)
|
||||
|
||||
# Write the keyframes out.
|
||||
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float)
|
||||
fcurve_data[0::2] = range(sequence.frame_count)
|
||||
# Note that the f-curve data consists of alternating time and value data.
|
||||
target_frame_count = resampled_sequence_data_matrix.shape[0]
|
||||
fcurve_data = np.zeros(2 * target_frame_count, dtype=float)
|
||||
fcurve_data[0::2] = range(0, target_frame_count)
|
||||
|
||||
for bone_index, import_bone in enumerate(import_bones):
|
||||
if import_bone is None:
|
||||
continue
|
||||
for fcurve_index, fcurve in enumerate(import_bone.fcurves):
|
||||
fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index]
|
||||
fcurve.keyframe_points.add(sequence.frame_count)
|
||||
if fcurve is None:
|
||||
continue
|
||||
fcurve_data[1::2] = resampled_sequence_data_matrix[:, bone_index, fcurve_index]
|
||||
fcurve.keyframe_points.add(target_frame_count)
|
||||
fcurve.keyframe_points.foreach_set('co', fcurve_data)
|
||||
for fcurve_keyframe in fcurve.keyframe_points:
|
||||
fcurve_keyframe.interpolation = 'LINEAR'
|
||||
|
||||
if options.should_convert_to_samples:
|
||||
# Bake the curve to samples.
|
||||
@@ -224,7 +302,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
|
||||
|
||||
# Write meta-data.
|
||||
if options.should_write_metadata:
|
||||
action['psa_sequence_fps'] = sequence.fps
|
||||
action.psa_export.fps = target_fps
|
||||
|
||||
action.use_fake_user = options.should_use_fake_user
|
||||
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import ctypes
|
||||
import warnings
|
||||
|
||||
import bpy.app.translations
|
||||
import numpy as np
|
||||
|
||||
from .data import *
|
||||
|
||||
|
||||
def _try_fix_cue4parse_issue_103(sequences) -> bool:
|
||||
# Detect if the file was exported from CUE4Parse prior to the fix for issue #103.
|
||||
# https://github.com/FabianFG/CUE4Parse/issues/103
|
||||
# The issue was that the frame_start_index was not being set correctly, and was always being set to the same value
|
||||
# as the frame_count.
|
||||
# This fix will eventually be deprecated as it is only necessary for files exported prior to the fix.
|
||||
if len(sequences) > 0 and sequences[0].frame_start_index == sequences[0].frame_count:
|
||||
# Manually set the frame_start_index for each sequence. This assumes that the sequences are in order with
|
||||
# no shared frames between sequences (all exporters that I know of do this, so it's a safe assumption).
|
||||
frame_start_index = 0
|
||||
for i, sequence in enumerate(sequences):
|
||||
sequence.frame_start_index = frame_start_index
|
||||
frame_start_index += sequence.frame_count
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PsaReader(object):
|
||||
"""
|
||||
This class reads the sequences and bone information immediately upon instantiation and holds onto a file handle.
|
||||
@@ -86,14 +105,17 @@ class PsaReader(object):
|
||||
elif section.name == b'ANIMINFO':
|
||||
sequences = []
|
||||
PsaReader._read_types(fp, Psa.Sequence, section, sequences)
|
||||
# Try to fix CUE4Parse bug, if necessary.
|
||||
_try_fix_cue4parse_issue_103(sequences)
|
||||
for sequence in sequences:
|
||||
psa.sequences[sequence.name.decode()] = sequence
|
||||
elif section.name == b'ANIMKEYS':
|
||||
# Skip keys on this pass. We will keep this file open and read from it as needed.
|
||||
self.keys_data_offset = fp.tell()
|
||||
fp.seek(section.data_size * section.data_count, 1)
|
||||
elif section.name == b'SCALEKEYS':
|
||||
fp.seek(section.data_size * section.data_count, 1)
|
||||
else:
|
||||
raise RuntimeError(f'Unrecognized section "{section.name}"')
|
||||
fp.seek(section.data_size * section.data_count, 1)
|
||||
message = bpy.app.translations.pgettext_iface('Unhandled section "{section_name}" at position {position}')
|
||||
message = message.format(section_name=section.name.decode(), position=fp.tell())
|
||||
warnings.warn(message)
|
||||
return psa
|
||||
|
||||
@@ -1,40 +1,55 @@
|
||||
from typing import Optional
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
from bpy.types import Armature
|
||||
import numpy as np
|
||||
from bpy.types import Armature, Material
|
||||
|
||||
from .data import *
|
||||
from .properties import triangle_type_and_bit_flags_to_poly_flags
|
||||
from ..helpers import *
|
||||
|
||||
|
||||
class PskInputObjects(object):
|
||||
def __init__(self):
|
||||
self.mesh_objects = []
|
||||
self.armature_object = None
|
||||
self.mesh_objects = set()
|
||||
self.armature_object: Optional[Object] = None
|
||||
|
||||
|
||||
class PskBuildOptions(object):
|
||||
def __init__(self):
|
||||
self.bone_filter_mode = 'ALL'
|
||||
self.bone_group_indices: List[int] = []
|
||||
self.bone_collection_indices: List[int] = []
|
||||
self.use_raw_mesh_data = True
|
||||
self.material_names: List[str] = []
|
||||
self.materials: List[Material] = []
|
||||
self.should_enforce_bone_name_restrictions = False
|
||||
|
||||
|
||||
def get_psk_input_objects(context) -> PskInputObjects:
|
||||
input_objects = PskInputObjects()
|
||||
for selected_object in context.view_layer.objects.selected:
|
||||
if selected_object.type != 'MESH':
|
||||
raise RuntimeError(f'Selected object "{selected_object.name}" is not a mesh')
|
||||
|
||||
input_objects.mesh_objects = context.view_layer.objects.selected
|
||||
# Check if the active object is an armature.
|
||||
if context.active_object is not None and context.active_object.type == 'ARMATURE':
|
||||
# Collect all the mesh objects that have an armature modifier referencing the active armature.
|
||||
input_objects.armature_object = context.active_object
|
||||
for obj in filter(lambda x: x.type == 'MESH' and x.visible_get(), context.view_layer.objects):
|
||||
for modifier in obj.modifiers:
|
||||
if modifier.type == 'ARMATURE' and modifier.object == input_objects.armature_object:
|
||||
input_objects.mesh_objects.add(obj)
|
||||
|
||||
for selected_object in context.view_layer.objects.selected:
|
||||
if selected_object is input_objects.armature_object:
|
||||
continue
|
||||
if selected_object.type == 'MESH':
|
||||
input_objects.mesh_objects.add(selected_object)
|
||||
|
||||
if len(input_objects.mesh_objects) == 0:
|
||||
raise RuntimeError('At least one mesh must be selected')
|
||||
|
||||
for mesh_object in input_objects.mesh_objects:
|
||||
if len(mesh_object.data.materials) == 0:
|
||||
raise RuntimeError(f'Mesh "{mesh_object.name}" must have at least one material')
|
||||
message = bpy.app.translations.pgettext_iface('Mesh "{object_name}" must have at least one material')
|
||||
raise RuntimeError(message.format(object_name=mesh_object.name))
|
||||
|
||||
# Ensure that there are either no armature modifiers (static mesh)
|
||||
# or that there is exactly one armature modifier object shared between
|
||||
@@ -46,29 +61,38 @@ def get_psk_input_objects(context) -> PskInputObjects:
|
||||
if len(modifiers) == 0:
|
||||
continue
|
||||
elif len(modifiers) > 1:
|
||||
raise RuntimeError(f'Mesh "{mesh_object.name}" must have only one armature modifier')
|
||||
message = bpy.app.translations.pgettext_iface('Mesh "{object_name}" must have only one armature modifier')
|
||||
raise RuntimeError(message.format(object_name=mesh_object.name))
|
||||
armature_modifier_objects.add(modifiers[0].object)
|
||||
|
||||
if len(armature_modifier_objects) > 1:
|
||||
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)})')
|
||||
message = bpy.app.translations.pgettext_iface('All selected meshes must have the same armature modifier, encountered {count} ({names})')
|
||||
message = message.format(count=len(armature_modifier_objects), names=', '.join(armature_modifier_names))
|
||||
raise RuntimeError(message)
|
||||
elif len(armature_modifier_objects) == 1:
|
||||
input_objects.armature_object = list(armature_modifier_objects)[0]
|
||||
|
||||
return input_objects
|
||||
|
||||
|
||||
def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
input_objects = get_psk_input_objects(context)
|
||||
class PskBuildResult(object):
|
||||
def __init__(self):
|
||||
self.psk = None
|
||||
self.warnings: List[str] = []
|
||||
|
||||
|
||||
def build_psk(context, options: PskBuildOptions) -> PskBuildResult:
|
||||
input_objects = get_psk_input_objects(context)
|
||||
armature_object: bpy.types.Object = input_objects.armature_object
|
||||
|
||||
result = PskBuildResult()
|
||||
psk = Psk()
|
||||
bones = []
|
||||
|
||||
if armature_object is None:
|
||||
# If the mesh has no armature object, simply assign it a dummy bone at the root to satisfy the requirement
|
||||
# that a PSK file must have at least one bone.
|
||||
if armature_object is None or len(armature_object.data.bones) == 0:
|
||||
# If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the
|
||||
# requirement that a PSK file must have at least one bone.
|
||||
psk_bone = Psk.Bone()
|
||||
psk_bone.name = bytes('root', encoding='windows-1252')
|
||||
psk_bone.flags = 0
|
||||
@@ -78,7 +102,7 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
psk_bone.rotation = Quaternion.identity()
|
||||
psk.bones.append(psk_bone)
|
||||
else:
|
||||
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices)
|
||||
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_indices)
|
||||
armature_data = typing.cast(Armature, armature_object.data)
|
||||
bones = [armature_data.bones[bone_name] for bone_name in bone_names]
|
||||
|
||||
@@ -88,7 +112,11 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
|
||||
for bone in bones:
|
||||
psk_bone = Psk.Bone()
|
||||
try:
|
||||
psk_bone.name = bytes(bone.name, encoding='windows-1252')
|
||||
except UnicodeEncodeError:
|
||||
message = bpy.app.translations.pgettext_iface('Bone name "{name}" contains characters that cannot be encoded in the Windows-1252 codepage')
|
||||
raise RuntimeError(message.format(name=bone.name))
|
||||
psk_bone.flags = 0
|
||||
psk_bone.children_count = 0
|
||||
|
||||
@@ -125,18 +153,28 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
psk.bones.append(psk_bone)
|
||||
|
||||
# MATERIALS
|
||||
material_names = options.material_names
|
||||
|
||||
for material_name in material_names:
|
||||
for material in options.materials:
|
||||
psk_material = Psk.Material()
|
||||
psk_material.name = bytes(material_name, encoding='windows-1252')
|
||||
try:
|
||||
psk_material.name = bytes(material.name, encoding='windows-1252')
|
||||
except UnicodeEncodeError:
|
||||
message = bpy.app.translations.pgettext_iface('Material name "{name}" contains characters that cannot be encoded in the Windows-1252 codepage')
|
||||
raise RuntimeError(message.format(name=material.name))
|
||||
psk_material.texture_index = len(psk.materials)
|
||||
psk_material.poly_flags = triangle_type_and_bit_flags_to_poly_flags(material.psk.mesh_triangle_type,
|
||||
material.psk.mesh_triangle_bit_flags)
|
||||
psk.materials.append(psk_material)
|
||||
|
||||
for input_mesh_object in input_objects.mesh_objects:
|
||||
context.window_manager.progress_begin(0, len(input_objects.mesh_objects))
|
||||
|
||||
material_names = [m.name for m in options.materials]
|
||||
|
||||
for object_index, input_mesh_object in enumerate(input_objects.mesh_objects):
|
||||
|
||||
should_flip_normals = False
|
||||
|
||||
# MATERIALS
|
||||
material_indices = [material_names.index(material.name) for material in input_mesh_object.data.materials]
|
||||
material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots]
|
||||
|
||||
# MESH DATA
|
||||
if options.use_raw_mesh_data:
|
||||
@@ -147,6 +185,8 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
|
||||
# Temporarily force the armature into the rest position.
|
||||
# We will undo this later.
|
||||
old_pose_position = None
|
||||
if armature_object is not None:
|
||||
old_pose_position = armature_object.data.pose_position
|
||||
armature_object.data.pose_position = 'REST'
|
||||
|
||||
@@ -159,11 +199,24 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
mesh_object = bpy.data.objects.new('', mesh_data)
|
||||
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)
|
||||
|
||||
# Negative scaling in Blender results in inverted normals after the scale is applied. However, if the scale
|
||||
# is not applied, the normals will appear unaffected in the viewport. The evaluated mesh data used in the
|
||||
# export will have the scale applied, but this behavior is not obvious to the user.
|
||||
#
|
||||
# In order to have the exporter be as WYSIWYG as possible, we need to check for negative scaling and invert
|
||||
# the normals if necessary. If two axes have negative scaling and the third has positive scaling, the
|
||||
# normals will be correct. We can detect this by checking if the number of negative scaling axes is odd. If
|
||||
# it is, we need to invert the normals of the mesh by swapping the order of the vertices in each face.
|
||||
should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1
|
||||
|
||||
# Copy the vertex groups
|
||||
for vertex_group in input_mesh_object.vertex_groups:
|
||||
mesh_object.vertex_groups.new(name=vertex_group.name)
|
||||
|
||||
# Restore the previous pose position on the armature.
|
||||
if old_pose_position is not None:
|
||||
armature_object.data.pose_position = old_pose_position
|
||||
|
||||
vertex_offset = len(psk.points)
|
||||
@@ -185,11 +238,11 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
# Build a list of non-unique wedges.
|
||||
wedges = []
|
||||
for loop_index, loop in enumerate(mesh_data.loops):
|
||||
wedge = Psk.Wedge()
|
||||
wedge.point_index = loop.vertex_index + vertex_offset
|
||||
wedge.u, wedge.v = uv_layer[loop_index].uv
|
||||
wedge.v = 1.0 - wedge.v
|
||||
wedges.append(wedge)
|
||||
wedges.append(Psk.Wedge(
|
||||
point_index=loop.vertex_index + vertex_offset,
|
||||
u=uv_layer[loop_index].uv[0],
|
||||
v=1.0 - uv_layer[loop_index].uv[1]
|
||||
))
|
||||
|
||||
# Assign material indices to the wedges.
|
||||
for triangle in mesh_data.loop_triangles:
|
||||
@@ -197,8 +250,8 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
wedges[loop_index].material_index = material_indices[triangle.material_index]
|
||||
|
||||
# Populate the list of wedges with unique wedges & build a look-up table of loop indices to wedge indices
|
||||
wedge_indices = {}
|
||||
loop_wedge_indices = [-1] * len(mesh_data.loops)
|
||||
wedge_indices = dict()
|
||||
loop_wedge_indices = np.full(len(mesh_data.loops), -1)
|
||||
for loop_index, wedge in enumerate(wedges):
|
||||
wedge_hash = hash(wedge)
|
||||
if wedge_hash in wedge_indices:
|
||||
@@ -211,6 +264,7 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
|
||||
# FACES
|
||||
poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=True)
|
||||
psk_face_start_index = len(psk.faces)
|
||||
for f in mesh_data.loop_triangles:
|
||||
face = Psk.Face()
|
||||
face.material_index = material_indices[f.material_index]
|
||||
@@ -220,6 +274,11 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
face.smoothing_groups = poly_groups[f.polygon_index]
|
||||
psk.faces.append(face)
|
||||
|
||||
if should_flip_normals:
|
||||
# Invert the normals of the faces.
|
||||
for face in psk.faces[psk_face_start_index:]:
|
||||
face.wedge_indices[0], face.wedge_indices[2] = face.wedge_indices[2], face.wedge_indices[0]
|
||||
|
||||
# WEIGHTS
|
||||
if armature_object is not None:
|
||||
armature_data = typing.cast(Armature, armature_object.data)
|
||||
@@ -268,4 +327,10 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
bpy.data.meshes.remove(mesh_data)
|
||||
del mesh_data
|
||||
|
||||
return psk
|
||||
context.window_manager.progress_update(object_index)
|
||||
|
||||
context.window_manager.progress_end()
|
||||
|
||||
result.psk = psk
|
||||
|
||||
return result
|
||||
|
||||
@@ -5,11 +5,11 @@ from ..data import *
|
||||
|
||||
class Psk(object):
|
||||
class Wedge(object):
|
||||
def __init__(self):
|
||||
self.point_index: int = 0
|
||||
self.u: float = 0.0
|
||||
self.v: float = 0.0
|
||||
self.material_index: int = 0
|
||||
def __init__(self, point_index: int, u: float, v: float, material_index: int = 0):
|
||||
self.point_index: int = point_index
|
||||
self.u: float = u
|
||||
self.v: float = v
|
||||
self.material_index = material_index
|
||||
|
||||
def __hash__(self):
|
||||
return hash(f'{self.point_index}-{self.u}-{self.v}-{self.material_index}')
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import bpy
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Operator
|
||||
from bpy_extras.io_utils import ExportHelper
|
||||
|
||||
from ..builder import build_psk, PskBuildOptions, get_psk_input_objects
|
||||
from ..writer import write_psk
|
||||
from ...helpers import populate_bone_group_list
|
||||
from ...helpers import populate_bone_collection_list
|
||||
|
||||
|
||||
def is_bone_filter_mode_item_available(context, identifier):
|
||||
input_objects = get_psk_input_objects(context)
|
||||
armature_object = input_objects.armature_object
|
||||
if identifier == 'BONE_GROUPS':
|
||||
if not armature_object or not armature_object.pose or not armature_object.pose.bone_groups:
|
||||
if identifier == 'BONE_COLLECTIONS':
|
||||
if armature_object is None or armature_object.data is None or len(armature_object.data.collections) == 0:
|
||||
return False
|
||||
# else if... you can set up other conditions if you add more options
|
||||
return True
|
||||
@@ -20,18 +21,20 @@ def is_bone_filter_mode_item_available(context, identifier):
|
||||
def populate_material_list(mesh_objects, material_list):
|
||||
material_list.clear()
|
||||
|
||||
material_names = []
|
||||
materials = []
|
||||
for mesh_object in mesh_objects:
|
||||
for i, material in enumerate(mesh_object.data.materials):
|
||||
for i, material_slot in enumerate(mesh_object.material_slots):
|
||||
material = material_slot.material
|
||||
# TODO: put this in the poll arg?
|
||||
if material is None:
|
||||
raise RuntimeError('Material cannot be empty (index ' + str(i) + ')')
|
||||
if material.name not in material_names:
|
||||
material_names.append(material.name)
|
||||
message = bpy.app.translations.pgettext_iface('Material slot cannot be empty (index {index})')
|
||||
raise RuntimeError(message.format(index=i))
|
||||
if material not in materials:
|
||||
materials.append(material)
|
||||
|
||||
for index, material_name in enumerate(material_names):
|
||||
for index, material in enumerate(materials):
|
||||
m = material_list.add()
|
||||
m.material_name = material_name
|
||||
m.material = material
|
||||
m.index = index
|
||||
|
||||
|
||||
@@ -50,7 +53,7 @@ class PSK_OT_material_list_move_up(Operator):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
pg.material_list.move(pg.material_list_index, pg.material_list_index - 1)
|
||||
pg.material_list_index -= 1
|
||||
return {"FINISHED"}
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSK_OT_material_list_move_down(Operator):
|
||||
@@ -68,7 +71,7 @@ class PSK_OT_material_list_move_down(Operator):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
pg.material_list.move(pg.material_list_index, pg.material_list_index + 1)
|
||||
pg.material_list_index += 1
|
||||
return {"FINISHED"}
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSK_OT_export(Operator, ExportHelper):
|
||||
@@ -94,8 +97,7 @@ class PSK_OT_export(Operator, ExportHelper):
|
||||
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
|
||||
# Populate bone groups list.
|
||||
populate_bone_group_list(input_objects.armature_object, pg.bone_group_list)
|
||||
populate_bone_collection_list(input_objects.armature_object, pg.bone_collection_list)
|
||||
|
||||
try:
|
||||
populate_material_list(input_objects.mesh_objects, pg.material_list)
|
||||
@@ -121,33 +123,36 @@ class PSK_OT_export(Operator, ExportHelper):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
|
||||
# MESH
|
||||
box = layout.box()
|
||||
box.label(text='Mesh', icon='MESH_DATA')
|
||||
box.prop(pg, 'use_raw_mesh_data')
|
||||
mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False)
|
||||
mesh_header.label(text='Mesh', icon='MESH_DATA')
|
||||
if mesh_panel is not None:
|
||||
mesh_panel.prop(pg, 'use_raw_mesh_data')
|
||||
|
||||
# BONES
|
||||
box = layout.box()
|
||||
box.label(text='Bones', icon='BONE_DATA')
|
||||
bones_header, bones_panel = layout.panel('bones_panel_id', default_closed=False)
|
||||
bones_header.label(text='Bones', icon='BONE_DATA')
|
||||
if bones_panel is not None:
|
||||
bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static
|
||||
row = box.row(align=True)
|
||||
row = bones_panel.row(align=True)
|
||||
for item in bone_filter_mode_items:
|
||||
identifier = item.identifier
|
||||
item_layout = row.row(align=True)
|
||||
item_layout.prop_enum(pg, 'bone_filter_mode', item.identifier)
|
||||
item_layout.enabled = is_bone_filter_mode_item_available(context, identifier)
|
||||
|
||||
if pg.bone_filter_mode == 'BONE_GROUPS':
|
||||
row = box.row()
|
||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||
row.template_list('PSX_UL_bone_group_list', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows)
|
||||
if pg.bone_filter_mode == 'BONE_COLLECTIONS':
|
||||
row = bones_panel.row()
|
||||
rows = max(3, min(len(pg.bone_collection_list), 10))
|
||||
row.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', rows=rows)
|
||||
|
||||
box.prop(pg, 'should_enforce_bone_name_restrictions')
|
||||
bones_panel.prop(pg, 'should_enforce_bone_name_restrictions')
|
||||
|
||||
# MATERIALS
|
||||
box = layout.box()
|
||||
box.label(text='Materials', icon='MATERIAL')
|
||||
row = box.row()
|
||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||
materials_header, materials_panel = layout.panel('materials_panel_id', default_closed=False)
|
||||
materials_header.label(text='Materials', icon='MATERIAL')
|
||||
if materials_panel is not None:
|
||||
row = materials_panel.row()
|
||||
rows = max(3, min(len(pg.bone_collection_list), 10))
|
||||
row.template_list('PSK_UL_materials', '', pg, 'material_list', pg, 'material_list_index', rows=rows)
|
||||
col = row.column(align=True)
|
||||
col.operator(PSK_OT_material_list_move_up.bl_idname, text='', icon='TRIA_UP')
|
||||
@@ -157,18 +162,25 @@ class PSK_OT_export(Operator, ExportHelper):
|
||||
pg = context.scene.psk_export
|
||||
options = PskBuildOptions()
|
||||
options.bone_filter_mode = pg.bone_filter_mode
|
||||
options.bone_group_indices = [x.index for x in pg.bone_group_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.material_names = [m.material_name for m in pg.material_list]
|
||||
options.materials = [m.material for m in pg.material_list]
|
||||
options.should_enforce_bone_name_restrictions = pg.should_enforce_bone_name_restrictions
|
||||
|
||||
try:
|
||||
psk = build_psk(context, options)
|
||||
write_psk(psk, self.filepath)
|
||||
result = build_psk(context, options)
|
||||
for warning in result.warnings:
|
||||
self.report({'WARNING'}, warning)
|
||||
write_psk(result.psk, self.filepath)
|
||||
if len(result.warnings) > 0:
|
||||
message = bpy.app.translations.pgettext_iface('PSK export successful with {warning_count} warnings')
|
||||
self.report({'WARNING'}, message.format(warning_count=len(result.warnings)))
|
||||
else:
|
||||
self.report({'INFO'}, f'PSK export successful')
|
||||
except RuntimeError as e:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
from bpy.props import EnumProperty, CollectionProperty, IntProperty, BoolProperty, StringProperty
|
||||
from bpy.types import PropertyGroup
|
||||
from bpy.props import EnumProperty, CollectionProperty, IntProperty, BoolProperty, PointerProperty
|
||||
from bpy.types import PropertyGroup, Material
|
||||
|
||||
from ...types import PSX_PG_bone_group_list_item
|
||||
from ...types import PSX_PG_bone_collection_list_item
|
||||
|
||||
empty_set = set()
|
||||
|
||||
class PSK_PG_material_list_item(PropertyGroup):
|
||||
material_name: StringProperty()
|
||||
material: PointerProperty(type=Material)
|
||||
index: IntProperty()
|
||||
|
||||
|
||||
class PSK_PG_export(PropertyGroup):
|
||||
bone_filter_mode: EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=set(),
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('ALL', 'All', 'All bones will be exported'),
|
||||
('BONE_GROUPS', 'Bone Groups',
|
||||
'Only bones belonging to the selected bone groups and their ancestors will be exported')
|
||||
('BONE_COLLECTIONS', 'Bone Collections',
|
||||
'Only bones belonging to the selected bone collections and their ancestors will be exported')
|
||||
)
|
||||
)
|
||||
bone_group_list: CollectionProperty(type=PSX_PG_bone_group_list_item)
|
||||
bone_group_list_index: IntProperty(default=0)
|
||||
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
|
||||
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')
|
||||
material_list: CollectionProperty(type=PSK_PG_material_list_item)
|
||||
material_list_index: IntProperty(default=0)
|
||||
|
||||
@@ -4,7 +4,7 @@ from bpy.types import UIList
|
||||
class PSK_UL_materials(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
row = layout.row()
|
||||
row.label(text=str(getattr(item, 'material_name')), icon='MATERIAL')
|
||||
row.prop(item.material, 'name', text='', emboss=False, icon_value=layout.icon(item.material))
|
||||
|
||||
|
||||
classes = (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy.app.translations
|
||||
from bpy.props import StringProperty, BoolProperty, EnumProperty, FloatProperty
|
||||
from bpy.types import Operator
|
||||
from bpy.types import Operator, FileHandler, Context
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
|
||||
from ..importer import PskImportOptions, import_psk
|
||||
@@ -11,6 +12,17 @@ from ..reader import read_psk
|
||||
empty_set = set()
|
||||
|
||||
|
||||
class PSK_FH_import(FileHandler):
|
||||
bl_idname = 'PSK_FH_import'
|
||||
bl_label = ''
|
||||
bl_import_operator = 'import_scene.psk'
|
||||
bl_file_extensions = '.psk;.pskx'
|
||||
|
||||
@classmethod
|
||||
def poll_drop(cls, context: Context):
|
||||
return context.area and context.area.type == 'VIEW_3D'
|
||||
|
||||
|
||||
class PSK_OT_import(Operator, ImportHelper):
|
||||
bl_idname = 'import_scene.psk'
|
||||
bl_label = 'Import'
|
||||
@@ -27,8 +39,8 @@ class PSK_OT_import(Operator, ImportHelper):
|
||||
should_import_vertex_colors: BoolProperty(
|
||||
default=True,
|
||||
options=empty_set,
|
||||
name='Vertex Colors',
|
||||
description='Import vertex colors from PSKX files, if available'
|
||||
name='Import Vertex Colors',
|
||||
description='Import vertex colors, if available'
|
||||
)
|
||||
vertex_color_space: EnumProperty(
|
||||
name='Vertex Color Space',
|
||||
@@ -42,38 +54,30 @@ class PSK_OT_import(Operator, ImportHelper):
|
||||
)
|
||||
should_import_vertex_normals: BoolProperty(
|
||||
default=True,
|
||||
name='Vertex Normals',
|
||||
name='Import Vertex Normals',
|
||||
options=empty_set,
|
||||
description='Import vertex normals, if available'
|
||||
)
|
||||
should_import_extra_uvs: BoolProperty(
|
||||
default=True,
|
||||
name='Extra UVs',
|
||||
name='Import Extra UVs',
|
||||
options=empty_set,
|
||||
description='Import extra UV maps, if available'
|
||||
description='Import extra UVs, if available'
|
||||
)
|
||||
should_import_mesh: BoolProperty(
|
||||
default=True,
|
||||
name='Import Mesh',
|
||||
options=empty_set,
|
||||
description='Import mesh'
|
||||
name='Mesh',
|
||||
options=empty_set
|
||||
)
|
||||
should_import_materials: BoolProperty(
|
||||
default=True,
|
||||
name='Import Materials',
|
||||
name='Materials',
|
||||
options=empty_set,
|
||||
)
|
||||
should_reuse_materials: BoolProperty(
|
||||
default=True,
|
||||
name='Reuse Materials',
|
||||
options=empty_set,
|
||||
description='Existing materials with matching names will be reused when available'
|
||||
)
|
||||
should_import_skeleton: BoolProperty(
|
||||
default=True,
|
||||
name='Import Skeleton',
|
||||
options=empty_set,
|
||||
description='Import skeleton'
|
||||
name='Armature',
|
||||
options=empty_set
|
||||
)
|
||||
bone_length: FloatProperty(
|
||||
default=1.0,
|
||||
@@ -82,14 +86,19 @@ class PSK_OT_import(Operator, ImportHelper):
|
||||
soft_min=1.0,
|
||||
name='Bone Length',
|
||||
options=empty_set,
|
||||
description='Length of the bones'
|
||||
subtype='DISTANCE'
|
||||
)
|
||||
should_import_shape_keys: BoolProperty(
|
||||
default=True,
|
||||
name='Shape Keys',
|
||||
name='Import Shape Keys',
|
||||
options=empty_set,
|
||||
description='Import shape keys, if available'
|
||||
)
|
||||
scale: FloatProperty(
|
||||
name='Scale',
|
||||
default=1.0,
|
||||
soft_min=0.0,
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
psk = read_psk(self.filepath)
|
||||
@@ -105,40 +114,64 @@ class PSK_OT_import(Operator, ImportHelper):
|
||||
options.bone_length = self.bone_length
|
||||
options.should_import_materials = self.should_import_materials
|
||||
options.should_import_shape_keys = self.should_import_shape_keys
|
||||
options.scale = self.scale
|
||||
|
||||
if not options.should_import_mesh and not options.should_import_skeleton:
|
||||
self.report({'ERROR'}, 'Nothing to import')
|
||||
return {'CANCELLED'}
|
||||
|
||||
result = import_psk(psk, context, options)
|
||||
|
||||
if len(result.warnings):
|
||||
message = f'PSK imported with {len(result.warnings)} warning(s)\n'
|
||||
message = bpy.app.translations.pgettext_iface('PSK imported with {count} warning(s)')
|
||||
message = message.format(count=len(result.warnings))
|
||||
message += '\n'.join(result.warnings)
|
||||
self.report({'WARNING'}, message)
|
||||
else:
|
||||
self.report({'INFO'}, f'PSK imported')
|
||||
message = bpy.app.translations.pgettext_iface('PSK imported ({name})')
|
||||
message = message.format(name=options.name)
|
||||
self.report({'INFO'}, message)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, 'should_import_materials')
|
||||
layout.prop(self, 'should_import_mesh')
|
||||
row = layout.column()
|
||||
row.use_property_split = True
|
||||
row.use_property_decorate = False
|
||||
if self.should_import_mesh:
|
||||
row.prop(self, 'should_import_vertex_normals')
|
||||
row.prop(self, 'should_import_extra_uvs')
|
||||
row.prop(self, 'should_import_vertex_colors')
|
||||
|
||||
row = layout.row()
|
||||
|
||||
col = row.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(self, 'scale')
|
||||
|
||||
mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False)
|
||||
mesh_header.prop(self, 'should_import_mesh')
|
||||
|
||||
if mesh_panel and self.should_import_mesh:
|
||||
row = mesh_panel.row()
|
||||
col = row.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(self, 'should_import_materials', text='Materials')
|
||||
col.prop(self, 'should_import_vertex_normals', text='Vertex Normals')
|
||||
col.prop(self, 'should_import_extra_uvs', text='Extra UVs')
|
||||
col.prop(self, 'should_import_vertex_colors', text='Vertex Colors')
|
||||
if self.should_import_vertex_colors:
|
||||
row.prop(self, 'vertex_color_space')
|
||||
row.prop(self, 'should_import_shape_keys')
|
||||
layout.prop(self, 'should_import_skeleton')
|
||||
row = layout.column()
|
||||
row.use_property_split = True
|
||||
row.use_property_decorate = False
|
||||
if self.should_import_skeleton:
|
||||
row.prop(self, 'bone_length')
|
||||
col.prop(self, 'vertex_color_space')
|
||||
col.prop(self, 'should_import_shape_keys', text='Shape Keys')
|
||||
|
||||
skeleton_header, skeleton_panel = layout.panel('skeleton_panel_id', default_closed=False)
|
||||
skeleton_header.prop(self, 'should_import_skeleton')
|
||||
|
||||
if skeleton_panel and self.should_import_skeleton:
|
||||
row = skeleton_panel.row()
|
||||
col = row.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(self, 'bone_length')
|
||||
|
||||
|
||||
classes = (
|
||||
PSK_OT_import,
|
||||
PSK_FH_import,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from math import inf
|
||||
from typing import Optional, List
|
||||
|
||||
import bmesh
|
||||
@@ -8,6 +7,7 @@ from bpy.types import VertexGroup
|
||||
from mathutils import Quaternion, Vector, Matrix
|
||||
|
||||
from .data import Psk
|
||||
from .properties import poly_flags_to_triangle_type_and_bit_flags
|
||||
from ..helpers import rgb_to_srgb, is_bdk_addon_loaded
|
||||
|
||||
|
||||
@@ -17,13 +17,14 @@ class PskImportOptions:
|
||||
self.should_import_mesh = True
|
||||
self.should_reuse_materials = True
|
||||
self.should_import_vertex_colors = True
|
||||
self.vertex_color_space = 'sRGB'
|
||||
self.vertex_color_space = 'SRGB'
|
||||
self.should_import_vertex_normals = True
|
||||
self.should_import_extra_uvs = True
|
||||
self.should_import_skeleton = True
|
||||
self.should_import_shape_keys = True
|
||||
self.bone_length = 1.0
|
||||
self.should_import_materials = True
|
||||
self.scale = 1.0
|
||||
|
||||
|
||||
class ImportBone:
|
||||
@@ -39,9 +40,9 @@ class ImportBone:
|
||||
self.world_rotation_matrix: Matrix = Matrix()
|
||||
self.world_matrix: Matrix = Matrix()
|
||||
self.vertex_group = None
|
||||
self.orig_quat: Quaternion = Quaternion()
|
||||
self.orig_loc: Vector = Vector()
|
||||
self.post_quat: Quaternion = Quaternion()
|
||||
self.original_rotation: Quaternion = Quaternion()
|
||||
self.original_location: Vector = Vector()
|
||||
self.post_rotation: Quaternion = Quaternion()
|
||||
|
||||
|
||||
class PskImportResult:
|
||||
@@ -52,6 +53,7 @@ class PskImportResult:
|
||||
def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
result = PskImportResult()
|
||||
armature_object = None
|
||||
mesh_object = None
|
||||
|
||||
if options.should_import_skeleton:
|
||||
# ARMATURE
|
||||
@@ -111,12 +113,6 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
edit_bone_matrix.translation = import_bone.world_matrix.translation
|
||||
edit_bone.matrix = edit_bone_matrix
|
||||
|
||||
# Store bind pose information in the bone's custom properties.
|
||||
# This information is used when importing animations from PSA files.
|
||||
edit_bone['orig_quat'] = import_bone.local_rotation
|
||||
edit_bone['orig_loc'] = import_bone.local_translation
|
||||
edit_bone['post_quat'] = import_bone.local_rotation.conjugated()
|
||||
|
||||
# MESH
|
||||
if options.should_import_mesh:
|
||||
mesh_data = bpy.data.meshes.new(options.name)
|
||||
@@ -131,7 +127,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
# Material already exists, just re-use it.
|
||||
material = bpy.data.materials[material_name]
|
||||
elif is_bdk_addon_loaded() and psk.has_material_references:
|
||||
# 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.
|
||||
material_reference = psk.material_references[material_index]
|
||||
if material_reference and bpy.ops.bdk.link_material(reference=material_reference) == {'FINISHED'}:
|
||||
@@ -139,6 +135,9 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
else:
|
||||
# Just create a blank material.
|
||||
material = bpy.data.materials.new(material_name)
|
||||
mesh_triangle_type, mesh_triangle_bit_flags = poly_flags_to_triangle_type_and_bit_flags(psk_material.poly_flags)
|
||||
material.psk.mesh_triangle_type = mesh_triangle_type
|
||||
material.psk.mesh_triangle_bit_flags = mesh_triangle_bit_flags
|
||||
material.use_nodes = True
|
||||
mesh_data.materials.append(material)
|
||||
|
||||
@@ -150,6 +149,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
|
||||
bm.verts.ensure_lookup_table()
|
||||
|
||||
# FACES
|
||||
invalid_face_indices = set()
|
||||
for face_index, face in enumerate(psk.faces):
|
||||
point_indices = map(lambda i: psk.wedges[i].point_index, reversed(face.wedge_indices))
|
||||
@@ -165,75 +165,75 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
|
||||
# TODO: Handle invalid faces better.
|
||||
if len(invalid_face_indices) > 0:
|
||||
result.warnings.append(f'Discarded {len(invalid_face_indices)} invalid face(s).')
|
||||
message = bpy.app.translations.pgettext_iface('Discarded {count} invalid face(s)')
|
||||
result.warnings.append(message.format(count=len(invalid_face_indices)))
|
||||
|
||||
bm.to_mesh(mesh_data)
|
||||
|
||||
# TEXTURE COORDINATES
|
||||
data_index = 0
|
||||
uv_layer_data_index = 0
|
||||
uv_layer = mesh_data.uv_layers.new(name='VTXW0000')
|
||||
for face_index, face in enumerate(psk.faces):
|
||||
if face_index in invalid_face_indices:
|
||||
continue
|
||||
face_wedges = [psk.wedges[i] for i in reversed(face.wedge_indices)]
|
||||
for wedge in face_wedges:
|
||||
uv_layer.data[data_index].uv = wedge.u, 1.0 - wedge.v
|
||||
data_index += 1
|
||||
uv_layer.data[uv_layer_data_index].uv = wedge.u, 1.0 - wedge.v
|
||||
uv_layer_data_index += 1
|
||||
|
||||
# EXTRA UVS
|
||||
if psk.has_extra_uvs and options.should_import_extra_uvs:
|
||||
extra_uv_channel_count = int(len(psk.extra_uvs) / len(psk.wedges))
|
||||
wedge_index_offset = 0
|
||||
for extra_uv_index in range(extra_uv_channel_count):
|
||||
data_index = 0
|
||||
uv_layer_data_index = 0
|
||||
uv_layer = mesh_data.uv_layers.new(name=f'EXTRAUV{extra_uv_index}')
|
||||
for face_index, face in enumerate(psk.faces):
|
||||
if face_index in invalid_face_indices:
|
||||
continue
|
||||
for wedge_index in reversed(face.wedge_indices):
|
||||
u, v = psk.extra_uvs[wedge_index_offset + wedge_index]
|
||||
uv_layer.data[data_index].uv = u, 1.0 - v
|
||||
data_index += 1
|
||||
uv_layer.data[uv_layer_data_index].uv = u, 1.0 - v
|
||||
uv_layer_data_index += 1
|
||||
wedge_index_offset += len(psk.wedges)
|
||||
|
||||
# VERTEX COLORS
|
||||
if psk.has_vertex_colors and options.should_import_vertex_colors:
|
||||
size = (len(psk.points), 4)
|
||||
vertex_colors = np.full(size, inf)
|
||||
vertex_color_data = mesh_data.vertex_colors.new(name='VERTEXCOLOR')
|
||||
ambiguous_vertex_color_point_indices = []
|
||||
# Convert vertex colors to sRGB if necessary.
|
||||
psk_vertex_colors = np.zeros((len(psk.vertex_colors), 4))
|
||||
for vertex_color_index in range(len(psk.vertex_colors)):
|
||||
psk_vertex_colors[vertex_color_index,:] = psk.vertex_colors[vertex_color_index].normalized()
|
||||
match options.vertex_color_space:
|
||||
case 'SRGBA':
|
||||
for i in range(psk_vertex_colors.shape[0]):
|
||||
psk_vertex_colors[i, :3] = tuple(map(lambda x: rgb_to_srgb(x), psk_vertex_colors[i, :3]))
|
||||
case _:
|
||||
pass
|
||||
|
||||
for wedge_index, wedge in enumerate(psk.wedges):
|
||||
point_index = wedge.point_index
|
||||
psk_vertex_color = psk.vertex_colors[wedge_index].normalized()
|
||||
if vertex_colors[point_index, 0] != inf and tuple(vertex_colors[point_index]) != psk_vertex_color:
|
||||
ambiguous_vertex_color_point_indices.append(point_index)
|
||||
else:
|
||||
vertex_colors[point_index] = psk_vertex_color
|
||||
# Map the PSK vertex colors to the face corners.
|
||||
face_count = len(psk.faces) - len(invalid_face_indices)
|
||||
face_corner_colors = np.full((face_count * 3, 4), 1.0)
|
||||
face_corner_color_index = 0
|
||||
for face_index, face in enumerate(psk.faces):
|
||||
if face_index in invalid_face_indices:
|
||||
continue
|
||||
for wedge_index in reversed(face.wedge_indices):
|
||||
face_corner_colors[face_corner_color_index] = psk_vertex_colors[wedge_index]
|
||||
face_corner_color_index += 1
|
||||
|
||||
if options.vertex_color_space == 'SRGBA':
|
||||
for i in range(vertex_colors.shape[0]):
|
||||
vertex_colors[i, :3] = tuple(map(lambda x: rgb_to_srgb(x), vertex_colors[i, :3]))
|
||||
|
||||
for loop_index, loop in enumerate(mesh_data.loops):
|
||||
vertex_color = vertex_colors[loop.vertex_index]
|
||||
if vertex_color is not None:
|
||||
vertex_color_data.data[loop_index].color = vertex_color
|
||||
else:
|
||||
vertex_color_data.data[loop_index].color = 1.0, 1.0, 1.0, 1.0
|
||||
|
||||
if len(ambiguous_vertex_color_point_indices) > 0:
|
||||
result.warnings.append(
|
||||
f'{len(ambiguous_vertex_color_point_indices)} vertex(es) with ambiguous vertex colors.')
|
||||
# Create the vertex color attribute.
|
||||
face_corner_color_attribute = mesh_data.attributes.new(name='VERTEXCOLOR', type='FLOAT_COLOR', domain='CORNER')
|
||||
face_corner_color_attribute.data.foreach_set('color', face_corner_colors.flatten())
|
||||
|
||||
# VERTEX NORMALS
|
||||
if psk.has_vertex_normals and options.should_import_vertex_normals:
|
||||
mesh_data.polygons.foreach_set("use_smooth", [True] * len(mesh_data.polygons))
|
||||
mesh_data.polygons.foreach_set('use_smooth', [True] * len(mesh_data.polygons))
|
||||
normals = []
|
||||
for vertex_normal in psk.vertex_normals:
|
||||
normals.append(tuple(vertex_normal))
|
||||
mesh_data.normals_split_custom_set_from_vertices(normals)
|
||||
mesh_data.use_auto_smooth = True
|
||||
else:
|
||||
mesh_data.shade_smooth()
|
||||
|
||||
bm.normal_update()
|
||||
bm.free()
|
||||
@@ -271,6 +271,9 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
armature_modifier.object = armature_object
|
||||
mesh_object.parent = armature_object
|
||||
|
||||
root_object = armature_object if options.should_import_skeleton else mesh_object
|
||||
root_object.scale = (options.scale, options.scale, options.scale)
|
||||
|
||||
try:
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
except:
|
||||
|
||||
48
io_scene_psk_psa/psk/properties.py
Normal file
48
io_scene_psk_psa/psk/properties.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from bpy.props import EnumProperty
|
||||
from bpy.types import PropertyGroup
|
||||
|
||||
mesh_triangle_types_items = (
|
||||
('NORMAL', 'Normal', '', 0),
|
||||
('NORMAL_TWO_SIDED', 'Normal Two-Sided', '', 1),
|
||||
('TRANSLUCENT', 'Translucent', '', 2),
|
||||
('MASKED', 'Masked', '', 3),
|
||||
('MODULATE', 'Modulate', '', 4),
|
||||
('PLACEHOLDER', 'Placeholder', '', 8),
|
||||
)
|
||||
|
||||
mesh_triangle_bit_flags_items = (
|
||||
('UNLIT', 'Unlit', '', 16),
|
||||
('FLAT', 'Flat', '', 32),
|
||||
('ENVIRONMENT', 'Environment', '', 64),
|
||||
('NO_SMOOTH', 'No Smooth', '', 128),
|
||||
)
|
||||
|
||||
class PSX_PG_material(PropertyGroup):
|
||||
mesh_triangle_type: EnumProperty(items=mesh_triangle_types_items, name='Triangle Type')
|
||||
mesh_triangle_bit_flags: EnumProperty(items=mesh_triangle_bit_flags_items, name='Triangle Bit Flags',
|
||||
options={'ENUM_FLAG'})
|
||||
|
||||
mesh_triangle_types_items_dict = {item[0]: item[3] for item in mesh_triangle_types_items}
|
||||
mesh_triangle_bit_flags_items_dict = {item[0]: item[3] for item in mesh_triangle_bit_flags_items}
|
||||
|
||||
|
||||
def triangle_type_and_bit_flags_to_poly_flags(mesh_triangle_type: str, mesh_triangle_bit_flags: set[str]) -> int:
|
||||
poly_flags = 0
|
||||
poly_flags |= mesh_triangle_types_items_dict.get(mesh_triangle_type, 0)
|
||||
for flag in mesh_triangle_bit_flags:
|
||||
poly_flags |= mesh_triangle_bit_flags_items_dict.get(flag, 0)
|
||||
return poly_flags
|
||||
|
||||
|
||||
def poly_flags_to_triangle_type_and_bit_flags(poly_flags: int) -> (str, set[str]):
|
||||
try:
|
||||
triangle_type = next(item[0] for item in mesh_triangle_types_items if item[3] == (poly_flags & 15))
|
||||
except StopIteration:
|
||||
triangle_type = 'NORMAL'
|
||||
triangle_bit_flags = {item[0] for item in mesh_triangle_bit_flags_items if item[3] & poly_flags}
|
||||
return triangle_type, triangle_bit_flags
|
||||
|
||||
|
||||
classes = (
|
||||
PSX_PG_material,
|
||||
)
|
||||
@@ -1,9 +1,10 @@
|
||||
import ctypes
|
||||
import os
|
||||
import re
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import bpy.app.translations
|
||||
|
||||
from .data import *
|
||||
|
||||
|
||||
@@ -23,7 +24,7 @@ def _read_material_references(path: str) -> List[str]:
|
||||
return []
|
||||
# Do a crude regex match to find the Material list entries.
|
||||
contents = property_file_path.read_text()
|
||||
pattern = r"Material\s*=\s*([^\s^,]+)"
|
||||
pattern = r'Material\s*=\s*([^\s^,]+)'
|
||||
return re.findall(pattern, contents)
|
||||
|
||||
|
||||
@@ -69,8 +70,9 @@ def read_psk(path: str) -> Psk:
|
||||
_read_types(fp, Psk.MorphData, section, psk.morph_data)
|
||||
else:
|
||||
# Section is not handled, skip it.
|
||||
fp.seek(section.data_size * section.data_count, os.SEEK_CUR)
|
||||
warnings.warn(f'Unrecognized section "{section.name} at position {fp.tell():15}"')
|
||||
message: str = bpy.app.translations.pgettext_iface('Unhandled section "{section_name}" at position {position}')
|
||||
message.format(section_name=section.name, position=f'{fp.tell():15}')
|
||||
warnings.warn(message)
|
||||
|
||||
'''
|
||||
UEViewer exports a sidecar file (*.props.txt) with fully-qualified reference paths for each material
|
||||
@@ -78,14 +80,14 @@ def read_psk(path: str) -> Psk:
|
||||
'''
|
||||
psk.material_references = _read_material_references(path)
|
||||
|
||||
'''
|
||||
"""
|
||||
Tools like UEViewer and CUE4Parse write the point index as a 32-bit integer, exploiting the fact that due to struct
|
||||
alignment, there were 16-bits of padding following the original 16-bit point index in the wedge struct.
|
||||
However, this breaks compatibility with PSK files that were created with older tools that treated the
|
||||
point index as a 16-bit integer and might have junk data written to the padding bits.
|
||||
To work around this, we check if each point is still addressable using a 16-bit index, and if it is, assume the
|
||||
point index is a 16-bit integer and truncate the high bits.
|
||||
'''
|
||||
"""
|
||||
if len(psk.points) <= 65536:
|
||||
for wedge in psk.wedges:
|
||||
wedge.point_index &= 0xFFFF
|
||||
|
||||
28
io_scene_psk_psa/psk/ui.py
Normal file
28
io_scene_psk_psa/psk/ui.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from bpy.types import Panel
|
||||
|
||||
|
||||
class PSK_PT_material(Panel):
|
||||
bl_label = 'PSK Material'
|
||||
bl_idname = 'PSK_PT_material'
|
||||
bl_space_type = 'PROPERTIES'
|
||||
bl_region_type = 'WINDOW'
|
||||
bl_context = 'material'
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return context.material is not None
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.use_property_split = True
|
||||
layout.use_property_decorate = False
|
||||
material = context.material
|
||||
layout.prop(material.psk, 'mesh_triangle_type')
|
||||
col = layout.column()
|
||||
col.prop(material.psk, 'mesh_triangle_bit_flags', expand=True, text='Flags')
|
||||
|
||||
|
||||
classes = (
|
||||
PSK_PT_material,
|
||||
)
|
||||
@@ -1,12 +1,14 @@
|
||||
from ctypes import Structure, sizeof
|
||||
from typing import Type
|
||||
|
||||
import bpy.app.translations
|
||||
|
||||
from .data import Psk
|
||||
from ..data import Section, Vector3
|
||||
|
||||
MAX_WEDGE_COUNT = 65536
|
||||
MAX_POINT_COUNT = 4294967296
|
||||
MAX_BONE_COUNT = 256
|
||||
MAX_BONE_COUNT = 2147483647
|
||||
MAX_MATERIAL_COUNT = 256
|
||||
|
||||
|
||||
@@ -24,15 +26,20 @@ def _write_section(fp, name: bytes, data_type: Type[Structure] = None, data: lis
|
||||
|
||||
def write_psk(psk: Psk, path: str):
|
||||
if len(psk.wedges) > MAX_WEDGE_COUNT:
|
||||
raise RuntimeError(f'Number of wedges ({len(psk.wedges)}) exceeds limit of {MAX_WEDGE_COUNT}')
|
||||
message = bpy.app.translations.pgettext_iface('Number of wedges ({wedge_count}) exceeds limit of {MAX_WEDGE_COUNT}')
|
||||
raise RuntimeError(message.format(wedge_count=len(psk.wedges), MAX_WEDGE_COUNT=MAX_WEDGE_COUNT))
|
||||
if len(psk.points) > MAX_POINT_COUNT:
|
||||
raise RuntimeError(f'Numbers of vertices ({len(psk.points)}) exceeds limit of {MAX_POINT_COUNT}')
|
||||
message = bpy.app.translations.pgettext_iface('Numbers of vertices ({point_count}) exceeds limit of {MAX_POINT_COUNT}')
|
||||
raise RuntimeError(message.format(point_count=len(psk.points), MAX_POINT_COUNT=MAX_POINT_COUNT))
|
||||
if len(psk.materials) > MAX_MATERIAL_COUNT:
|
||||
raise RuntimeError(f'Number of materials ({len(psk.materials)}) exceeds limit of {MAX_MATERIAL_COUNT}')
|
||||
message = bpy.app.translations.pgettext_iface('Number of materials ({material_count}) exceeds limit of {MAX_MATERIAL_COUNT}')
|
||||
raise RuntimeError(message.format(material_count=len(psk.materials), MAX_MATERIAL_COUNT=MAX_MATERIAL_COUNT))
|
||||
if len(psk.bones) > MAX_BONE_COUNT:
|
||||
raise RuntimeError(f'Number of bones ({len(psk.bones)}) exceeds limit of {MAX_BONE_COUNT}')
|
||||
message = bpy.app.translations.pgettext_iface('Number of bones ({bone_count}) exceeds limit of {MAX_BONE_COUNT}')
|
||||
raise RuntimeError(message.format(bone_count=len(psk.bones), MAX_BONE_COUNT=MAX_BONE_COUNT))
|
||||
elif len(psk.bones) == 0:
|
||||
raise RuntimeError(f'At least one bone must be marked for export')
|
||||
message = bpy.app.translations.pgettext_iface('At least one bone must be marked for export')
|
||||
raise RuntimeError(message)
|
||||
|
||||
with open(path, 'wb') as fp:
|
||||
_write_section(fp, b'ACTRHEAD')
|
||||
|
||||
@@ -2,16 +2,16 @@ from bpy.props import StringProperty, IntProperty, BoolProperty, FloatProperty
|
||||
from bpy.types import PropertyGroup, UIList, UILayout, Context, AnyType, Panel
|
||||
|
||||
|
||||
class PSX_UL_bone_group_list(UIList):
|
||||
class PSX_UL_bone_collection_list(UIList):
|
||||
|
||||
def draw_item(self, context: Context, layout: UILayout, data: AnyType, item: AnyType, icon: int,
|
||||
active_data: AnyType, active_property: str, index: int = 0, flt_flag: int = 0):
|
||||
row = layout.row()
|
||||
row.prop(item, 'is_selected', text=getattr(item, 'name'))
|
||||
row.prop(item, 'is_selected', text=item.name, translate=False)
|
||||
row.label(text=str(getattr(item, 'count')), icon='BONE_DATA')
|
||||
|
||||
|
||||
class PSX_PG_bone_group_list_item(PropertyGroup):
|
||||
class PSX_PG_bone_collection_list_item(PropertyGroup):
|
||||
name: StringProperty()
|
||||
index: IntProperty()
|
||||
count: IntProperty()
|
||||
@@ -19,8 +19,9 @@ class PSX_PG_bone_group_list_item(PropertyGroup):
|
||||
|
||||
|
||||
class PSX_PG_action_export(PropertyGroup):
|
||||
compression_ratio: FloatProperty(name='Compression Ratio', default=1.0, min=0.0, max=1.0, subtype='FACTOR', description='The key sampling ratio of the exported sequence.\n\nA compression ratio of 1.0 will export all frames, while a compression ratio of 0.5 will export half of the frames')
|
||||
key_quota: IntProperty(name='Key Quota', default=0, min=1, description='The minimum number of frames to be exported')
|
||||
compression_ratio: FloatProperty(name='Compression Ratio', default=1.0, min=0.0, max=1.0, subtype='FACTOR', description='The keyframe sampling ratio of the exported sequence.\n\nA compression ratio of 1.0 will export all frames, while a compression ratio of 0.5 will export half of the frames')
|
||||
key_quota: IntProperty(name='Keyframe Quota', default=0, min=1, description='The minimum number of keyframes to be exported')
|
||||
fps: FloatProperty(name='FPS', default=30.0, min=0.0, description='The frame rate of the exported sequence')
|
||||
|
||||
|
||||
class PSX_PT_action(Panel):
|
||||
@@ -38,13 +39,17 @@ class PSX_PT_action(Panel):
|
||||
def draw(self, context: 'Context'):
|
||||
action = context.active_action
|
||||
layout = self.layout
|
||||
layout.prop(action.psa_export, 'compression_ratio')
|
||||
layout.prop(action.psa_export, 'key_quota')
|
||||
flow = layout.grid_flow(columns=1)
|
||||
flow.use_property_split = True
|
||||
flow.use_property_decorate = False
|
||||
flow.prop(action.psa_export, 'compression_ratio')
|
||||
flow.prop(action.psa_export, 'key_quota')
|
||||
flow.prop(action.psa_export, 'fps')
|
||||
|
||||
|
||||
classes = (
|
||||
PSX_PG_action_export,
|
||||
PSX_PG_bone_group_list_item,
|
||||
PSX_UL_bone_group_list,
|
||||
PSX_PT_action
|
||||
PSX_PG_bone_collection_list_item,
|
||||
PSX_UL_bone_collection_list,
|
||||
PSX_PT_action,
|
||||
)
|
||||
|
||||
1
localization/.gitignore
vendored
Normal file
1
localization/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
artifacts
|
||||
132
localization/stringscan.py
Normal file
132
localization/stringscan.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import ast
|
||||
import glob
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import polib
|
||||
|
||||
# Walk the directory and open all .py files using glob
|
||||
strings = dict()
|
||||
root_path = pathlib.Path('../io_scene_psk_psa').resolve()
|
||||
for file in glob.glob('../io_scene_psk_psa/**/*.py', recursive=True):
|
||||
with open(os.path.join(file), 'r') as f:
|
||||
if file.endswith('i18n.py'):
|
||||
# TODO: Don't parse the i18n files.
|
||||
continue
|
||||
# Walk the entire tree and build a list of all string literals.
|
||||
try:
|
||||
a = ast.parse(f.read())
|
||||
for node in ast.walk(a):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
a = pathlib.Path(file).resolve()
|
||||
filepath = a.relative_to(root_path)
|
||||
if node.s not in strings:
|
||||
strings[node.s] = filepath, node.lineno, node.col_offset
|
||||
except UnicodeDecodeError as e:
|
||||
print(f'Error reading file {file}: {e}')
|
||||
|
||||
string_keys = set(strings.keys())
|
||||
|
||||
# Remove all keys from the dictionary that are empty or contain only whitespace.
|
||||
string_keys = set(filter(lambda x: x.strip(), string_keys))
|
||||
|
||||
# Remove all strings that have no alphabetic characters.
|
||||
string_keys = set(filter(lambda x: any(c.isalpha() for c in x), string_keys))
|
||||
|
||||
# Remove any strings that have '@return: ' in them.
|
||||
string_keys = set(filter(lambda x: '@return: ' not in x, string_keys))
|
||||
|
||||
# Remove any strings that are entirely lowercase and have no whitespace.
|
||||
string_keys = set(filter(lambda x: not x.islower() or ' ' in x, string_keys))
|
||||
|
||||
# Remove any strings that are in SCREAMING_SNAKE_CASE.
|
||||
string_keys = set(filter(lambda x: not x.isupper(), string_keys))
|
||||
|
||||
# Remove any strings that have underscores and no spaces.
|
||||
string_keys = set(filter(lambda x: '_' not in x or ' ' in x, string_keys))
|
||||
|
||||
# Remove any string that starts with a newline.
|
||||
string_keys = set(filter(lambda x: not x.startswith('\n'), string_keys))
|
||||
|
||||
# Remove any string that looks like a regular expression.
|
||||
string_keys = set(filter(lambda x: not any(c in x for c in '^'), string_keys))
|
||||
|
||||
def write_multiline_string(f, string):
|
||||
f.write(f'msgid ""\n')
|
||||
for line in string.split('\n'):
|
||||
f.write(f'"{line}"\n')
|
||||
f.write('msgstr ""\n\n')
|
||||
|
||||
# TODO: big brain move would be to load the translated Blender strings and remove any that are already translated
|
||||
# instead of manually removing them.
|
||||
exclude_strings = {
|
||||
'Import-Export',
|
||||
'Linear',
|
||||
'Masked',
|
||||
'Normal',
|
||||
'Placeholder',
|
||||
'Flat',
|
||||
'Environment',
|
||||
'Advanced',
|
||||
'Action',
|
||||
'All',
|
||||
'Assets',
|
||||
'Armature',
|
||||
'Materials'
|
||||
'Bones',
|
||||
'Custom',
|
||||
'Data',
|
||||
'Colin Basnett, Yurii Ti',
|
||||
'Invert',
|
||||
'Keyframes', # maybe?
|
||||
'Mesh',
|
||||
'None',
|
||||
'Options',
|
||||
'Overwrite',
|
||||
'Scale',
|
||||
'Scene',
|
||||
'Select',
|
||||
'RemoveTracks'
|
||||
'Source',
|
||||
'Move Up',
|
||||
'Move Down',
|
||||
'Unassigned',
|
||||
'Prefix',
|
||||
'Suffix',
|
||||
'Timeline Markers',
|
||||
'Pose Markers',
|
||||
'Actions',
|
||||
'sRGBA',
|
||||
}
|
||||
|
||||
# Remove any strings that are in the exclude_strings set.
|
||||
string_keys = set(filter(lambda x: x not in exclude_strings, string_keys))
|
||||
|
||||
# Make a new PO file and write the strings to it.
|
||||
pofile = polib.POFile()
|
||||
|
||||
pofile.header = '''msgid ""
|
||||
msgstr ""
|
||||
"Language: en\\n"
|
||||
"MIME-Version: 1.0\\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\\n"
|
||||
"Content-Transfer-Encoding: 8bit\\n"
|
||||
'''
|
||||
|
||||
# Sort the string keys into a list.
|
||||
string_keys = list(string_keys)
|
||||
string_keys.sort()
|
||||
|
||||
for string_key in string_keys:
|
||||
file, line, col = strings[string_key]
|
||||
entry = polib.POEntry(
|
||||
msgid=string_key,
|
||||
msgstr=string_key,
|
||||
comment=f'{file}:{line}',
|
||||
)
|
||||
pofile.append(entry)
|
||||
|
||||
pofile.save('../extern/io_scene_psk_psa-translations/io_scene_psk_psa.en.po')
|
||||
|
||||
# Print the # of strings.
|
||||
print(f'Found {len(string_keys)} strings.')
|
||||
27
localization/update_i18n.py
Normal file
27
localization/update_i18n.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import os.path
|
||||
import pprint
|
||||
import re
|
||||
from glob import glob
|
||||
|
||||
import polib
|
||||
|
||||
langs = {}
|
||||
|
||||
for file_path in glob('../extern/io_scene_psk_psa-translations/io_scene_psk_psa.*.po'):
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
po = polib.pofile(content)
|
||||
|
||||
# Get the language code from the file name.
|
||||
lang_code = re.match(r'io_scene_psk_psa.(\w*)\.po', os.path.basename(file_path)).group(1)
|
||||
|
||||
if lang_code == 'en':
|
||||
continue
|
||||
|
||||
langs[lang_code] = {('*', entry.msgid): entry.msgstr for entry in po if entry.msgid != ''}
|
||||
|
||||
with open('../io_scene_psk_psa/i18n.py', 'w', encoding='utf-8') as f:
|
||||
s = pprint.pformat(langs)
|
||||
f.write(f'langs = {s}')
|
||||
print(f'Language_codes = {list(langs.keys())}')
|
||||
print('Wrote i18n.py')
|
||||
Reference in New Issue
Block a user