Compare commits

..

35 Commits

Author SHA1 Message Date
Colin Basnett
2b41815545 Fixed issue where parented bone collections would not be selectable in the bone collection list 2025-01-13 12:05:38 -08:00
Colin Basnett
a83314c8b3 Fixed bug when bone collections would not be visible on the collection exporter 2025-01-13 12:03:26 -08:00
Colin Basnett
f8234b3892 Added cross-version compatibility for is_action_for_armature function 2025-01-13 12:02:13 -08:00
Colin Basnett
35ac0bf86c Added sampling mode and changed the default sampling mode to be "Interpolated" rather than "Subframe" 2025-01-05 18:22:03 -08:00
Colin Basnett
515ee17203 Added the ability to define a custom compression ratio for PSA export 2025-01-05 15:43:11 -08:00
Colin Basnett
0a5ebf4548 Fixed dependency issue in automation script 2024-12-31 00:23:03 -08:00
Colin Basnett
de1cf2316a Incremented version to 8.0.0 2024-12-31 00:21:08 -08:00
Colin Basnett
c1d5a2229d Unified handling of translating bpy PSK options to the options passed into the build function
Also fixed a bug where the normal export operator was exporting duplicate objects
2024-12-27 14:59:22 -08:00
Colin Basnett
ff5ded004a Fix for error if any selected armatures had no actions or animation data when exporting PSAs 2024-12-10 02:52:25 -08:00
Colin Basnett
ea2ecc6a5a Removed debugging line 2024-12-09 00:06:35 -08:00
Colin Basnett
ef559d9475 Added an export list to the PSA export dialog for active actions 2024-12-08 14:43:37 -08:00
Colin Basnett
491e042cec Added a number of new features:
* Added "Active Action" as the sequence type, allowing the user to
  select multiple armatures with the same data block and export an
  action for each one.
* Added a "Scale" option for PSA export when exporting animations
  for an armature that had been scaled on export.
* You are now able to export meshes without materials.
2024-12-08 03:01:23 -08:00
Colin Basnett
9b0df1c942 Fixed Blender 4.3 compatiblity error 2024-12-04 18:50:49 -08:00
Colin Basnett
79ea131f64 Removed should_enforce_bone_name_restrictions option as it was too fiddly and not all that useful 2024-11-29 20:37:24 -08:00
Colin Basnett
77dc4e5d50 Added the ability to filter bone collections from PSK collection exporter 2024-11-29 19:26:23 -08:00
Colin Basnett
526df424e3 Added "Export Space" option to allow exporting in armature space
Armature space exporting means that the user doesn't need to manually
move the armature back to origin before exporting, since it was simply
using world-space exporting before.

Also moved all DFS functions into their own file & cleaned up the
usage interface. Added `is_visible` and `is_selected` properties
for better ergonomics.
2024-11-29 15:42:42 -08:00
Colin Basnett
ed42b2e227 Added handling for exporting instance collections 2024-11-27 00:57:55 -08:00
Colin Basnett
42a859e24b Added scale factor to PSK export
Also a load of cleanup I don't have time to catalog
2024-11-26 04:52:28 -08:00
Colin Basnett
e791859217 Fix #114: Fixed typing error that caused multiple PSA import to fail 2024-11-25 18:17:49 -08:00
Colin Basnett
0dba7bb262 Incremented version to 7.1.3 2024-11-03 17:56:43 -08:00
Colin Basnett
77cc97107e Fix #113: is_bdk_addon_loaded now actually works correctly
This was causing an error for some people because of the bizarre
behavior around `getattr` and `setattr` in `bpy.ops` always reporting
that the BDK was installed.
2024-11-03 17:56:13 -08:00
Colin Basnett
1f2ec4c76b Fixed a bug where 0 frames could be output for a sequence if the compression ratio was less than 1.0 2024-10-01 20:35:12 -07:00
Colin Basnett
ff74f47178 Implemented multiple PSA import (#55)
This can be invoked by drag-and-dropping multiple PSA files onto the
Blender viewport when you have the target armature selected
2024-09-09 17:07:36 -07:00
Colin Basnett
bdd35ef61d Incremented version to v7.1.2 2024-09-09 16:30:01 -07:00
Colin Basnett
1c4967bd67 Fixed is_bdk_addon_loaded function 2024-09-09 16:29:20 -07:00
Colin Basnett
b5dba35ac4 Implemented feature requested in #87 2024-09-09 16:25:29 -07:00
Colin Basnett
7cc5cbe667 Added Visible Only option to the PSK collection exporter 2024-09-09 15:59:13 -07:00
Colin Basnett
e1f0fc7e89 Fix #101: Dashes in the names of PSA config keys results in parsing errors
The issue here was the regex pattern was too restrictive, so it did not
pick up the lines as ones that needed to have the `=` appended at the
end so that the ConfigParser could properly parse the file.
2024-08-07 23:36:48 -07:00
Colin Basnett
03c69783b3 Updated workflow file to be targetted against the stable version of 4.2 2024-07-31 19:16:00 -07:00
Colin Basnett
da4960298b Incremented version to 7.1.1 2024-07-31 19:09:49 -07:00
Colin Basnett
a9706d88a5 Vertices without explicit weights are now weighted to the root bone
There is an issue in some older versions of Unreal (e.g. Postal), where
the engine does not handle vertices without explicit weighting,
resulting in corrupted meshes. This now mitigates the issue.

Thank you to makabray for reporting this issue.
2024-07-31 19:09:22 -07:00
Colin Basnett
9dd02260d5 Replaced __doc__ with bl_description 2024-07-31 19:01:48 -07:00
Colin Basnett
7ceaa88f1d Incremented version to 7.0.1 2024-03-31 14:23:35 -07:00
Colin Basnett
37e246bf3e Fixed ordering of panels in the PSA import dialog 2024-03-31 14:22:16 -07:00
Colin Basnett
db93314fbc Initial commit for multiple PSA import 2024-03-31 12:47:48 -07:00
22 changed files with 1310 additions and 435 deletions

View File

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

View File

@@ -6,6 +6,8 @@ if 'bpy' in locals():
importlib.reload(shared_data) importlib.reload(shared_data)
importlib.reload(shared_helpers) importlib.reload(shared_helpers)
importlib.reload(shared_types) importlib.reload(shared_types)
importlib.reload(shared_dfs)
importlib.reload(shared_ui)
importlib.reload(psk_data) importlib.reload(psk_data)
importlib.reload(psk_reader) importlib.reload(psk_reader)
@@ -33,6 +35,7 @@ if 'bpy' in locals():
importlib.reload(psa_import_ui) importlib.reload(psa_import_ui)
else: else:
from .shared import data as shared_data, types as shared_types, helpers as shared_helpers from .shared import data as shared_data, types as shared_types, helpers as shared_helpers
from .shared import dfs as shared_dfs, ui as shared_ui
from .psk import data as psk_data, builder as psk_builder, writer as psk_writer, \ from .psk import data as psk_data, builder as psk_builder, writer as psk_writer, \
importer as psk_importer, properties as psk_properties importer as psk_importer, properties as psk_properties
from .psk import reader as psk_reader, ui as psk_ui from .psk import reader as psk_reader, ui as psk_ui

View File

@@ -1,6 +1,6 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "io_scene_psk_psa" id = "io_scene_psk_psa"
version = "7.1.0" version = "8.0.0"
name = "Unreal PSK/PSA (.psk/.psa)" name = "Unreal PSK/PSA (.psk/.psa)"
tagline = "Import and export PSK and PSA files used in Unreal Engine" tagline = "Import and export PSK and PSA files used in Unreal Engine"
maintainer = "Colin Basnett <cmbasnett@gmail.com>" maintainer = "Colin Basnett <cmbasnett@gmail.com>"

View File

@@ -1,6 +1,7 @@
from typing import Optional from typing import Optional
from bpy.types import Armature, Bone, Action, PoseBone from bpy.types import Bone, Action, PoseBone
from mathutils import Vector
from .data import * from .data import *
from ..shared.helpers import * from ..shared.helpers import *
@@ -13,7 +14,9 @@ class PsaBuildSequence:
self.frame_start: int = 0 self.frame_start: int = 0
self.frame_end: int = 0 self.frame_end: int = 0
def __init__(self): def __init__(self, armature_object: Object, anim_data: AnimData):
self.armature_object = armature_object
self.anim_data = anim_data
self.name: str = '' self.name: str = ''
self.nla_state: PsaBuildSequence.NlaState = PsaBuildSequence.NlaState() self.nla_state: PsaBuildSequence.NlaState = PsaBuildSequence.NlaState()
self.compression_ratio: float = 1.0 self.compression_ratio: float = 1.0
@@ -27,10 +30,11 @@ class PsaBuildOptions:
self.sequences: List[PsaBuildSequence] = [] self.sequences: List[PsaBuildSequence] = []
self.bone_filter_mode: str = 'ALL' self.bone_filter_mode: str = 'ALL'
self.bone_collection_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_prefix: str = ''
self.sequence_name_suffix: str = '' self.sequence_name_suffix: str = ''
self.root_motion: bool = False self.root_motion: bool = False
self.scale = 1.0
self.sampling_mode: str = 'INTERPOLATED' # One of ('INTERPOLATED', 'SUBFRAME')
def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions): def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions):
@@ -49,6 +53,8 @@ def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: O
location = pose_bone_matrix.to_translation() location = pose_bone_matrix.to_translation()
rotation = pose_bone_matrix.to_quaternion().normalized() rotation = pose_bone_matrix.to_quaternion().normalized()
location *= options.scale
if pose_bone.parent is not None: if pose_bone.parent is not None:
rotation.conjugate() rotation.conjugate()
@@ -68,9 +74,6 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
# As a result, we need to reconstruct the list of pose bones in the same order as the # As a result, we need to reconstruct the list of pose bones in the same order as the
# armature bones. # armature bones.
bone_names = [x.name for x in bones] bone_names = [x.name for x in bones]
pose_bones = [(bone_names.index(bone.name), bone) for bone in armature_object.pose.bones]
pose_bones.sort(key=lambda x: x[0])
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. # 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_collection_indices) export_bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_indices)
@@ -78,16 +81,11 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
# Make the bone lists contain only the bones that are going to be exported. # Make the bone lists contain only the bones that are going to be exported.
bones = [bones[bone_index] for bone_index in bone_indices] bones = [bones[bone_index] for bone_index in bone_indices]
pose_bones = [pose_bones[bone_index] for bone_index in bone_indices]
# No bones are going to be exported. # No bones are going to be exported.
if len(bones) == 0: if len(bones) == 0:
raise RuntimeError('No bones available for export') raise RuntimeError('No bones available for export')
# Check that all bone names are valid.
if options.should_enforce_bone_name_restrictions:
check_bone_names(map(lambda bone: bone.name, bones))
# Build list of PSA bones. # Build list of PSA bones.
for bone in bones: for bone in bones:
psa_bone = Psa.Bone() psa_bone = Psa.Bone()
@@ -145,8 +143,14 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
context.window_manager.progress_begin(0, len(options.sequences)) context.window_manager.progress_begin(0, len(options.sequences))
for export_sequence_index, export_sequence in enumerate(options.sequences): for export_sequence_index, export_sequence in enumerate(options.sequences):
# Look up the pose bones for the bones that are going to be exported.
pose_bones = [(bone_names.index(bone.name), bone) for bone in export_sequence.armature_object.pose.bones]
pose_bones.sort(key=lambda x: x[0])
pose_bones = [x[1] for x in pose_bones]
pose_bones = [pose_bones[bone_index] for bone_index in bone_indices]
# Link the action to the animation data and update view layer. # Link the action to the animation data and update view layer.
options.animation_data.action = export_sequence.nla_state.action export_sequence.anim_data.action = export_sequence.nla_state.action
context.view_layer.update() context.view_layer.update()
frame_start = export_sequence.nla_state.frame_start frame_start = export_sequence.nla_state.frame_start
@@ -155,7 +159,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
# Calculate the frame step based on the compression factor. # Calculate the frame step based on the compression factor.
frame_extents = abs(frame_end - frame_start) frame_extents = abs(frame_end - frame_start)
frame_count_raw = frame_extents + 1 frame_count_raw = frame_extents + 1
frame_count = max(export_sequence.key_quota, int(frame_count_raw * export_sequence.compression_ratio)) frame_count = max(1, max(export_sequence.key_quota, int(frame_count_raw * export_sequence.compression_ratio)))
try: try:
frame_step = frame_extents / (frame_count - 1) frame_step = frame_extents / (frame_count - 1)
@@ -182,24 +186,83 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
frame = float(frame_start) frame = float(frame_start)
for _ in range(frame_count): def add_key(location: Vector, rotation: Quaternion):
context.scene.frame_set(frame=int(frame), subframe=frame % 1.0) key = Psa.Key()
key.location.x = location.x
key.location.y = location.y
key.location.z = location.z
key.rotation.x = rotation.x
key.rotation.y = rotation.y
key.rotation.z = rotation.z
key.rotation.w = rotation.w
key.time = 1.0 / psa_sequence.fps
psa.keys.append(key)
for pose_bone in pose_bones: match options.sampling_mode:
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, armature_object, options) case 'INTERPOLATED':
# Used as a store for the last frame's pose bone locations and rotations.
last_frame: Optional[int] = None
last_frame_bone_poses: List[Tuple[Vector, Quaternion]] = []
key = Psa.Key() next_frame: Optional[int] = None
key.location.x = location.x next_frame_bone_poses: List[Tuple[Vector, Quaternion]] = []
key.location.y = location.y
key.location.z = location.z
key.rotation.x = rotation.x
key.rotation.y = rotation.y
key.rotation.z = rotation.z
key.rotation.w = rotation.w
key.time = 1.0 / psa_sequence.fps
psa.keys.append(key)
frame += frame_step for _ in range(frame_count):
if last_frame is None or last_frame != int(frame):
# Populate the bone poses for frame A.
last_frame = int(frame)
# TODO: simplify this code and make it easier to follow!
if next_frame == last_frame:
# Simply transfer the data from next_frame to the last_frame so that we don't need to
# resample anything.
last_frame_bone_poses = next_frame_bone_poses.copy()
else:
last_frame_bone_poses.clear()
context.scene.frame_set(frame=last_frame)
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(pose_bone,
export_sequence.armature_object,
options)
last_frame_bone_poses.append((location, rotation))
next_frame = None
next_frame_bone_poses.clear()
# If this is not a subframe, just use the last frame's bone poses.
if frame % 1.0 == 0:
for i in range(len(pose_bones)):
add_key(*last_frame_bone_poses[i])
else:
# Otherwise, this is a subframe, so we need to interpolate the pose between the next frame and the last frame.
if next_frame is None:
next_frame = last_frame + 1
context.scene.frame_set(frame=next_frame)
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options)
next_frame_bone_poses.append((location, rotation))
factor = frame % 1.0
for i in range(len(pose_bones)):
last_location, last_rotation = last_frame_bone_poses[i]
next_location, next_rotation = next_frame_bone_poses[i]
location = last_location.lerp(next_location, factor)
rotation = last_rotation.slerp(next_rotation, factor)
add_key(location, rotation)
frame += frame_step
case 'SUBFRAME':
for _ in range(frame_count):
context.scene.frame_set(frame=int(frame), subframe=frame % 1.0)
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options)
add_key(location, rotation)
frame += frame_step
frame_start_index += frame_count frame_start_index += frame_count

View File

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

View File

@@ -1,6 +1,5 @@
import re
from collections import Counter from collections import Counter
from typing import List, Iterable, Dict, Tuple from typing import List, Iterable, Dict, Tuple, cast, Optional
import bpy import bpy
from bpy.props import StringProperty from bpy.props import StringProperty
@@ -8,32 +7,62 @@ from bpy.types import Context, Armature, Action, Object, AnimData, TimelineMarke
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from bpy_types import Operator from bpy_types import Operator
from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences, \
get_sequences_from_name_and_frame_range
from ..builder import build_psa, PsaBuildSequence, PsaBuildOptions from ..builder import build_psa, PsaBuildSequence, PsaBuildOptions
from ..writer import write_psa from ..writer import write_psa
from ...shared.helpers import populate_bone_collection_list, get_nla_strips_in_frame_range from ...shared.helpers import populate_bone_collection_list, get_nla_strips_in_frame_range, SemanticVersion
from ...shared.ui import draw_bone_filter_mode
def get_sequences_propnames_from_source(sequence_source: str) -> Optional[Tuple[str, str]]:
match sequence_source:
case 'ACTIONS':
return 'action_list', 'action_list_index'
case 'TIMELINE_MARKERS':
return 'marker_list', 'marker_list_index'
case 'NLA_TRACK_STRIPS':
return 'nla_strip_list', 'nla_strip_list_index'
case 'ACTIVE_ACTION':
return 'active_action_list', 'active_action_list_index'
case _:
raise ValueError(f'Unhandled sequence source: {sequence_source}')
def is_action_for_armature(armature: Armature, action: Action): def is_action_for_armature(armature: Armature, action: Action):
if len(action.fcurves) == 0: if len(action.fcurves) == 0:
return False return False
bone_names = set([x.name for x in armature.bones])
for fcurve in action.fcurves: version = SemanticVersion(bpy.app.version)
match = re.match(r'pose\.bones\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path)
if not match: if version < SemanticVersion((4, 4, 0)):
continue import re
bone_name = match.group(1) bone_names = set([x.name for x in armature.bones])
if bone_name in bone_names: for fcurve in action.fcurves:
return True match = re.match(r'pose\.bones\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path)
if not match:
continue
bone_name = match.group(1)
if bone_name in bone_names:
return True
else:
# Look up the armature by ID and check if its data block pointer matches the armature.
for slot in filter(lambda x: x.id_root == 'OBJECT', action.slots):
# Lop off the 'OB' prefix from the identifier for the lookup.
object = bpy.data.objects.get(slot.identifier[2:], None)
if object and object.data == armature:
return True
return False return False
def update_actions_and_timeline_markers(context: Context, armature: Armature): def update_actions_and_timeline_markers(context: Context):
pg = getattr(context.scene, 'psa_export') pg = getattr(context.scene, 'psa_export')
# Clear actions and markers. # Clear actions and markers.
pg.action_list.clear() pg.action_list.clear()
pg.marker_list.clear() pg.marker_list.clear()
pg.active_action_list.clear()
# Get animation data. # Get animation data.
animation_data_object = get_animation_data_object(context) animation_data_object = get_animation_data_object(context)
@@ -42,9 +71,11 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature):
if animation_data is None: if animation_data is None:
return return
active_armature = cast(Armature, context.active_object.data)
# Populate actions list. # Populate actions list.
for action in bpy.data.actions: for action in bpy.data.actions:
if not is_action_for_armature(armature, action): if not is_action_for_armature(active_armature, action):
continue continue
if action.name != '' and not action.name.startswith('#'): if action.name != '' and not action.name.startswith('#'):
@@ -89,6 +120,21 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature):
item.frame_start = frame_start item.frame_start = frame_start
item.frame_end = frame_end item.frame_end = frame_end
# Populate the active action list.
for armature_object in context.selected_objects:
if armature_object.type != 'ARMATURE':
continue
action = armature_object.animation_data.action if armature_object.animation_data else None
if action is None:
continue
item = pg.active_action_list.add()
item.name = action.name
item.armature_object = armature_object
item.action = action
item.frame_start = int(item.action.frame_range[0])
item.frame_end = int(item.action.frame_range[1])
item.is_selected = True
def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float: def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float:
match fps_source: match fps_source:
@@ -103,6 +149,17 @@ def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actio
raise RuntimeError(f'Invalid FPS source "{fps_source}"') raise RuntimeError(f'Invalid FPS source "{fps_source}"')
def get_sequence_compression_ratio(compression_ratio_source: str, compression_ratio_custom: float, actions: Iterable[Action]) -> float:
match compression_ratio_source:
case 'ACTION_METADATA':
# Get the minimum value of action metadata compression ratio values.
return min([action.psa_export.compression_ratio for action in actions])
case 'CUSTOM':
return compression_ratio_custom
case _:
raise RuntimeError(f'Invalid compression ratio source "{compression_ratio_source}"')
def get_animation_data_object(context: Context) -> Object: def get_animation_data_object(context: Context) -> Object:
pg: PSA_PG_export = getattr(context.scene, 'psa_export') pg: PSA_PG_export = getattr(context.scene, 'psa_export')
@@ -119,19 +176,11 @@ def get_animation_data_object(context: Context) -> Object:
return animation_data_object return animation_data_object
def is_bone_filter_mode_item_available(context, identifier):
if identifier == 'BONE_COLLECTIONS':
armature = context.active_object.data
if len(armature.collections) == 0:
return False
return True
def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context: Context, marker_names: List[str]) -> Dict: def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context: Context, marker_names: List[str]) -> Dict:
# Timeline markers need to be sorted so that we can determine the sequence start and end positions. # Timeline markers need to be sorted so that we can determine the sequence start and end positions.
sequence_frame_ranges = dict() sequence_frame_ranges = dict()
sorted_timeline_markers = list(sorted(context.scene.timeline_markers, key=lambda x: x.frame)) sorted_timeline_markers = list(sorted(context.scene.timeline_markers, key=lambda x: x.frame))
sorted_timeline_marker_names = list(map(lambda x: x.name, sorted_timeline_markers)) sorted_timeline_marker_names = [x.name for x in sorted_timeline_markers]
for marker_name in marker_names: for marker_name in marker_names:
marker = context.scene.timeline_markers[marker_name] marker = context.scene.timeline_markers[marker_name]
@@ -144,7 +193,7 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context:
if next_marker_index < len(sorted_timeline_markers): 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. # 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 frame_end = sorted_timeline_markers[next_marker_index].frame
nla_strips = get_nla_strips_in_frame_range(animation_data, marker.frame, frame_end) nla_strips = list(get_nla_strips_in_frame_range(animation_data, marker.frame, frame_end))
if len(nla_strips) > 0: if len(nla_strips) > 0:
frame_end = min(frame_end, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips))) 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))) frame_start = max(frame_start, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips)))
@@ -168,20 +217,6 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context:
return sequence_frame_ranges return sequence_frame_ranges
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, 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 [(name, frame_start, frame_end)]
def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]: def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]:
frame_start = int(action.frame_range[0]) frame_start = int(action.frame_range[0])
frame_end = int(action.frame_range[1]) frame_end = int(action.frame_range[1])
@@ -214,7 +249,7 @@ class PSA_OT_export(Operator, ExportHelper):
bl_idname = 'psa_export.operator' bl_idname = 'psa_export.operator'
bl_label = 'Export' bl_label = 'Export'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
__doc__ = 'Export actions to PSA' bl_description = 'Export actions to PSA'
filename_ext = '.psa' filename_ext = '.psa'
filter_glob: StringProperty(default='*.psa', options={'HIDDEN'}) filter_glob: StringProperty(default='*.psa', options={'HIDDEN'})
filepath: StringProperty( filepath: StringProperty(
@@ -223,7 +258,8 @@ class PSA_OT_export(Operator, ExportHelper):
maxlen=1024, maxlen=1024,
default='') default='')
def __init__(self): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.armature_object = None self.armature_object = None
@classmethod @classmethod
@@ -266,43 +302,59 @@ class PSA_OT_export(Operator, ExportHelper):
row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') 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') row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
# ACTIONS from .ui import PSA_UL_export_sequences
if pg.sequence_source == 'ACTIONS':
rows = max(3, min(len(pg.action_list), 10))
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))
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)
flow = sequences_panel.grid_flow() propname, active_propname = get_sequences_propnames_from_source(pg.sequence_source)
flow.use_property_split = True sequences_panel.template_list(PSA_UL_export_sequences.bl_idname, '', pg, propname, pg, active_propname,
flow.use_property_decorate = False rows=max(3, min(len(getattr(pg, propname)), 10)))
flow.prop(pg, 'sequence_name_prefix', text='Name Prefix')
flow.prop(pg, 'sequence_name_suffix')
# Determine if there is going to be a naming conflict and display an error, if so. name_header, name_panel = layout.panel('Name', default_closed=False)
selected_items = [x for x in pg.action_list if x.is_selected] name_header.label(text='Name')
action_names = [x.name for x in selected_items] if name_panel:
action_name_counts = Counter(action_names) flow = name_panel.grid_flow()
for action_name, count in action_name_counts.items(): flow.use_property_split = True
if count > 1: flow.use_property_decorate = False
layout.label(text=f'Duplicate action: {action_name}', icon='ERROR') flow.prop(pg, 'sequence_name_prefix', text='Name Prefix')
break flow.prop(pg, 'sequence_name_suffix')
# FPS # Determine if there is going to be a naming conflict and display an error, if so.
flow.prop(pg, 'fps_source') selected_items = [x for x in pg.action_list if x.is_selected]
if pg.fps_source == 'CUSTOM': action_names = [x.name for x in selected_items]
flow.prop(pg, 'fps_custom', text='Custom') action_name_counts = Counter(action_names)
for action_name, count in action_name_counts.items():
if count > 1:
layout.label(text=f'Duplicate action: {action_name}', icon='ERROR')
break
sampling_header, sampling_panel = layout.panel('Data Source', default_closed=False)
sampling_header.label(text='Sampling')
if sampling_panel:
flow = sampling_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
# SAMPLING MODE
flow.prop(pg, 'sampling_mode', text='Sampling Mode')
# FPS
col = flow.row(align=True)
col.prop(pg, 'fps_source', text='FPS')
if pg.fps_source == 'CUSTOM':
col.prop(pg, 'fps_custom', text='')
# COMPRESSION RATIO
col = flow.row(align=True)
col.prop(pg, 'compression_ratio_source', text='Compression Ratio')
if pg.compression_ratio_source == 'CUSTOM':
col.prop(pg, 'compression_ratio_custom', text='')
# BONES # BONES
bones_header, bones_panel = layout.panel('Bones', default_closed=False) bones_header, bones_panel = layout.panel('Bones', default_closed=False)
bones_header.label(text='Bones', icon='BONE_DATA') bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel: if bones_panel:
row = bones_panel.row(align=True) row = bones_panel.row(align=True)
row.prop(pg, 'bone_filter_mode', text='Bones')
draw_bone_filter_mode(row, pg)
if pg.bone_filter_mode == 'BONE_COLLECTIONS': if pg.bone_filter_mode == 'BONE_COLLECTIONS':
row = bones_panel.row(align=True) row = bones_panel.row(align=True)
@@ -313,20 +365,16 @@ class PSA_OT_export(Operator, ExportHelper):
bones_panel.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', bones_panel.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index',
rows=rows) rows=rows)
flow = bones_panel.grid_flow() # TRANSFORM
flow.use_property_split = True transform_header, transform_panel = layout.panel('Advanced', default_closed=False)
flow.use_property_decorate = False transform_header.label(text='Transform')
flow.prop(pg, 'should_enforce_bone_name_restrictions')
# ADVANCED if transform_panel:
advanced_header, advanced_panel = layout.panel('Advanced', default_closed=False) flow = transform_panel.grid_flow()
advanced_header.label(text='Advanced')
if advanced_panel:
flow = advanced_panel.grid_flow()
flow.use_property_split = True flow.use_property_split = True
flow.use_property_decorate = False flow.use_property_decorate = False
flow.prop(pg, 'root_motion', text='Root Motion') flow.prop(pg, 'root_motion', text='Root Motion')
flow.prop(pg, 'scale', text='Scale')
@classmethod @classmethod
def _check_context(cls, context): def _check_context(cls, context):
@@ -334,7 +382,19 @@ class PSA_OT_export(Operator, ExportHelper):
raise RuntimeError('An armature must be selected') raise RuntimeError('An armature must be selected')
if context.view_layer.objects.active.type != 'ARMATURE': if context.view_layer.objects.active.type != 'ARMATURE':
raise RuntimeError('The selected object must be an armature') raise RuntimeError('The active object must be an armature')
# If we have multiple armatures selected, make sure that they all use the same underlying armature data.
armature_objects = [obj for obj in context.selected_objects if obj.type == 'ARMATURE']
for obj in armature_objects:
if obj.data != context.view_layer.objects.active.data:
raise RuntimeError(f'All selected armatures must use the same armature data block.\n\n'
f'\The armature data block for "{obj.name}\" (\'{obj.data.name}\') does not match '
f'the active armature data block (\'{context.view_layer.objects.active.name}\')')
if context.scene.is_nla_tweakmode:
raise RuntimeError('Cannot export PSA while in NLA tweak mode')
def invoke(self, context, _event): def invoke(self, context, _event):
try: try:
@@ -351,7 +411,7 @@ class PSA_OT_export(Operator, ExportHelper):
# data created before (i.e. if no action was ever assigned to it). # data created before (i.e. if no action was ever assigned to it).
self.armature_object.animation_data_create() self.armature_object.animation_data_create()
update_actions_and_timeline_markers(context, self.armature_object.data) update_actions_and_timeline_markers(context)
populate_bone_collection_list(self.armature_object, pg.bone_collection_list) populate_bone_collection_list(self.armature_object, pg.bone_collection_list)
@@ -379,53 +439,68 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequences: List[PsaBuildSequence] = [] export_sequences: List[PsaBuildSequence] = []
if pg.sequence_source == 'ACTIONS': selected_armature_objects = [obj for obj in context.selected_objects if obj.type == 'ARMATURE']
for action_item in filter(lambda x: x.is_selected, pg.action_list):
if len(action_item.action.fcurves) == 0: match pg.sequence_source:
continue case 'ACTIONS':
export_sequence = PsaBuildSequence() for action_item in filter(lambda x: x.is_selected, pg.action_list):
export_sequence.nla_state.action = action_item.action if len(action_item.action.fcurves) == 0:
export_sequence.name = action_item.name continue
export_sequence.nla_state.frame_start = action_item.frame_start export_sequence = PsaBuildSequence(context.active_object, animation_data)
export_sequence.nla_state.frame_end = action_item.frame_end export_sequence.name = action_item.name
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action_item.action]) export_sequence.nla_state.action = action_item.action
export_sequence.compression_ratio = action_item.action.psa_export.compression_ratio export_sequence.nla_state.frame_start = action_item.frame_start
export_sequence.key_quota = action_item.action.psa_export.key_quota export_sequence.nla_state.frame_end = action_item.frame_end
export_sequences.append(export_sequence) export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action_item.action])
elif pg.sequence_source == 'TIMELINE_MARKERS': export_sequence.compression_ratio = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, [action_item.action])
for marker_item in filter(lambda x: x.is_selected, pg.marker_list): export_sequence.key_quota = action_item.action.psa_export.key_quota
export_sequence = PsaBuildSequence() export_sequences.append(export_sequence)
export_sequence.name = marker_item.name case 'TIMELINE_MARKERS':
export_sequence.nla_state.action = None for marker_item in filter(lambda x: x.is_selected, pg.marker_list):
export_sequence.nla_state.frame_start = marker_item.frame_start export_sequence = PsaBuildSequence(context.active_object, animation_data)
export_sequence.nla_state.frame_end = marker_item.frame_end export_sequence.name = marker_item.name
nla_strips_actions = set( export_sequence.nla_state.frame_start = marker_item.frame_start
map(lambda x: x.action, get_nla_strips_in_frame_range(animation_data, marker_item.frame_start, marker_item.frame_end))) export_sequence.nla_state.frame_end = marker_item.frame_end
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, nla_strips_actions) nla_strips_actions = set(
export_sequences.append(export_sequence) map(lambda x: x.action, get_nla_strips_in_frame_range(animation_data, marker_item.frame_start, marker_item.frame_end)))
elif pg.sequence_source == 'NLA_TRACK_STRIPS': export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, nla_strips_actions)
for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list): export_sequence.compression_ratio = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, nla_strips_actions)
export_sequence = PsaBuildSequence() export_sequences.append(export_sequence)
export_sequence.name = nla_strip_item.name case 'NLA_TRACK_STRIPS':
export_sequence.nla_state.action = None for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list):
export_sequence.nla_state.frame_start = nla_strip_item.frame_start export_sequence = PsaBuildSequence(context.active_object, animation_data)
export_sequence.nla_state.frame_end = nla_strip_item.frame_end export_sequence.name = nla_strip_item.name
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [nla_strip_item.action]) export_sequence.nla_state.frame_start = nla_strip_item.frame_start
export_sequence.compression_ratio = nla_strip_item.action.psa_export.compression_ratio export_sequence.nla_state.frame_end = nla_strip_item.frame_end
export_sequence.key_quota = nla_strip_item.action.psa_export.key_quota export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [nla_strip_item.action])
export_sequences.append(export_sequence) export_sequence.compression_ratio = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, [nla_strip_item.action])
else: export_sequence.key_quota = nla_strip_item.action.psa_export.key_quota
raise ValueError(f'Unhandled sequence source: {pg.sequence_source}') export_sequences.append(export_sequence)
case 'ACTIVE_ACTION':
for active_action_item in filter(lambda x: x.is_selected, pg.active_action_list):
export_sequence = PsaBuildSequence(active_action_item.armature_object, active_action_item.armature_object.animation_data)
action = active_action_item.action
export_sequence.name = action.name
export_sequence.nla_state.action = action
export_sequence.nla_state.frame_start = int(action.frame_range[0])
export_sequence.nla_state.frame_end = int(action.frame_range[1])
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action])
export_sequence.compression_ratio = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, [action])
export_sequence.key_quota = action.psa_export.key_quota
export_sequences.append(export_sequence)
case _:
raise ValueError(f'Unhandled sequence source: {pg.sequence_source}')
options = PsaBuildOptions() options = PsaBuildOptions()
options.animation_data = animation_data options.animation_data = animation_data
options.sequences = export_sequences options.sequences = export_sequences
options.bone_filter_mode = pg.bone_filter_mode options.bone_filter_mode = pg.bone_filter_mode
options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected] options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected]
options.should_ignore_bone_name_restrictions = pg.should_enforce_bone_name_restrictions
options.sequence_name_prefix = pg.sequence_name_prefix options.sequence_name_prefix = pg.sequence_name_prefix
options.sequence_name_suffix = pg.sequence_name_suffix options.sequence_name_suffix = pg.sequence_name_suffix
options.root_motion = pg.root_motion options.root_motion = pg.root_motion
options.scale = pg.scale
options.sampling_mode = pg.sampling_mode
try: try:
psa = build_psa(context, options) psa = build_psa(context, options)
@@ -448,13 +523,17 @@ class PSA_OT_export_actions_select_all(Operator):
@classmethod @classmethod
def get_item_list(cls, context): def get_item_list(cls, context):
pg = context.scene.psa_export pg = context.scene.psa_export
if pg.sequence_source == 'ACTIONS': match pg.sequence_source:
return pg.action_list case 'ACTIONS':
elif pg.sequence_source == 'TIMELINE_MARKERS': return pg.action_list
return pg.marker_list case 'TIMELINE_MARKERS':
elif pg.sequence_source == 'NLA_TRACK_STRIPS': return pg.marker_list
return pg.nla_strip_list case 'NLA_TRACK_STRIPS':
return None return pg.nla_strip_list
case 'ACTIVE_ACTION':
return pg.active_action_list
case _:
return None
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
@@ -481,13 +560,17 @@ class PSA_OT_export_actions_deselect_all(Operator):
@classmethod @classmethod
def get_item_list(cls, context): def get_item_list(cls, context):
pg = context.scene.psa_export pg = context.scene.psa_export
if pg.sequence_source == 'ACTIONS': match pg.sequence_source:
return pg.action_list case 'ACTIONS':
elif pg.sequence_source == 'TIMELINE_MARKERS': return pg.action_list
return pg.marker_list case 'TIMELINE_MARKERS':
elif pg.sequence_source == 'NLA_TRACK_STRIPS': return pg.marker_list
return pg.nla_strip_list case 'NLA_TRACK_STRIPS':
return None return pg.nla_strip_list
case 'ACTIVE_ACTION':
return pg.active_action_list
case _:
return None
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):

View File

@@ -1,12 +1,13 @@
import re import re
import sys import sys
from fnmatch import fnmatch from fnmatch import fnmatch
from typing import List, Optional from typing import List, Optional, Tuple
from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \ from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \
StringProperty StringProperty
from bpy.types import PropertyGroup, Object, Action, AnimData, Context from bpy.types import PropertyGroup, Object, Action, AnimData, Context
from ...shared.data import bone_filter_mode_items
from ...shared.types import PSX_PG_bone_collection_list_item from ...shared.types import PSX_PG_bone_collection_list_item
@@ -26,6 +27,15 @@ class PSA_PG_export_action_list_item(PropertyGroup):
is_pose_marker: BoolProperty(options={'HIDDEN'}) is_pose_marker: BoolProperty(options={'HIDDEN'})
class PSA_PG_export_active_action_list_item(PropertyGroup):
action: PointerProperty(type=Action)
name: StringProperty()
armature_object: PointerProperty(type=Object)
is_selected: BoolProperty(default=True)
frame_start: IntProperty(options={'HIDDEN'})
frame_end: IntProperty(options={'HIDDEN'})
class PSA_PG_export_timeline_markers(PropertyGroup): # TODO: rename this to singular class PSA_PG_export_timeline_markers(PropertyGroup): # TODO: rename this to singular
marker_index: IntProperty() marker_index: IntProperty()
name: StringProperty() name: StringProperty()
@@ -42,6 +52,20 @@ class PSA_PG_export_nla_strip_list_item(PropertyGroup):
is_selected: BoolProperty(default=True) is_selected: BoolProperty(default=True)
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, 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 [(name, frame_start, frame_end)]
def nla_track_update_cb(self: 'PSA_PG_export', context: Context) -> None: def nla_track_update_cb(self: 'PSA_PG_export', context: Context) -> None:
self.nla_strip_list.clear() self.nla_strip_list.clear()
match = re.match(r'^(\d+).+$', self.nla_track) match = re.match(r'^(\d+).+$', self.nla_track)
@@ -52,11 +76,12 @@ def nla_track_update_cb(self: 'PSA_PG_export', context: Context) -> None:
return return
nla_track = animation_data.nla_tracks[self.nla_track_index] nla_track = animation_data.nla_tracks[self.nla_track_index]
for nla_strip in nla_track.strips: for nla_strip in nla_track.strips:
strip: PSA_PG_export_nla_strip_list_item = self.nla_strip_list.add() for sequence_name, frame_start, frame_end in get_sequences_from_name_and_frame_range(nla_strip.name, nla_strip.frame_start, nla_strip.frame_end):
strip.action = nla_strip.action strip: PSA_PG_export_nla_strip_list_item = self.nla_strip_list.add()
strip.name = nla_strip.name strip.action = nla_strip.action
strip.frame_start = nla_strip.frame_start strip.name = sequence_name
strip.frame_end = nla_strip.frame_end strip.frame_start = frame_start
strip.frame_end = frame_end
def get_animation_data(pg: 'PSA_PG_export', context: Context) -> Optional[AnimData]: def get_animation_data(pg: 'PSA_PG_export', context: Context) -> Optional[AnimData]:
@@ -69,10 +94,9 @@ def get_animation_data(pg: 'PSA_PG_export', context: Context) -> Optional[AnimDa
def nla_track_search_cb(self, context: Context, edit_text: str): def nla_track_search_cb(self, context: Context, edit_text: str):
pg = getattr(context.scene, 'psa_export') pg = getattr(context.scene, 'psa_export')
animation_data = get_animation_data(pg, context) animation_data = get_animation_data(pg, context)
if animation_data is None: if animation_data is not None:
return for index, nla_track in enumerate(animation_data.nla_tracks):
for index, nla_track in enumerate(animation_data.nla_tracks): yield f'{index} - {nla_track.name}'
yield f'{index} - {nla_track.name}'
def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context): def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context):
@@ -108,7 +132,8 @@ class PSA_PG_export(PropertyGroup):
items=( items=(
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0), ('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences are delineated by scene 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_STRIPS', 'NLA Track Strips', 'Sequences are delineated by the start & end times of strips on the selected NLA track', 'NLA', 2),
('ACTIVE_ACTION', 'Active Action', 'The active action will be exported for each selected armature', 'ACTION', 3),
) )
) )
nla_track: StringProperty( nla_track: StringProperty(
@@ -131,30 +156,32 @@ class PSA_PG_export(PropertyGroup):
) )
fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=empty_set, step=100, fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=empty_set, step=100,
soft_max=60.0) soft_max=60.0)
compression_ratio_source: EnumProperty(
name='Compression Ratio Source',
options=empty_set,
description='',
items=(
('ACTION_METADATA', 'Action Metadata', 'The compression ratio will be determined by action\'s Compression Ratio property found in the PSA Export panel.\n\nIf the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used', 'ACTION', 1),
('CUSTOM', 'Custom', '', 2)
)
)
compression_ratio_custom: FloatProperty(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')
action_list: CollectionProperty(type=PSA_PG_export_action_list_item) action_list: CollectionProperty(type=PSA_PG_export_action_list_item)
action_list_index: IntProperty(default=0) action_list_index: IntProperty(default=0)
marker_list: CollectionProperty(type=PSA_PG_export_timeline_markers) marker_list: CollectionProperty(type=PSA_PG_export_timeline_markers)
marker_list_index: IntProperty(default=0) marker_list_index: IntProperty(default=0)
nla_strip_list: CollectionProperty(type=PSA_PG_export_nla_strip_list_item) nla_strip_list: CollectionProperty(type=PSA_PG_export_nla_strip_list_item)
nla_strip_list_index: IntProperty(default=0) nla_strip_list_index: IntProperty(default=0)
active_action_list: CollectionProperty(type=PSA_PG_export_active_action_list_item)
active_action_list_index: IntProperty(default=0)
bone_filter_mode: EnumProperty( bone_filter_mode: EnumProperty(
name='Bone Filter', name='Bone Filter',
options=empty_set, options=empty_set,
description='', description='',
items=( items=bone_filter_mode_items,
('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_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item) bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
bone_collection_list_index: IntProperty(default=0, name='', description='') 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 '
'may not be able to be referenced in-engine'
)
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set) sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set) sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)
sequence_filter_name: StringProperty( sequence_filter_name: StringProperty(
@@ -183,6 +210,23 @@ class PSA_PG_export(PropertyGroup):
name='Show Reversed', name='Show Reversed',
description='Show reversed sequences' description='Show reversed sequences'
) )
scale: FloatProperty(
name='Scale',
default=1.0,
description='Scale factor to apply to the bone translations. Use this if you are exporting animations for a scaled PSK mesh',
min=0.0,
soft_max=100.0
)
sampling_mode: EnumProperty(
name='Sampling Mode',
options=empty_set,
description='The method by which frames are sampled',
items=(
('INTERPOLATED', 'Interpolated', 'Sampling is performed by interpolating the evaluated bone poses from the adjacent whole frames.', 'INTERPOLATED', 0),
('SUBFRAME', 'Subframe', 'Sampling is performed by evaluating the bone poses at the subframe time.\n\nNot recommended unless you are also animating with subframes enabled.', 'SUBFRAME', 1),
),
default='INTERPOLATED'
)
def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]: def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:
@@ -222,5 +266,6 @@ classes = (
PSA_PG_export_action_list_item, PSA_PG_export_action_list_item,
PSA_PG_export_timeline_markers, PSA_PG_export_timeline_markers,
PSA_PG_export_nla_strip_list_item, PSA_PG_export_nla_strip_list_item,
PSA_PG_export_active_action_list_item,
PSA_PG_export, PSA_PG_export,
) )

View File

@@ -1,4 +1,4 @@
from typing import cast import typing
from bpy.types import UIList from bpy.types import UIList
@@ -6,14 +6,16 @@ from .properties import PSA_PG_export_action_list_item, filter_sequences
class PSA_UL_export_sequences(UIList): class PSA_UL_export_sequences(UIList):
bl_idname = 'PSA_UL_export_sequences'
def __init__(self): def __init__(self, *args, **kwargs):
super(PSA_UL_export_sequences, self).__init__() super(PSA_UL_export_sequences, self).__init__(*args, **kwargs)
# Show the filtering options by default. # Show the filtering options by default.
self.use_filter_show = True self.use_filter_show = True
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
item = cast(PSA_PG_export_action_list_item, item) item = typing.cast(PSA_PG_export_action_list_item, item)
is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker
layout.prop(item, 'is_selected', icon_only=True, text=item.name) layout.prop(item, 'is_selected', icon_only=True, text=item.name)
if hasattr(item, 'action') and item.action is not None and item.action.asset_data is not None: if hasattr(item, 'action') and item.action is not None and item.action.asset_data is not None:
@@ -26,6 +28,9 @@ class PSA_UL_export_sequences(UIList):
if is_pose_marker: if is_pose_marker:
row.label(text=item.action.name, icon='PMARKER') row.label(text=item.action.name, icon='PMARKER')
if hasattr(item, 'armature_object') and item.armature_object is not None:
row.label(text=item.armature_object.name, icon='ARMATURE_DATA')
def draw_filter(self, context, layout): def draw_filter(self, context, layout):
pg = getattr(context.scene, 'psa_export') pg = getattr(context.scene, 'psa_export')
row = layout.row() row = layout.row()

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ class PsaImportOptions(object):
self.bone_mapping_mode = 'CASE_INSENSITIVE' self.bone_mapping_mode = 'CASE_INSENSITIVE'
self.fps_source = 'SEQUENCE' self.fps_source = 'SEQUENCE'
self.fps_custom: float = 30.0 self.fps_custom: float = 30.0
self.translation_scale: float = 1.0
self.should_use_config_file = True self.should_use_config_file = True
self.psa_config: PsaConfig = PsaConfig() self.psa_config: PsaConfig = PsaConfig()
@@ -88,6 +89,7 @@ def _get_sample_frame_times(source_frame_count: int, frame_step: float) -> typin
time += frame_step time += frame_step
yield source_frame_count - 1 yield source_frame_count - 1
def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step: float = 1.0) -> np.ndarray: def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step: float = 1.0) -> np.ndarray:
""" """
Resamples the sequence data matrix to the target frame count. Resamples the sequence data matrix to the target frame count.
@@ -271,6 +273,10 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
# Read the sequence data matrix from the PSA. # Read the sequence data matrix from the PSA.
sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name) sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name)
if options.translation_scale != 1.0:
# Scale the translation data.
sequence_data_matrix[:, :, 4:] *= options.translation_scale
# Convert the sequence's data from world-space to local-space. # Convert the sequence's data from world-space to local-space.
for bone_index, import_bone in enumerate(import_bones): for bone_index, import_bone in enumerate(import_bones):
if import_bone is None: if import_bone is None:

View File

@@ -1,18 +1,20 @@
import typing
from typing import Optional from typing import Optional
import bmesh import bmesh
import bpy
import numpy as np import numpy as np
from bpy.types import Armature, Material, Collection, Context from bpy.types import Material, Collection, Context
from mathutils import Matrix, Vector
from .data import * from .data import *
from .properties import triangle_type_and_bit_flags_to_poly_flags from .properties import triangle_type_and_bit_flags_to_poly_flags
from ..shared.dfs import dfs_collection_objects, dfs_view_layer_objects, DfsObject
from ..shared.helpers import * from ..shared.helpers import *
class PskInputObjects(object): class PskInputObjects(object):
def __init__(self): def __init__(self):
self.mesh_objects = [] self.mesh_objects: List[DfsObject] = []
self.armature_object: Optional[Object] = None self.armature_object: Optional[Object] = None
@@ -22,22 +24,51 @@ class PskBuildOptions(object):
self.bone_collection_indices: List[int] = [] self.bone_collection_indices: List[int] = []
self.object_eval_state = 'EVALUATED' self.object_eval_state = 'EVALUATED'
self.materials: List[Material] = [] self.materials: List[Material] = []
self.should_enforce_bone_name_restrictions = False self.scale = 1.0
self.export_space = 'WORLD'
self.forward_axis = 'X'
self.up_axis = 'Z'
def get_mesh_objects_for_collection(collection: Collection): def get_vector_from_axis_identifier(axis_identifier: str) -> Vector:
for obj in collection.all_objects: match axis_identifier:
if obj.type == 'MESH': case 'X':
yield obj return Vector((1.0, 0.0, 0.0))
case 'Y':
return Vector((0.0, 1.0, 0.0))
case 'Z':
return Vector((0.0, 0.0, 1.0))
case '-X':
return Vector((-1.0, 0.0, 0.0))
case '-Y':
return Vector((0.0, -1.0, 0.0))
case '-Z':
return Vector((0.0, 0.0, -1.0))
def get_mesh_objects_for_context(context: Context): def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z') -> Matrix:
for obj in context.view_layer.objects.selected: forward = get_vector_from_axis_identifier(forward_axis)
if obj.type == 'MESH': up = get_vector_from_axis_identifier(up_axis)
yield obj left = up.cross(forward)
return Matrix((
(forward.x, forward.y, forward.z, 0.0),
(left.x, left.y, left.z, 0.0),
(up.x, up.y, up.z, 0.0),
(0.0, 0.0, 0.0, 1.0)
)).inverted()
def get_armature_for_mesh_objects(mesh_objects: List[Object]) -> Optional[Object]: def get_mesh_objects_for_collection(collection: Collection) -> Iterable[DfsObject]:
return filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection))
def get_mesh_objects_for_context(context: Context) -> Iterable[DfsObject]:
for dfs_object in dfs_view_layer_objects(context.view_layer):
if dfs_object.obj.type == 'MESH' and dfs_object.is_selected:
yield dfs_object
def get_armature_for_mesh_objects(mesh_objects: Iterable[Object]) -> Optional[Object]:
# Ensure that there are either no armature modifiers (static mesh) or that there is exactly one armature modifier # Ensure that there are either no armature modifiers (static mesh) or that there is exactly one armature modifier
# object shared between all meshes. # object shared between all meshes.
armature_modifier_objects = set() armature_modifier_objects = set()
@@ -59,17 +90,14 @@ def get_armature_for_mesh_objects(mesh_objects: List[Object]) -> Optional[Object
return None return None
def _get_psk_input_objects(mesh_objects: List[Object]) -> PskInputObjects: def _get_psk_input_objects(mesh_objects: Iterable[DfsObject]) -> PskInputObjects:
mesh_objects = list(mesh_objects)
if len(mesh_objects) == 0: if len(mesh_objects) == 0:
raise RuntimeError('At least one mesh must be selected') raise RuntimeError('At least one mesh must be selected')
for mesh_object in mesh_objects:
if len(mesh_object.data.materials) == 0:
raise RuntimeError(f'Mesh "{mesh_object.name}" must have at least one material')
input_objects = PskInputObjects() input_objects = PskInputObjects()
input_objects.mesh_objects = mesh_objects input_objects.mesh_objects = mesh_objects
input_objects.armature_object = get_armature_for_mesh_objects(mesh_objects) input_objects.armature_object = get_armature_for_mesh_objects([x.obj for x in mesh_objects])
return input_objects return input_objects
@@ -79,8 +107,10 @@ def get_psk_input_objects_for_context(context: Context) -> PskInputObjects:
return _get_psk_input_objects(mesh_objects) return _get_psk_input_objects(mesh_objects)
def get_psk_input_objects_for_collection(collection: Collection) -> PskInputObjects: def get_psk_input_objects_for_collection(collection: Collection, should_exclude_hidden_meshes: bool = True) -> PskInputObjects:
mesh_objects = list(get_mesh_objects_for_collection(collection)) mesh_objects = get_mesh_objects_for_collection(collection)
if should_exclude_hidden_meshes:
mesh_objects = filter(lambda x: x.is_visible, mesh_objects)
return _get_psk_input_objects(mesh_objects) return _get_psk_input_objects(mesh_objects)
@@ -97,6 +127,24 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
psk = Psk() psk = Psk()
bones = [] bones = []
def get_export_space_matrix():
match options.export_space:
case 'WORLD':
return Matrix.Identity(4)
case 'ARMATURE':
if armature_object is not None:
return armature_object.matrix_world.inverted()
else:
return Matrix.Identity(4)
case _:
raise ValueError(f'Invalid export space: {options.export_space}')
coordinate_system_matrix = get_coordinate_system_transform(options.forward_axis, options.up_axis)
coordinate_system_default_rotation = coordinate_system_matrix.to_quaternion()
export_space_matrix = get_export_space_matrix() # TODO: maybe neutralize the scale here?
scale_matrix = coordinate_system_matrix @ Matrix.Scale(options.scale, 4)
if armature_object is None or len(armature_object.data.bones) == 0: 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 # 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. # requirement that a PSK file must have at least one bone.
@@ -106,17 +154,13 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
psk_bone.children_count = 0 psk_bone.children_count = 0
psk_bone.parent_index = 0 psk_bone.parent_index = 0
psk_bone.location = Vector3.zero() psk_bone.location = Vector3.zero()
psk_bone.rotation = Quaternion.identity() psk_bone.rotation = coordinate_system_default_rotation
psk.bones.append(psk_bone) psk.bones.append(psk_bone)
else: else:
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_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) armature_data = typing.cast(Armature, armature_object.data)
bones = [armature_data.bones[bone_name] for bone_name in bone_names] bones = [armature_data.bones[bone_name] for bone_name in bone_names]
# Check that all bone names are valid.
if options.should_enforce_bone_name_restrictions:
check_bone_names(map(lambda x: x.name, bones))
for bone in bones: for bone in bones:
psk_bone = Psk.Bone() psk_bone = Psk.Bone()
try: try:
@@ -141,12 +185,30 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
parent_tail = inverse_parent_rotation @ bone.parent.tail parent_tail = inverse_parent_rotation @ bone.parent.tail
location = (parent_tail - parent_head) + bone.head location = (parent_tail - parent_head) + bone.head
else: else:
armature_local_matrix = armature_object.matrix_local def get_armature_local_matrix():
match options.export_space:
case 'WORLD':
return armature_object.matrix_world
case 'ARMATURE':
return Matrix.Identity(4)
case _:
raise ValueError(f'Invalid export space: {options.export_space}')
armature_local_matrix = get_armature_local_matrix()
location = armature_local_matrix @ bone.head location = armature_local_matrix @ bone.head
bone_rotation = bone.matrix.to_quaternion().conjugated() bone_rotation = bone.matrix.to_quaternion().conjugated()
local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated() local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated()
rotation = bone_rotation @ local_rotation rotation = bone_rotation @ local_rotation
rotation.conjugate() rotation.conjugate()
rotation = coordinate_system_default_rotation @ rotation
location = scale_matrix @ location
# If the armature object has been scaled, we need to scale the bone's location to match.
_, _, armature_object_scale = armature_object.matrix_world.decompose()
location.x *= armature_object_scale.x
location.y *= armature_object_scale.y
location.z *= armature_object_scale.z
psk_bone.location.x = location.x psk_bone.location.x = location.x
psk_bone.location.y = location.y psk_bone.location.y = location.y
@@ -171,22 +233,53 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
material.psk.mesh_triangle_bit_flags) material.psk.mesh_triangle_bit_flags)
psk.materials.append(psk_material) psk.materials.append(psk_material)
# TODO: This wasn't left in a good state. We should detect if we need to add a "default" material.
# This can be done by checking if there is an empty material slot on any of the mesh objects, or if there are
# no material slots on any of the mesh objects.
# If so, it should be added to the end of the list of materials, and its index should mapped to a None value in the
# material indices list.
if len(psk.materials) == 0:
# Add a default material if no materials are present.
psk_material = Psk.Material()
psk_material.name = bytes('None', encoding='windows-1252')
psk.materials.append(psk_material)
context.window_manager.progress_begin(0, len(input_objects.mesh_objects)) context.window_manager.progress_begin(0, len(input_objects.mesh_objects))
material_names = [m.name for m in options.materials] material_names = [m.name for m in options.materials]
for object_index, input_mesh_object in enumerate(input_objects.mesh_objects): for object_index, input_mesh_object in enumerate(input_objects.mesh_objects):
obj, instance_objects, matrix_world = input_mesh_object.obj, input_mesh_object.instance_objects, input_mesh_object.matrix_world
should_flip_normals = False should_flip_normals = False
def get_material_name_indices(obj: Object, material_names: List[str]) -> Iterable[int]:
'''
Returns the index of the material in the list of material names.
If the material is not found, the index 0 is returned.
'''
for material_slot in obj.material_slots:
if material_slot.material is None:
yield 0
else:
try:
yield material_names.index(material_slot.material.name)
except ValueError:
yield 0
# MATERIALS # MATERIALS
material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots] material_indices = list(get_material_name_indices(obj, material_names))
if len(material_indices) == 0:
# Add a default material if no materials are present.
material_indices = [0]
# MESH DATA # MESH DATA
match options.object_eval_state: match options.object_eval_state:
case 'ORIGINAL': case 'ORIGINAL':
mesh_object = input_mesh_object mesh_object = obj
mesh_data = input_mesh_object.data mesh_data = obj.data
case 'EVALUATED': case 'EVALUATED':
# Create a copy of the mesh object after non-armature modifiers are applied. # Create a copy of the mesh object after non-armature modifiers are applied.
@@ -199,14 +292,21 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
depsgraph = context.evaluated_depsgraph_get() depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new() bm = bmesh.new()
bm.from_object(input_mesh_object, depsgraph)
try:
bm.from_object(obj, depsgraph)
except ValueError:
raise RuntimeError(f'Object "{obj.name}" is not evaluated.\n'
'This is likely because the object is in a collection that has been excluded from the view layer.')
mesh_data = bpy.data.meshes.new('') mesh_data = bpy.data.meshes.new('')
bm.to_mesh(mesh_data) bm.to_mesh(mesh_data)
del bm del bm
mesh_object = bpy.data.objects.new('', mesh_data) mesh_object = bpy.data.objects.new('', mesh_data)
mesh_object.matrix_world = input_mesh_object.matrix_world mesh_object.matrix_world = matrix_world
scale = (input_mesh_object.scale.x, input_mesh_object.scale.y, input_mesh_object.scale.z) # Extract the scale from the matrix.
_, _, scale = matrix_world.decompose()
# Negative scaling in Blender results in inverted normals after the scale is applied. However, if the scale # Negative scaling in Blender results in inverted normals after the scale is applied. However, if the scale
# is not applied, the normals will appear unaffected in the viewport. The evaluated mesh data used in the # is not applied, the normals will appear unaffected in the viewport. The evaluated mesh data used in the
@@ -219,19 +319,22 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1 should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1
# Copy the vertex groups # Copy the vertex groups
for vertex_group in input_mesh_object.vertex_groups: for vertex_group in obj.vertex_groups:
mesh_object.vertex_groups.new(name=vertex_group.name) mesh_object.vertex_groups.new(name=vertex_group.name)
# Restore the previous pose position on the armature. # Restore the previous pose position on the armature.
if old_pose_position is not None: if old_pose_position is not None:
armature_object.data.pose_position = old_pose_position armature_object.data.pose_position = old_pose_position
case _:
raise ValueError(f'Invalid object evaluation state: {options.object_eval_state}')
vertex_offset = len(psk.points) vertex_offset = len(psk.points)
matrix_world = scale_matrix @ export_space_matrix @ mesh_object.matrix_world
# VERTICES # VERTICES
for vertex in mesh_data.vertices: for vertex in mesh_data.vertices:
point = Vector3() point = Vector3()
v = mesh_object.matrix_world @ vertex.co v = matrix_world @ vertex.co
point.x = v.x point.x = v.x
point.y = v.y point.y = v.y
point.z = v.z point.z = v.z

View File

@@ -1,30 +1,23 @@
from typing import List from typing import List, Optional, cast, Iterable
import bpy import bpy
from bpy.props import StringProperty, BoolProperty, EnumProperty from bpy.props import StringProperty
from bpy.types import Operator, Context, Object from bpy.types import Operator, Context, Object, Collection, SpaceProperties, Depsgraph, Material
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from .properties import object_eval_state_items from .properties import add_psk_export_properties
from ..builder import build_psk, PskBuildOptions, get_psk_input_objects_for_context, \ from ..builder import build_psk, PskBuildOptions, get_psk_input_objects_for_context, \
get_psk_input_objects_for_collection get_psk_input_objects_for_collection
from ..writer import write_psk from ..writer import write_psk
from ...shared.helpers import populate_bone_collection_list from ...shared.helpers import populate_bone_collection_list
from ...shared.ui import draw_bone_filter_mode
def is_bone_filter_mode_item_available(context, identifier): def get_materials_for_mesh_objects(depsgraph: Depsgraph, mesh_objects: Iterable[Object]):
input_objects = get_psk_input_objects_for_context(context)
armature_object = input_objects.armature_object
if identifier == 'BONE_COLLECTIONS':
if armature_object is None or armature_object.data is None or len(armature_object.data.collections) == 0:
return False
return True
def get_materials_for_mesh_objects(mesh_objects: List[Object]):
materials = [] materials = []
for mesh_object in mesh_objects: for mesh_object in mesh_objects:
for i, material_slot in enumerate(mesh_object.material_slots): evaluated_mesh_object = mesh_object.evaluated_get(depsgraph)
for i, material_slot in enumerate(evaluated_mesh_object.material_slots):
material = material_slot.material material = material_slot.material
if material is None: if material is None:
raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')') raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')')
@@ -32,15 +25,80 @@ def get_materials_for_mesh_objects(mesh_objects: List[Object]):
materials.append(material) materials.append(material)
return materials return materials
def populate_material_list(mesh_objects, material_list):
materials = get_materials_for_mesh_objects(mesh_objects) def populate_material_name_list(depsgraph: Depsgraph, mesh_objects, material_list):
materials = get_materials_for_mesh_objects(depsgraph, mesh_objects)
material_list.clear() material_list.clear()
for index, material in enumerate(materials): for index, material in enumerate(materials):
m = material_list.add() m = material_list.add()
m.material = material m.material_name = material.name
m.index = index m.index = index
def get_collection_from_context(context: Context) -> Optional[Collection]:
if context.space_data.type != 'PROPERTIES':
return None
space_data = cast(SpaceProperties, context.space_data)
if space_data.use_pin_id:
return cast(Collection, space_data.pin_id)
else:
return context.collection
def get_collection_export_operator_from_context(context: Context) -> Optional[object]:
collection = get_collection_from_context(context)
if collection is None:
return None
if 0 > collection.active_exporter_index >= len(collection.exporters):
return None
exporter = collection.exporters[collection.active_exporter_index]
# TODO: make sure this is actually an ASE exporter.
return exporter.export_properties
class PSK_OT_populate_bone_collection_list(Operator):
bl_idname = 'psk_export.populate_bone_collection_list'
bl_label = 'Populate Bone Collection List'
bl_description = 'Populate the bone collection list from the armature that will be used in this collection export'
bl_options = {'INTERNAL'}
def execute(self, context):
export_operator = get_collection_export_operator_from_context(context)
if export_operator is None:
self.report({'ERROR_INVALID_CONTEXT'}, 'No valid export operator found in context')
return {'CANCELLED'}
input_objects = get_psk_input_objects_for_collection(context.collection)
if input_objects.armature_object is None:
self.report({'ERROR_INVALID_CONTEXT'}, 'No armature found in collection')
return {'CANCELLED'}
populate_bone_collection_list(input_objects.armature_object, export_operator.bone_collection_list)
return {'FINISHED'}
class PSK_OT_populate_material_name_list(Operator):
bl_idname = 'psk_export.populate_material_name_list'
bl_label = 'Populate Material Name List'
bl_description = 'Populate the material name list from the objects that will be used in this export'
bl_options = {'INTERNAL'}
def execute(self, context):
export_operator = get_collection_export_operator_from_context(context)
if export_operator is None:
self.report({'ERROR_INVALID_CONTEXT'}, 'No valid export operator found in context')
return {'CANCELLED'}
depsgraph = context.evaluated_depsgraph_get()
input_objects = get_psk_input_objects_for_collection(context.collection)
try:
populate_material_name_list(depsgraph, [x.obj for x in input_objects.mesh_objects], export_operator.material_name_list)
except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'}
return {'FINISHED'}
class PSK_OT_material_list_move_up(Operator): class PSK_OT_material_list_move_up(Operator):
bl_idname = 'psk_export.material_list_item_move_up' bl_idname = 'psk_export.material_list_item_move_up'
bl_label = 'Move Up' bl_label = 'Move Up'
@@ -50,12 +108,12 @@ class PSK_OT_material_list_move_up(Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
pg = getattr(context.scene, 'psk_export') pg = getattr(context.scene, 'psk_export')
return pg.material_list_index > 0 return pg.material_name_list_index > 0
def execute(self, context): def execute(self, context):
pg = getattr(context.scene, 'psk_export') pg = getattr(context.scene, 'psk_export')
pg.material_list.move(pg.material_list_index, pg.material_list_index - 1) pg.material_name_list.move(pg.material_name_list_index, pg.material_name_list_index - 1)
pg.material_list_index -= 1 pg.material_name_list_index -= 1
return {'FINISHED'} return {'FINISHED'}
@@ -68,15 +126,98 @@ class PSK_OT_material_list_move_down(Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
pg = getattr(context.scene, 'psk_export') pg = getattr(context.scene, 'psk_export')
return pg.material_list_index < len(pg.material_list) - 1 return pg.material_name_list_index < len(pg.material_name_list) - 1
def execute(self, context): def execute(self, context):
pg = getattr(context.scene, 'psk_export') pg = getattr(context.scene, 'psk_export')
pg.material_list.move(pg.material_list_index, pg.material_list_index + 1) pg.material_name_list.move(pg.material_name_list_index, pg.material_name_list_index + 1)
pg.material_list_index += 1 pg.material_name_list_index += 1
return {'FINISHED'} return {'FINISHED'}
class PSK_OT_material_list_name_move_up(Operator):
bl_idname = 'psk_export.material_name_list_item_move_up'
bl_label = 'Move Up'
bl_options = {'INTERNAL'}
bl_description = 'Move the selected material name up one slot'
@classmethod
def poll(cls, context):
export_operator = get_collection_export_operator_from_context(context)
if export_operator is None:
return False
return export_operator.material_name_list_index > 0
def execute(self, context):
export_operator = get_collection_export_operator_from_context(context)
if export_operator is None:
self.report({'ERROR_INVALID_CONTEXT'}, 'No valid export operator found in context')
return {'CANCELLED'}
export_operator.material_name_list.move(export_operator.material_name_list_index, export_operator.material_name_list_index - 1)
export_operator.material_name_list_index -= 1
return {'FINISHED'}
class PSK_OT_material_list_name_move_down(Operator):
bl_idname = 'psk_export.material_name_list_item_move_down'
bl_label = 'Move Down'
bl_options = {'INTERNAL'}
bl_description = 'Move the selected material name down one slot'
@classmethod
def poll(cls, context):
export_operator = get_collection_export_operator_from_context(context)
if export_operator is None:
return False
return export_operator.material_name_list_index < len(export_operator.material_name_list) - 1
def execute(self, context):
export_operator = get_collection_export_operator_from_context(context)
if export_operator is None:
self.report({'ERROR_INVALID_CONTEXT'}, 'No valid export operator found in context')
return {'CANCELLED'}
export_operator.material_name_list.move(export_operator.material_name_list_index, export_operator.material_name_list_index + 1)
export_operator.material_name_list_index += 1
return {'FINISHED'}
empty_set = set()
def get_sorted_materials_by_names(materials: Iterable[Material], material_names: List[str]) -> List[Material]:
"""
Sorts the materials by the order of the material names list. Any materials not in the list will be appended to the
end of the list in the order they are found.
@param materials: A list of materials to sort
@param material_names: A list of material names to sort by
@return: A sorted list of materials
"""
materials_in_collection = [m for m in materials if m.name in material_names]
materials_not_in_collection = [m for m in materials if m.name not in material_names]
materials_in_collection = sorted(materials_in_collection, key=lambda x: material_names.index(x.name))
return materials_in_collection + materials_not_in_collection
def get_psk_build_options_from_property_group(mesh_objects: Iterable[Object], pg: 'PSK_PG_export', depsgraph: Optional[Depsgraph] = None) -> PskBuildOptions:
if depsgraph is None:
depsgraph = bpy.context.evaluated_depsgraph_get()
options = PskBuildOptions()
options.object_eval_state = pg.object_eval_state
options.export_space = pg.export_space
options.bone_filter_mode = pg.bone_filter_mode
options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected]
options.scale = pg.scale
options.forward_axis = pg.forward_axis
options.up_axis = pg.up_axis
# TODO: perhaps move this into the build function and replace the materials list with a material names list.
materials = get_materials_for_mesh_objects(depsgraph, mesh_objects)
options.materials = get_sorted_materials_by_names(materials, [m.material_name for m in pg.material_name_list])
return options
class PSK_OT_export_collection(Operator, ExportHelper): class PSK_OT_export_collection(Operator, ExportHelper):
bl_idname = 'export.psk_collection' bl_idname = 'export.psk_collection'
bl_label = 'Export' bl_label = 'Export'
@@ -91,32 +232,16 @@ class PSK_OT_export_collection(Operator, ExportHelper):
subtype='FILE_PATH') subtype='FILE_PATH')
collection: StringProperty(options={'HIDDEN'}) collection: StringProperty(options={'HIDDEN'})
object_eval_state: EnumProperty(
items=object_eval_state_items,
name='Object Evaluation State',
default='EVALUATED'
)
should_enforce_bone_name_restrictions: BoolProperty(
default=False,
name='Enforce Bone Name Restrictions',
description='Enforce that bone names must only contain letters, numbers, spaces, hyphens and underscores.\n\n'
'Depending on the engine, improper bone names might not be referenced correctly by scripts'
)
def execute(self, context): def execute(self, context):
collection = bpy.data.collections.get(self.collection) collection = bpy.data.collections.get(self.collection)
try: try:
input_objects = get_psk_input_objects_for_collection(collection) input_objects = get_psk_input_objects_for_collection(collection, self.should_exclude_hidden_meshes)
except RuntimeError as e: except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
options = PskBuildOptions() options = get_psk_build_options_from_property_group([x.obj for x in input_objects.mesh_objects], self)
options.bone_filter_mode = 'ALL'
options.object_eval_state = self.object_eval_state
options.materials = get_materials_for_mesh_objects(input_objects.mesh_objects)
options.should_enforce_bone_name_restrictions = self.should_enforce_bone_name_restrictions
try: try:
result = build_psk(context, input_objects, options) result = build_psk(context, input_objects, options)
@@ -136,6 +261,10 @@ class PSK_OT_export_collection(Operator, ExportHelper):
def draw(self, context: Context): def draw(self, context: Context):
layout = self.layout layout = self.layout
flow = layout.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
# MESH # MESH
mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False) mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False)
mesh_header.label(text='Mesh', icon='MESH_DATA') mesh_header.label(text='Mesh', icon='MESH_DATA')
@@ -144,25 +273,56 @@ class PSK_OT_export_collection(Operator, ExportHelper):
flow.use_property_split = True flow.use_property_split = True
flow.use_property_decorate = False flow.use_property_decorate = False
flow.prop(self, 'object_eval_state', text='Data') flow.prop(self, 'object_eval_state', text='Data')
flow.prop(self, 'should_exclude_hidden_meshes')
# BONES # BONES
bones_header, bones_panel = layout.panel('Bones', default_closed=False) bones_header, bones_panel = layout.panel('Bones', default_closed=False)
bones_header.label(text='Bones', icon='BONE_DATA') bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel: if bones_panel:
flow = bones_panel.grid_flow(row_major=True) draw_bone_filter_mode(bones_panel, self, True)
if self.bone_filter_mode == 'BONE_COLLECTIONS':
bones_panel.operator(PSK_OT_populate_bone_collection_list.bl_idname, icon='FILE_REFRESH')
rows = max(3, min(len(self.bone_collection_list), 10))
bones_panel.template_list('PSX_UL_bone_collection_list', '', self, 'bone_collection_list', self, 'bone_collection_list_index', rows=rows)
# MATERIALS
materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials', icon='MATERIAL')
if materials_panel:
materials_panel.operator(PSK_OT_populate_material_name_list.bl_idname, icon='FILE_REFRESH')
rows = max(3, min(len(self.material_name_list), 10))
row = materials_panel.row()
row.template_list('PSK_UL_material_names', '', self, 'material_name_list', self, 'material_name_list_index', rows=rows)
col = row.column(align=True)
col.operator(PSK_OT_material_list_name_move_up.bl_idname, text='', icon='TRIA_UP')
col.operator(PSK_OT_material_list_name_move_down.bl_idname, text='', icon='TRIA_DOWN')
# TRANSFORM
transform_header, transform_panel = layout.panel('Transform', default_closed=False)
transform_header.label(text='Transform')
if transform_panel:
flow = transform_panel.grid_flow(row_major=True)
flow.use_property_split = True flow.use_property_split = True
flow.use_property_decorate = False flow.use_property_decorate = False
flow.prop(self, 'should_enforce_bone_name_restrictions') flow.prop(self, 'export_space')
flow.prop(self, 'scale')
flow.prop(self, 'forward_axis')
flow.prop(self, 'up_axis')
add_psk_export_properties(PSK_OT_export_collection)
class PSK_OT_export(Operator, ExportHelper): class PSK_OT_export(Operator, ExportHelper):
bl_idname = 'export.psk' bl_idname = 'export.psk'
bl_label = 'Export' bl_label = 'Export'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
__doc__ = 'Export mesh and armature to PSK' bl_description = 'Export mesh and armature to PSK'
filename_ext = '.psk' filename_ext = '.psk'
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'}) filter_glob: StringProperty(default='*.psk', options={'HIDDEN'})
filepath: StringProperty( filepath: StringProperty(
name='File Path', name='File Path',
description='File path used for exporting the PSK file', description='File path used for exporting the PSK file',
@@ -176,12 +336,18 @@ class PSK_OT_export(Operator, ExportHelper):
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
if len(input_objects.mesh_objects) == 0:
self.report({'ERROR_INVALID_CONTEXT'}, 'No mesh objects selected')
return {'CANCELLED'}
pg = getattr(context.scene, 'psk_export') pg = getattr(context.scene, 'psk_export')
populate_bone_collection_list(input_objects.armature_object, pg.bone_collection_list) populate_bone_collection_list(input_objects.armature_object, pg.bone_collection_list)
depsgraph = context.evaluated_depsgraph_get()
try: try:
populate_material_list(input_objects.mesh_objects, pg.material_list) populate_material_name_list(depsgraph, [x.obj for x in input_objects.mesh_objects], pg.material_name_list)
except RuntimeError as e: except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
@@ -190,15 +356,6 @@ class PSK_OT_export(Operator, ExportHelper):
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
@classmethod
def poll(cls, context):
try:
get_psk_input_objects_for_context(context)
except RuntimeError as e:
cls.poll_message_set(str(e))
return False
return True
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
@@ -217,28 +374,19 @@ class PSK_OT_export(Operator, ExportHelper):
bones_header, bones_panel = layout.panel('Bones', default_closed=False) bones_header, bones_panel = layout.panel('Bones', default_closed=False)
bones_header.label(text='Bones', icon='BONE_DATA') bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel: if bones_panel:
bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static draw_bone_filter_mode(bones_panel, pg)
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_COLLECTIONS': if pg.bone_filter_mode == 'BONE_COLLECTIONS':
row = bones_panel.row() row = bones_panel.row()
rows = max(3, min(len(pg.bone_collection_list), 10)) 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) row.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', rows=rows)
bones_panel.prop(pg, 'should_enforce_bone_name_restrictions')
# MATERIALS # MATERIALS
materials_header, materials_panel = layout.panel('Materials', default_closed=False) materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials', icon='MATERIAL') materials_header.label(text='Materials', icon='MATERIAL')
if materials_panel: if materials_panel:
row = materials_panel.row() row = materials_panel.row()
rows = max(3, min(len(pg.bone_collection_list), 10)) 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) row.template_list('PSK_UL_material_names', '', pg, 'material_name_list', pg, 'material_name_list_index', rows=rows)
col = row.column(align=True) col = row.column(align=True)
col.operator(PSK_OT_material_list_move_up.bl_idname, text='', icon='TRIA_UP') col.operator(PSK_OT_material_list_move_up.bl_idname, text='', icon='TRIA_UP')
col.operator(PSK_OT_material_list_move_down.bl_idname, text='', icon='TRIA_DOWN') col.operator(PSK_OT_material_list_move_down.bl_idname, text='', icon='TRIA_DOWN')
@@ -247,14 +395,8 @@ class PSK_OT_export(Operator, ExportHelper):
pg = getattr(context.scene, 'psk_export') pg = getattr(context.scene, 'psk_export')
input_objects = get_psk_input_objects_for_context(context) input_objects = get_psk_input_objects_for_context(context)
options = get_psk_build_options_from_property_group([x.obj for x in input_objects.mesh_objects], pg)
options = PskBuildOptions()
options.bone_filter_mode = pg.bone_filter_mode
options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected]
options.object_eval_state = pg.object_eval_state
options.materials = [m.material for m in pg.material_list]
options.should_enforce_bone_name_restrictions = pg.should_enforce_bone_name_restrictions
try: try:
result = build_psk(context, input_objects, options) result = build_psk(context, input_objects, options)
for warning in result.warnings: for warning in result.warnings:
@@ -267,7 +409,7 @@ class PSK_OT_export(Operator, ExportHelper):
except RuntimeError as e: except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
return {'FINISHED'} return {'FINISHED'}
@@ -276,4 +418,8 @@ classes = (
PSK_OT_material_list_move_down, PSK_OT_material_list_move_down,
PSK_OT_export, PSK_OT_export,
PSK_OT_export_collection, PSK_OT_export_collection,
PSK_OT_populate_bone_collection_list,
PSK_OT_populate_material_name_list,
PSK_OT_material_list_name_move_up,
PSK_OT_material_list_name_move_down,
) )

View File

@@ -1,50 +1,126 @@
from bpy.props import EnumProperty, CollectionProperty, IntProperty, BoolProperty, PointerProperty from bpy.props import EnumProperty, CollectionProperty, IntProperty, PointerProperty, FloatProperty, StringProperty, \
BoolProperty
from bpy.types import PropertyGroup, Material from bpy.types import PropertyGroup, Material
from ...shared.data import bone_filter_mode_items
from ...shared.types import PSX_PG_bone_collection_list_item from ...shared.types import PSX_PG_bone_collection_list_item
empty_set = set() empty_set = set()
object_eval_state_items = ( object_eval_state_items = (
('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'), ('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'),
('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'), ('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'),
) )
export_space_items = [
('WORLD', 'World', 'Export in world space'),
('ARMATURE', 'Armature', 'Export in armature space'),
]
axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z')
forward_items = (
('X', 'X Forward', ''),
('Y', 'Y Forward', ''),
('Z', 'Z Forward', ''),
('-X', '-X Forward', ''),
('-Y', '-Y Forward', ''),
('-Z', '-Z Forward', ''),
)
up_items = (
('X', 'X Up', ''),
('Y', 'Y Up', ''),
('Z', 'Z Up', ''),
('-X', '-X Up', ''),
('-Y', '-Y Up', ''),
('-Z', '-Z Up', ''),
)
class PSK_PG_material_list_item(PropertyGroup): class PSK_PG_material_list_item(PropertyGroup):
material: PointerProperty(type=Material) material: PointerProperty(type=Material)
index: IntProperty() index: IntProperty()
class PSK_PG_material_name_list_item(PropertyGroup):
material_name: StringProperty()
index: IntProperty()
def forward_axis_update(self, _context):
if self.forward_axis == self.up_axis:
# Automatically set the up axis to the next available axis
self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z')
def up_axis_update(self, _context):
if self.up_axis == self.forward_axis:
# Automatically set the forward axis to the next available axis
self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X')
# In order to share the same properties between the PSA and PSK export properties, we need to define the properties in a
# separate function and then apply them to the classes. This is because the collection exporter cannot have
# PointerProperties, so we must effectively duplicate the storage of the properties.
def add_psk_export_properties(cls):
cls.__annotations__['object_eval_state'] = EnumProperty(
items=object_eval_state_items,
name='Object Evaluation State',
default='EVALUATED'
)
cls.__annotations__['should_exclude_hidden_meshes'] = BoolProperty(
default=False,
name='Visible Only',
description='Export only visible meshes'
)
cls.__annotations__['scale'] = FloatProperty(
name='Scale',
default=1.0,
description='Scale factor to apply to the exported mesh and armature',
min=0.0001,
soft_max=100.0
)
cls.__annotations__['export_space'] = EnumProperty(
name='Export Space',
description='Space to export the mesh in',
items=export_space_items,
default='WORLD'
)
cls.__annotations__['bone_filter_mode'] = EnumProperty(
name='Bone Filter',
options=empty_set,
description='',
items=bone_filter_mode_items,
)
cls.__annotations__['bone_collection_list'] = CollectionProperty(type=PSX_PG_bone_collection_list_item)
cls.__annotations__['bone_collection_list_index'] = IntProperty(default=0)
cls.__annotations__['forward_axis'] = EnumProperty(
name='Forward',
items=forward_items,
default='X',
update=forward_axis_update
)
cls.__annotations__['up_axis'] = EnumProperty(
name='Up',
items=up_items,
default='Z',
update=up_axis_update
)
cls.__annotations__['material_name_list'] = CollectionProperty(type=PSK_PG_material_name_list_item)
cls.__annotations__['material_name_list_index'] = IntProperty(default=0)
class PSK_PG_export(PropertyGroup): class PSK_PG_export(PropertyGroup):
bone_filter_mode: EnumProperty( pass
name='Bone Filter',
options=empty_set,
description='', add_psk_export_properties(PSK_PG_export)
items=(
('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_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
bone_collection_list_index: IntProperty(default=0)
object_eval_state: EnumProperty(
items=object_eval_state_items,
name='Object Evaluation State',
default='EVALUATED'
)
material_list: CollectionProperty(type=PSK_PG_material_list_item)
material_list_index: IntProperty(default=0)
should_enforce_bone_name_restrictions: BoolProperty(
default=False,
name='Enforce Bone Name Restrictions',
description='Enforce that bone names must only contain letters, numbers, spaces, hyphens and underscores.\n\n'
'Depending on the engine, improper bone names might not be referenced correctly by scripts'
)
classes = ( classes = (
PSK_PG_material_list_item, PSK_PG_material_list_item,
PSK_PG_material_name_list_item,
PSK_PG_export, PSK_PG_export,
) )

View File

@@ -1,12 +1,14 @@
import bpy
from bpy.types import UIList from bpy.types import UIList
class PSK_UL_materials(UIList): class PSK_UL_material_names(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row() row = layout.row()
row.prop(item.material, 'name', text='', emboss=False, icon_value=layout.icon(item.material)) material = bpy.data.materials.get(item.material_name, None)
row.prop(item, 'material_name', text='', emboss=False, icon_value=layout.icon(material) if material else 0)
classes = ( classes = (
PSK_UL_materials, PSK_UL_material_names,
) )

View File

@@ -27,7 +27,7 @@ class PSK_OT_import(Operator, ImportHelper):
bl_idname = 'import_scene.psk' bl_idname = 'import_scene.psk'
bl_label = 'Import' bl_label = 'Import'
bl_options = {'INTERNAL', 'UNDO', 'PRESET'} bl_options = {'INTERNAL', 'UNDO', 'PRESET'}
__doc__ = 'Load a PSK file' bl_description = 'Import a PSK file'
filename_ext = '.psk' filename_ext = '.psk'
filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'}) filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'})
filepath: StringProperty( filepath: StringProperty(
@@ -152,6 +152,7 @@ class PSK_OT_import(Operator, ImportHelper):
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(self, 'scale') col.prop(self, 'scale')
col.prop(self, 'export_space')
mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False) mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False)
mesh_header.prop(self, 'should_import_mesh') mesh_header.prop(self, 'should_import_mesh')

View File

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

View File

@@ -36,41 +36,43 @@ def read_psk(path: str) -> Psk:
while fp.read(1): while fp.read(1):
fp.seek(-1, 1) fp.seek(-1, 1)
section = Section.from_buffer_copy(fp.read(ctypes.sizeof(Section))) section = Section.from_buffer_copy(fp.read(ctypes.sizeof(Section)))
if section.name == b'ACTRHEAD': match section.name:
pass case b'ACTRHEAD':
elif section.name == b'PNTS0000': pass
_read_types(fp, Vector3, section, psk.points) case b'PNTS0000':
elif section.name == b'VTXW0000': _read_types(fp, Vector3, section, psk.points)
if section.data_size == ctypes.sizeof(Psk.Wedge16): case b'VTXW0000':
_read_types(fp, Psk.Wedge16, section, psk.wedges) if section.data_size == ctypes.sizeof(Psk.Wedge16):
elif section.data_size == ctypes.sizeof(Psk.Wedge32): _read_types(fp, Psk.Wedge16, section, psk.wedges)
_read_types(fp, Psk.Wedge32, section, psk.wedges) elif section.data_size == ctypes.sizeof(Psk.Wedge32):
else: _read_types(fp, Psk.Wedge32, section, psk.wedges)
raise RuntimeError('Unrecognized wedge format') else:
elif section.name == b'FACE0000': raise RuntimeError('Unrecognized wedge format')
_read_types(fp, Psk.Face, section, psk.faces) case b'FACE0000':
elif section.name == b'MATT0000': _read_types(fp, Psk.Face, section, psk.faces)
_read_types(fp, Psk.Material, section, psk.materials) case b'MATT0000':
elif section.name == b'REFSKELT': _read_types(fp, Psk.Material, section, psk.materials)
_read_types(fp, Psk.Bone, section, psk.bones) case b'REFSKELT':
elif section.name == b'RAWWEIGHTS': _read_types(fp, Psk.Bone, section, psk.bones)
_read_types(fp, Psk.Weight, section, psk.weights) case b'RAWWEIGHTS':
elif section.name == b'FACE3200': _read_types(fp, Psk.Weight, section, psk.weights)
_read_types(fp, Psk.Face32, section, psk.faces) case b'FACE3200':
elif section.name == b'VERTEXCOLOR': _read_types(fp, Psk.Face32, section, psk.faces)
_read_types(fp, Color, section, psk.vertex_colors) case b'VERTEXCOLOR':
elif section.name.startswith(b'EXTRAUVS'): _read_types(fp, Color, section, psk.vertex_colors)
_read_types(fp, Vector2, section, psk.extra_uvs) case b'VTXNORMS':
elif section.name == b'VTXNORMS': _read_types(fp, Vector3, section, psk.vertex_normals)
_read_types(fp, Vector3, section, psk.vertex_normals) case b'MRPHINFO':
elif section.name == b'MRPHINFO': _read_types(fp, Psk.MorphInfo, section, psk.morph_infos)
_read_types(fp, Psk.MorphInfo, section, psk.morph_infos) case b'MRPHDATA':
elif section.name == b'MRPHDATA': _read_types(fp, Psk.MorphData, section, psk.morph_data)
_read_types(fp, Psk.MorphData, section, psk.morph_data) case _:
else: if section.name.startswith(b'EXTRAUVS'):
# Section is not handled, skip it. _read_types(fp, Vector2, section, psk.extra_uvs)
fp.seek(section.data_size * section.data_count, os.SEEK_CUR) else:
warnings.warn(f'Unrecognized section "{section.name} at position {fp.tell():15}"') # 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}"')
''' '''
UEViewer exports a sidecar file (*.props.txt) with fully-qualified reference paths for each material UEViewer exports a sidecar file (*.props.txt) with fully-qualified reference paths for each material

View File

@@ -93,3 +93,9 @@ class Section(Structure):
def __init__(self, *args, **kw): def __init__(self, *args, **kw):
super().__init__(*args, **kw) super().__init__(*args, **kw)
self.type_flags = 1999801 self.type_flags = 1999801
bone_filter_mode_items = (
('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')
)

View File

@@ -0,0 +1,155 @@
'''
Depth-first object iterator functions for Blender collections and view layers.
These functions are used to iterate over objects in a collection or view layer in a depth-first manner, including
instances. This is useful for exporters that need to traverse the object hierarchy in a predictable order.
'''
from typing import Optional, Set, Iterable, List
from bpy.types import Collection, Object, ViewLayer, LayerCollection
from mathutils import Matrix
class DfsObject:
'''
Represents an object in a depth-first search.
'''
def __init__(self, obj: Object, instance_objects: List[Object], matrix_world: Matrix):
self.obj = obj
self.instance_objects = instance_objects
self.matrix_world = matrix_world
@property
def is_visible(self) -> bool:
'''
Check if the object is visible.
@return: True if the object is visible, False otherwise.
'''
if self.instance_objects:
return self.instance_objects[-1].visible_get()
return self.obj.visible_get()
@property
def is_selected(self) -> bool:
'''
Check if the object is selected.
@return: True if the object is selected, False otherwise.
'''
if self.instance_objects:
return self.instance_objects[-1].select_get()
return self.obj.select_get()
def _dfs_object_children(obj: Object, collection: Collection) -> Iterable[Object]:
'''
Construct a list of objects in hierarchy order from `collection.objects`, only keeping those that are in the
collection.
@param obj: The object to start the search from.
@param collection: The collection to search in.
@return: An iterable of objects in hierarchy order.
'''
yield obj
for child in obj.children:
if child.name in collection.objects:
yield from _dfs_object_children(child, collection)
def dfs_objects_in_collection(collection: Collection) -> Iterable[Object]:
'''
Returns a depth-first iterator over all objects in a collection, only keeping those that are directly in the
collection.
@param collection: The collection to search in.
@return: An iterable of objects in hierarchy order.
'''
objects_hierarchy = []
for obj in collection.objects:
if obj.parent is None or obj.parent not in set(collection.objects):
objects_hierarchy.append(obj)
for obj in objects_hierarchy:
yield from _dfs_object_children(obj, collection)
def dfs_collection_objects(collection: Collection, visible_only: bool = False) -> Iterable[DfsObject]:
'''
Depth-first search of objects in a collection, including recursing into instances.
@param collection: The collection to search in.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix.
'''
yield from _dfs_collection_objects_recursive(collection)
def _dfs_collection_objects_recursive(
collection: Collection,
instance_objects: Optional[List[Object]] = None,
matrix_world: Matrix = Matrix.Identity(4),
visited: Optional[Set[Object]]=None
) -> Iterable[DfsObject]:
'''
Depth-first search of objects in a collection, including recursing into instances.
This is a recursive function.
@param collection: The collection to search in.
@param instance_objects: The running hierarchy of instance objects.
@param matrix_world: The world matrix of the current object.
@param visited: A set of visited object-instance pairs.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix.
'''
# We want to also yield the top-level instance object so that callers can inspect the selection status etc.
if visited is None:
visited = set()
if instance_objects is None:
instance_objects = list()
# First, yield all objects in child collections.
for child in collection.children:
yield from _dfs_collection_objects_recursive(child, instance_objects, matrix_world.copy(), visited)
# Then, evaluate all objects in this collection.
for obj in dfs_objects_in_collection(collection):
visited_pair = (obj, instance_objects[-1] if instance_objects else None)
if visited_pair in visited:
continue
# If this an instance, we need to recurse into it.
if obj.instance_collection is not None:
# Calculate the instance transform.
instance_offset_matrix = Matrix.Translation(-obj.instance_collection.instance_offset)
# Recurse into the instance collection.
yield from _dfs_collection_objects_recursive(obj.instance_collection,
instance_objects + [obj],
matrix_world @ (obj.matrix_world @ instance_offset_matrix),
visited)
else:
# Object is not an instance, yield it.
yield DfsObject(obj, instance_objects, matrix_world @ obj.matrix_world)
visited.add(visited_pair)
def dfs_view_layer_objects(view_layer: ViewLayer) -> Iterable[DfsObject]:
'''
Depth-first iterator over all objects in a view layer, including recursing into instances.
@param view_layer: The view layer to inspect.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix.
'''
visited = set()
def layer_collection_objects_recursive(layer_collection: LayerCollection):
for child in layer_collection.children:
yield from layer_collection_objects_recursive(child)
# Iterate only the top-level objects in this collection first.
yield from _dfs_collection_objects_recursive(layer_collection.collection, visited=visited)
yield from layer_collection_objects_recursive(view_layer.layer_collection)
def _is_dfs_object_visible(obj: Object, instance_objects: List[Object]) -> bool:
'''
Check if a DFS object is visible.
@param obj: The object.
@param instance_objects: The instance objects.
@return: True if the object is visible, False otherwise.
'''
if instance_objects:
return instance_objects[-1].visible_get()
return obj.visible_get()

View File

@@ -1,9 +1,9 @@
import re from typing import List, Iterable, cast, Tuple
import typing
from typing import List, Iterable
import bpy.types import bpy
from bpy.types import NlaStrip, Object, AnimData from bpy.props import CollectionProperty
from bpy.types import AnimData, Object
from bpy.types import Armature
def rgb_to_srgb(c: float): def rgb_to_srgb(c: float):
@@ -13,10 +13,9 @@ def rgb_to_srgb(c: float):
return 12.92 * c return 12.92 * c
def get_nla_strips_in_frame_range(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):
if animation_data is None: if animation_data is None:
return [] return
strips = []
for nla_track in animation_data.nla_tracks: for nla_track in animation_data.nla_tracks:
if nla_track.mute: if nla_track.mute:
continue continue
@@ -24,11 +23,10 @@ def get_nla_strips_in_frame_range(animation_data: AnimData, frame_min: float, fr
if (strip.frame_start < frame_min and strip.frame_end > frame_max) or \ if (strip.frame_start < frame_min and strip.frame_end > frame_max) or \
(frame_min <= strip.frame_start < frame_max) or \ (frame_min <= strip.frame_start < frame_max) or \
(frame_min < strip.frame_end <= frame_max): (frame_min < strip.frame_end <= frame_max):
strips.append(strip) yield strip
return strips
def populate_bone_collection_list(armature_object: Object, bone_collection_list: bpy.props.CollectionProperty) -> None: def populate_bone_collection_list(armature_object: Object, bone_collection_list: CollectionProperty) -> None:
""" """
Updates the bone collections collection. Updates the bone collections collection.
@@ -53,7 +51,7 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
bone_collection_list.clear() bone_collection_list.clear()
armature = armature_object.data armature = cast(Armature, armature_object.data)
if armature is None: if armature is None:
return return
@@ -65,7 +63,7 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
item.count = sum(map(lambda bone: 1 if len(bone.collections) == 0 else 0, armature.bones)) item.count = sum(map(lambda bone: 1 if len(bone.collections) == 0 else 0, armature.bones))
item.is_selected = unassigned_collection_is_selected item.is_selected = unassigned_collection_is_selected
for bone_collection_index, bone_collection in enumerate(armature.collections): for bone_collection_index, bone_collection in enumerate(armature.collections_all):
item = bone_collection_list.add() item = bone_collection_list.add()
item.name = bone_collection.name item.name = bone_collection.name
item.index = bone_collection_index item.index = bone_collection_index
@@ -73,16 +71,7 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
item.is_selected = bone_collection.name in selected_assigned_collection_names if has_selected_collections else True 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]): def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_collection_indices: Iterable[int]) -> List[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.')
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. Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone collections.
@@ -90,20 +79,20 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
:param armature_object: Blender object with type 'ARMATURE' :param armature_object: Blender object with type 'ARMATURE'
:param bone_filter_mode: One of ['ALL', 'BONE_COLLECTIONS'] :param bone_filter_mode: One of ['ALL', 'BONE_COLLECTIONS']
:param bone_collection_indices: List of bone collection indices to be exported. :param bone_collection_indices: A list of bone collection indices to export.
:return: A sorted list of bone indices that should be exported. :return: A sorted list of bone indices that should be exported.
""" """
if armature_object is None or armature_object.type != 'ARMATURE': if armature_object is None or armature_object.type != 'ARMATURE':
raise ValueError('An armature object must be supplied') raise ValueError('An armature object must be supplied')
armature_data = typing.cast(bpy.types.Armature, armature_object.data) armature_data = cast(Armature, armature_object.data)
bones = armature_data.bones bones = armature_data.bones
bone_names = [x.name for x in bones] bone_names = [x.name for x in bones]
# Get a list of the bone indices that we are explicitly including. # Get a list of the bone indices that we are explicitly including.
bone_index_stack = [] bone_index_stack = []
is_exporting_unassigned_bone_collections = -1 in bone_collection_indices is_exporting_unassigned_bone_collections = -1 in bone_collection_indices
bone_collections = list(armature_data.collections) bone_collections = list(armature_data.collections_all)
for bone_index, bone in enumerate(bones): for bone_index, bone in enumerate(bones):
# Check if this bone is in any of the collections in the bone collection indices list. # Check if this bone is in any of the collections in the bone collection indices list.
@@ -163,7 +152,59 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
return bone_names return bone_names
def is_bdk_addon_loaded(): def is_bdk_addon_loaded() -> bool:
# TODO: this does not work anymore for *reasons*. Just check if bpy.ops.bdk.link_material exists. return 'bdk' in dir(bpy.ops)
# return addon_utils.check('bdk_addon')[1]
return bpy.ops.bdk.link_material is not None
class SemanticVersion(object):
def __init__(self, version: Tuple[int, int, int]):
self.major, self.minor, self.patch = version
def __iter__(self):
yield self.major
yield self.minor
yield self.patch
@staticmethod
def compare(lhs: 'SemanticVersion', rhs: 'SemanticVersion') -> int:
"""
Compares two semantic versions.
Returns:
-1 if lhs < rhs
0 if lhs == rhs
1 if lhs > rhs
"""
for l, r in zip(lhs, rhs):
if l < r:
return -1
if l > r:
return 1
return 0
def __str__(self):
return f'{self.major}.{self.minor}.{self.patch}'
def __repr__(self):
return str(self)
def __eq__(self, other):
return self.compare(self, other) == 0
def __ne__(self, other):
return not self == other
def __lt__(self, other):
return self.compare(self, other) == -1
def __le__(self, other):
return self.compare(self, other) <= 0
def __gt__(self, other):
return self.compare(self, other) == 1
def __ge__(self, other):
return self.compare(self, other) >= 0
def __hash__(self):
return hash((self.major, self.minor, self.patch))

View File

@@ -0,0 +1,18 @@
from bpy.types import UILayout
from .data import bone_filter_mode_items
def is_bone_filter_mode_item_available(pg, identifier):
if identifier == 'BONE_COLLECTIONS' and len(pg.bone_collection_list) == 0:
return False
return True
def draw_bone_filter_mode(layout: UILayout, pg, should_always_show_bone_collections=False):
row = layout.row(align=True)
for item_identifier, _, _ 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 = should_always_show_bone_collections or is_bone_filter_mode_item_available(pg, identifier)