Compare commits
28 Commits
no-weight-
...
8.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1d5a2229d | ||
|
|
ff5ded004a | ||
|
|
ea2ecc6a5a | ||
|
|
ef559d9475 | ||
|
|
491e042cec | ||
|
|
9b0df1c942 | ||
|
|
79ea131f64 | ||
|
|
77dc4e5d50 | ||
|
|
526df424e3 | ||
|
|
ed42b2e227 | ||
|
|
42a859e24b | ||
|
|
e791859217 | ||
|
|
0dba7bb262 | ||
|
|
77cc97107e | ||
|
|
1f2ec4c76b | ||
|
|
ff74f47178 | ||
|
|
bdd35ef61d | ||
|
|
1c4967bd67 | ||
|
|
b5dba35ac4 | ||
|
|
7cc5cbe667 | ||
|
|
e1f0fc7e89 | ||
|
|
03c69783b3 | ||
|
|
da4960298b | ||
|
|
a9706d88a5 | ||
|
|
9dd02260d5 | ||
|
|
7ceaa88f1d | ||
|
|
37e246bf3e | ||
|
|
db93314fbc |
18
.github/workflows/main.yml
vendored
18
.github/workflows/main.yml
vendored
@@ -11,13 +11,19 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
- 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
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
sudo apt-get install libxxf86vm-dev -y
|
||||
@@ -35,14 +41,14 @@ jobs:
|
||||
echo "${{ github.workspace }}/${{ env.BLENDER_VERSION }}/" >> $GITHUB_PATH
|
||||
- name: Build extension
|
||||
run: |
|
||||
pushd ./io_scene_psk_psa
|
||||
pushd ./${{ env.ADDON_NAME }}
|
||||
blender --command extension build
|
||||
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
|
||||
- name: Archive addon
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: io_scene_psk_psa-${{ github.ref_name }}-${{ github.sha }}
|
||||
name: ${{ env.ADDON_NAME }}-${{ github.ref_name }}-${{ github.sha }}
|
||||
path: |
|
||||
./io_scene_psk_psa/artifact/*
|
||||
./${{ env.ADDON_NAME }}/artifact/*
|
||||
|
||||
@@ -6,6 +6,8 @@ if 'bpy' in locals():
|
||||
importlib.reload(shared_data)
|
||||
importlib.reload(shared_helpers)
|
||||
importlib.reload(shared_types)
|
||||
importlib.reload(shared_dfs)
|
||||
importlib.reload(shared_ui)
|
||||
|
||||
importlib.reload(psk_data)
|
||||
importlib.reload(psk_reader)
|
||||
@@ -33,6 +35,7 @@ if 'bpy' in locals():
|
||||
importlib.reload(psa_import_ui)
|
||||
else:
|
||||
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, \
|
||||
importer as psk_importer, properties as psk_properties
|
||||
from .psk import reader as psk_reader, ui as psk_ui
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
schema_version = "1.0.0"
|
||||
id = "io_scene_psk_psa"
|
||||
version = "7.1.0"
|
||||
version = "7.1.3"
|
||||
name = "Unreal PSK/PSA (.psk/.psa)"
|
||||
tagline = "Import and export PSK and PSA files used in Unreal Engine"
|
||||
maintainer = "Colin Basnett <cmbasnett@gmail.com>"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from bpy.types import Armature, Bone, Action, PoseBone
|
||||
from bpy.types import Bone, Action, PoseBone
|
||||
|
||||
from .data import *
|
||||
from ..shared.helpers import *
|
||||
@@ -13,7 +13,9 @@ class PsaBuildSequence:
|
||||
self.frame_start: 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.nla_state: PsaBuildSequence.NlaState = PsaBuildSequence.NlaState()
|
||||
self.compression_ratio: float = 1.0
|
||||
@@ -27,10 +29,10 @@ class PsaBuildOptions:
|
||||
self.sequences: List[PsaBuildSequence] = []
|
||||
self.bone_filter_mode: str = 'ALL'
|
||||
self.bone_collection_indices: List[int] = []
|
||||
self.should_enforce_bone_name_restrictions: bool = False
|
||||
self.sequence_name_prefix: str = ''
|
||||
self.sequence_name_suffix: str = ''
|
||||
self.root_motion: bool = False
|
||||
self.scale = 1.0
|
||||
|
||||
|
||||
def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions):
|
||||
@@ -49,6 +51,8 @@ def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: O
|
||||
location = pose_bone_matrix.to_translation()
|
||||
rotation = pose_bone_matrix.to_quaternion().normalized()
|
||||
|
||||
location *= options.scale
|
||||
|
||||
if pose_bone.parent is not None:
|
||||
rotation.conjugate()
|
||||
|
||||
@@ -68,9 +72,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
|
||||
# armature 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.
|
||||
export_bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_indices)
|
||||
@@ -78,16 +79,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.
|
||||
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.
|
||||
if len(bones) == 0:
|
||||
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.
|
||||
for bone in bones:
|
||||
psa_bone = Psa.Bone()
|
||||
@@ -145,8 +141,14 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
context.window_manager.progress_begin(0, len(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.
|
||||
options.animation_data.action = export_sequence.nla_state.action
|
||||
export_sequence.anim_data.action = export_sequence.nla_state.action
|
||||
context.view_layer.update()
|
||||
|
||||
frame_start = export_sequence.nla_state.frame_start
|
||||
@@ -155,7 +157,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
# Calculate the frame step based on the compression factor.
|
||||
frame_extents = abs(frame_end - frame_start)
|
||||
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:
|
||||
frame_step = frame_extents / (frame_count - 1)
|
||||
@@ -186,7 +188,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
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, armature_object, options)
|
||||
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options)
|
||||
|
||||
key = Psa.Key()
|
||||
key.location.x = location.x
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import re
|
||||
from configparser import ConfigParser
|
||||
from typing import Dict
|
||||
|
||||
from .reader import PsaReader
|
||||
from typing import Dict, List
|
||||
|
||||
REMOVE_TRACK_LOCATION = (1 << 0)
|
||||
REMOVE_TRACK_ROTATION = (1 << 1)
|
||||
@@ -28,7 +26,7 @@ def _load_config_file(file_path: str) -> ConfigParser:
|
||||
with open(file_path, 'r') as f:
|
||||
lines = f.read().split('\n')
|
||||
|
||||
lines = [re.sub(r'^\s*(\w+)\s*$', r'\1=', line) for line in lines]
|
||||
lines = [re.sub(r'^\s*([^=]+)\s*$', r'\1=', line) for line in lines]
|
||||
|
||||
contents = '\n'.join(lines)
|
||||
|
||||
@@ -50,7 +48,7 @@ def _get_bone_flags_from_value(value: str) -> int:
|
||||
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()
|
||||
|
||||
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.
|
||||
try:
|
||||
psa_sequence_names = list(psa_reader.sequences.keys())
|
||||
lowercase_sequence_names = [sequence_name.lower() for sequence_name in psa_sequence_names]
|
||||
sequence_name = psa_sequence_names[lowercase_sequence_names.index(sequence_name.lower())]
|
||||
except ValueError:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import List, Iterable, Dict, Tuple
|
||||
from typing import List, Iterable, Dict, Tuple, cast, Optional
|
||||
|
||||
import bpy
|
||||
from bpy.props import StringProperty
|
||||
@@ -8,10 +8,26 @@ from bpy.types import Context, Armature, Action, Object, AnimData, TimelineMarke
|
||||
from bpy_extras.io_utils import ExportHelper
|
||||
from bpy_types import Operator
|
||||
|
||||
from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences
|
||||
from .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 ..writer import write_psa
|
||||
from ...shared.helpers import populate_bone_collection_list, get_nla_strips_in_frame_range
|
||||
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):
|
||||
@@ -28,12 +44,13 @@ def is_action_for_armature(armature: Armature, action: Action):
|
||||
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')
|
||||
|
||||
# Clear actions and markers.
|
||||
pg.action_list.clear()
|
||||
pg.marker_list.clear()
|
||||
pg.active_action_list.clear()
|
||||
|
||||
# Get animation data.
|
||||
animation_data_object = get_animation_data_object(context)
|
||||
@@ -42,9 +59,11 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature):
|
||||
if animation_data is None:
|
||||
return
|
||||
|
||||
active_armature = cast(Armature, context.active_object.data)
|
||||
|
||||
# Populate actions list.
|
||||
for action in bpy.data.actions:
|
||||
if not is_action_for_armature(armature, action):
|
||||
if not is_action_for_armature(active_armature, action):
|
||||
continue
|
||||
|
||||
if action.name != '' and not action.name.startswith('#'):
|
||||
@@ -89,6 +108,21 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature):
|
||||
item.frame_start = frame_start
|
||||
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:
|
||||
match fps_source:
|
||||
@@ -119,19 +153,11 @@ def get_animation_data_object(context: Context) -> 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:
|
||||
# Timeline markers need to be sorted so that we can determine the sequence start and end positions.
|
||||
sequence_frame_ranges = dict()
|
||||
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:
|
||||
marker = context.scene.timeline_markers[marker_name]
|
||||
@@ -144,7 +170,7 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context:
|
||||
if next_marker_index < len(sorted_timeline_markers):
|
||||
# There is a next marker. Use that next marker's frame position as the last frame of this sequence.
|
||||
frame_end = sorted_timeline_markers[next_marker_index].frame
|
||||
nla_strips = get_nla_strips_in_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:
|
||||
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)))
|
||||
@@ -168,20 +194,6 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context:
|
||||
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]]:
|
||||
frame_start = int(action.frame_range[0])
|
||||
frame_end = int(action.frame_range[1])
|
||||
@@ -214,7 +226,7 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
bl_idname = 'psa_export.operator'
|
||||
bl_label = 'Export'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
__doc__ = 'Export actions to PSA'
|
||||
bl_description = 'Export actions to PSA'
|
||||
filename_ext = '.psa'
|
||||
filter_glob: StringProperty(default='*.psa', options={'HIDDEN'})
|
||||
filepath: StringProperty(
|
||||
@@ -223,7 +235,8 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
maxlen=1024,
|
||||
default='')
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.armature_object = None
|
||||
|
||||
@classmethod
|
||||
@@ -266,16 +279,11 @@ 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_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
|
||||
# ACTIONS
|
||||
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)
|
||||
from .ui import PSA_UL_export_sequences
|
||||
|
||||
propname, active_propname = get_sequences_propnames_from_source(pg.sequence_source)
|
||||
sequences_panel.template_list(PSA_UL_export_sequences.bl_idname, '', pg, propname, pg, active_propname,
|
||||
rows=max(3, min(len(getattr(pg, propname)), 10)))
|
||||
|
||||
flow = sequences_panel.grid_flow()
|
||||
flow.use_property_split = True
|
||||
@@ -302,7 +310,8 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
bones_header.label(text='Bones', icon='BONE_DATA')
|
||||
if bones_panel:
|
||||
row = bones_panel.row(align=True)
|
||||
row.prop(pg, 'bone_filter_mode', text='Bones')
|
||||
|
||||
draw_bone_filter_mode(row, pg)
|
||||
|
||||
if pg.bone_filter_mode == 'BONE_COLLECTIONS':
|
||||
row = bones_panel.row(align=True)
|
||||
@@ -313,20 +322,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',
|
||||
rows=rows)
|
||||
|
||||
flow = bones_panel.grid_flow()
|
||||
flow.use_property_split = True
|
||||
flow.use_property_decorate = False
|
||||
flow.prop(pg, 'should_enforce_bone_name_restrictions')
|
||||
# TRANSFORM
|
||||
transform_header, transform_panel = layout.panel('Advanced', default_closed=False)
|
||||
transform_header.label(text='Transform')
|
||||
|
||||
# ADVANCED
|
||||
advanced_header, advanced_panel = layout.panel('Advanced', default_closed=False)
|
||||
advanced_header.label(text='Advanced')
|
||||
|
||||
if advanced_panel:
|
||||
flow = advanced_panel.grid_flow()
|
||||
if transform_panel:
|
||||
flow = transform_panel.grid_flow()
|
||||
flow.use_property_split = True
|
||||
flow.use_property_decorate = False
|
||||
flow.prop(pg, 'root_motion', text='Root Motion')
|
||||
flow.prop(pg, 'scale', text='Scale')
|
||||
|
||||
@classmethod
|
||||
def _check_context(cls, context):
|
||||
@@ -334,7 +339,16 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
raise RuntimeError('An armature must be selected')
|
||||
|
||||
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}\')')
|
||||
|
||||
def invoke(self, context, _event):
|
||||
try:
|
||||
@@ -351,7 +365,7 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
# data created before (i.e. if no action was ever assigned to it).
|
||||
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)
|
||||
|
||||
@@ -379,42 +393,55 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
|
||||
export_sequences: List[PsaBuildSequence] = []
|
||||
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
selected_armature_objects = [obj for obj in context.selected_objects if obj.type == 'ARMATURE']
|
||||
|
||||
match pg.sequence_source:
|
||||
case 'ACTIONS':
|
||||
for action_item in filter(lambda x: x.is_selected, pg.action_list):
|
||||
if len(action_item.action.fcurves) == 0:
|
||||
continue
|
||||
export_sequence = PsaBuildSequence()
|
||||
export_sequence.nla_state.action = action_item.action
|
||||
export_sequence = PsaBuildSequence(context.active_object, animation_data)
|
||||
export_sequence.name = action_item.name
|
||||
export_sequence.nla_state.action = action_item.action
|
||||
export_sequence.nla_state.frame_start = action_item.frame_start
|
||||
export_sequence.nla_state.frame_end = action_item.frame_end
|
||||
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action_item.action])
|
||||
export_sequence.compression_ratio = action_item.action.psa_export.compression_ratio
|
||||
export_sequence.key_quota = action_item.action.psa_export.key_quota
|
||||
export_sequences.append(export_sequence)
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
case 'TIMELINE_MARKERS':
|
||||
for marker_item in filter(lambda x: x.is_selected, pg.marker_list):
|
||||
export_sequence = PsaBuildSequence()
|
||||
export_sequence = PsaBuildSequence(context.active_object, animation_data)
|
||||
export_sequence.name = marker_item.name
|
||||
export_sequence.nla_state.action = None
|
||||
export_sequence.nla_state.frame_start = marker_item.frame_start
|
||||
export_sequence.nla_state.frame_end = marker_item.frame_end
|
||||
nla_strips_actions = set(
|
||||
map(lambda x: x.action, get_nla_strips_in_frame_range(animation_data, marker_item.frame_start, marker_item.frame_end)))
|
||||
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, nla_strips_actions)
|
||||
export_sequences.append(export_sequence)
|
||||
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
|
||||
case 'NLA_TRACK_STRIPS':
|
||||
for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list):
|
||||
export_sequence = PsaBuildSequence()
|
||||
export_sequence = PsaBuildSequence(context.active_object, animation_data)
|
||||
export_sequence.name = nla_strip_item.name
|
||||
export_sequence.nla_state.action = None
|
||||
export_sequence.nla_state.frame_start = nla_strip_item.frame_start
|
||||
export_sequence.nla_state.frame_end = nla_strip_item.frame_end
|
||||
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [nla_strip_item.action])
|
||||
export_sequence.compression_ratio = nla_strip_item.action.psa_export.compression_ratio
|
||||
export_sequence.key_quota = nla_strip_item.action.psa_export.key_quota
|
||||
export_sequences.append(export_sequence)
|
||||
else:
|
||||
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 = action.psa_export.compression_ratio
|
||||
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()
|
||||
@@ -422,10 +449,10 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
options.sequences = export_sequences
|
||||
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.should_ignore_bone_name_restrictions = pg.should_enforce_bone_name_restrictions
|
||||
options.sequence_name_prefix = pg.sequence_name_prefix
|
||||
options.sequence_name_suffix = pg.sequence_name_suffix
|
||||
options.root_motion = pg.root_motion
|
||||
options.scale = pg.scale
|
||||
|
||||
try:
|
||||
psa = build_psa(context, options)
|
||||
@@ -448,12 +475,16 @@ class PSA_OT_export_actions_select_all(Operator):
|
||||
@classmethod
|
||||
def get_item_list(cls, context):
|
||||
pg = context.scene.psa_export
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
match pg.sequence_source:
|
||||
case 'ACTIONS':
|
||||
return pg.action_list
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
case 'TIMELINE_MARKERS':
|
||||
return pg.marker_list
|
||||
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
|
||||
case 'NLA_TRACK_STRIPS':
|
||||
return pg.nla_strip_list
|
||||
case 'ACTIVE_ACTION':
|
||||
return pg.active_action_list
|
||||
case _:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -481,12 +512,16 @@ class PSA_OT_export_actions_deselect_all(Operator):
|
||||
@classmethod
|
||||
def get_item_list(cls, context):
|
||||
pg = context.scene.psa_export
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
match pg.sequence_source:
|
||||
case 'ACTIONS':
|
||||
return pg.action_list
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
case 'TIMELINE_MARKERS':
|
||||
return pg.marker_list
|
||||
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
|
||||
case 'NLA_TRACK_STRIPS':
|
||||
return pg.nla_strip_list
|
||||
case 'ACTIVE_ACTION':
|
||||
return pg.active_action_list
|
||||
case _:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import re
|
||||
import sys
|
||||
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, \
|
||||
StringProperty
|
||||
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
|
||||
|
||||
|
||||
@@ -26,6 +27,15 @@ class PSA_PG_export_action_list_item(PropertyGroup):
|
||||
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
|
||||
marker_index: IntProperty()
|
||||
name: StringProperty()
|
||||
@@ -42,6 +52,20 @@ class PSA_PG_export_nla_strip_list_item(PropertyGroup):
|
||||
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:
|
||||
self.nla_strip_list.clear()
|
||||
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
|
||||
nla_track = animation_data.nla_tracks[self.nla_track_index]
|
||||
for nla_strip in nla_track.strips:
|
||||
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: PSA_PG_export_nla_strip_list_item = self.nla_strip_list.add()
|
||||
strip.action = nla_strip.action
|
||||
strip.name = nla_strip.name
|
||||
strip.frame_start = nla_strip.frame_start
|
||||
strip.frame_end = nla_strip.frame_end
|
||||
strip.name = sequence_name
|
||||
strip.frame_start = frame_start
|
||||
strip.frame_end = frame_end
|
||||
|
||||
|
||||
def get_animation_data(pg: 'PSA_PG_export', context: Context) -> Optional[AnimData]:
|
||||
@@ -69,8 +94,7 @@ def get_animation_data(pg: 'PSA_PG_export', context: Context) -> Optional[AnimDa
|
||||
def nla_track_search_cb(self, context: Context, edit_text: str):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
animation_data = get_animation_data(pg, context)
|
||||
if animation_data is None:
|
||||
return
|
||||
if animation_data is not None:
|
||||
for index, nla_track in enumerate(animation_data.nla_tracks):
|
||||
yield f'{index} - {nla_track.name}'
|
||||
|
||||
@@ -108,7 +132,8 @@ class PSA_PG_export(PropertyGroup):
|
||||
items=(
|
||||
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
|
||||
('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(
|
||||
@@ -137,24 +162,16 @@ class PSA_PG_export(PropertyGroup):
|
||||
marker_list_index: IntProperty(default=0)
|
||||
nla_strip_list: CollectionProperty(type=PSA_PG_export_nla_strip_list_item)
|
||||
nla_strip_list_index: IntProperty(default=0)
|
||||
active_action_list: CollectionProperty(type=PSA_PG_export_active_action_list_item)
|
||||
active_action_list_index: IntProperty(default=0)
|
||||
bone_filter_mode: EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('ALL', 'All', 'All bones will be exported.'),
|
||||
('BONE_COLLECTIONS', 'Bone Collections', 'Only bones belonging to the selected bone collections and their '
|
||||
'ancestors will be exported.'),
|
||||
)
|
||||
items=bone_filter_mode_items,
|
||||
)
|
||||
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
|
||||
bone_collection_list_index: IntProperty(default=0, name='', description='')
|
||||
should_enforce_bone_name_restrictions: BoolProperty(
|
||||
default=False,
|
||||
name='Enforce Bone Name Restrictions',
|
||||
description='Bone names restrictions will be enforced. Note that bone names without properly formatted names '
|
||||
'may not be able to be referenced in-engine'
|
||||
)
|
||||
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
|
||||
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)
|
||||
sequence_filter_name: StringProperty(
|
||||
@@ -183,6 +200,13 @@ class PSA_PG_export(PropertyGroup):
|
||||
name='Show Reversed',
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:
|
||||
@@ -222,5 +246,6 @@ classes = (
|
||||
PSA_PG_export_action_list_item,
|
||||
PSA_PG_export_timeline_markers,
|
||||
PSA_PG_export_nla_strip_list_item,
|
||||
PSA_PG_export_active_action_list_item,
|
||||
PSA_PG_export,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import cast
|
||||
import typing
|
||||
|
||||
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):
|
||||
bl_idname = 'PSA_UL_export_sequences'
|
||||
|
||||
def __init__(self):
|
||||
super(PSA_UL_export_sequences, self).__init__()
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(PSA_UL_export_sequences, self).__init__(*args, **kwargs)
|
||||
# Show the filtering options by default.
|
||||
self.use_filter_show = True
|
||||
|
||||
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
|
||||
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:
|
||||
@@ -26,6 +28,9 @@ class PSA_UL_export_sequences(UIList):
|
||||
if is_pose_marker:
|
||||
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):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
row = layout.row()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Operator, Event, Context, FileHandler
|
||||
from bpy.props import StringProperty, CollectionProperty
|
||||
from bpy.types import Operator, Event, Context, FileHandler, OperatorFileListElement, Object
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
|
||||
from .properties import get_visible_sequences
|
||||
@@ -112,6 +113,94 @@ def on_psa_file_path_updated(cls, context):
|
||||
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):
|
||||
bl_idname = 'psa_import.import'
|
||||
bl_label = 'Import'
|
||||
@@ -137,36 +226,13 @@ class PSA_OT_import(Operator, ImportHelper):
|
||||
|
||||
def execute(self, context):
|
||||
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]
|
||||
|
||||
if len(sequence_names) == 0:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected')
|
||||
return {'CANCELLED'}
|
||||
|
||||
options = PsaImportOptions()
|
||||
options.sequence_names = sequence_names
|
||||
options.should_use_fake_user = pg.should_use_fake_user
|
||||
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)
|
||||
result = _import_psa(context, pg, self.filepath, sequence_names, context.view_layer.objects.active)
|
||||
|
||||
if len(result.warnings) > 0:
|
||||
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.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.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
@@ -256,10 +327,48 @@ class PSA_OT_import(Operator, ImportHelper):
|
||||
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):
|
||||
bl_idname = 'PSA_FH_import'
|
||||
bl_label = 'File handler for Unreal PSA import'
|
||||
bl_import_operator = 'psa_import.import'
|
||||
bl_import_operator = 'psa_import.import_multiple'
|
||||
bl_export_operator = 'psa_export.export'
|
||||
bl_file_extensions = '.psa'
|
||||
|
||||
@@ -273,5 +382,6 @@ classes = (
|
||||
PSA_OT_import_sequences_deselect_all,
|
||||
PSA_OT_import_sequences_from_text,
|
||||
PSA_OT_import,
|
||||
PSA_OT_import_multiple,
|
||||
PSA_FH_import,
|
||||
)
|
||||
|
||||
@@ -103,6 +103,11 @@ class PSA_PG_import(PropertyGroup):
|
||||
soft_max=1.0,
|
||||
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]:
|
||||
|
||||
@@ -24,6 +24,7 @@ class PsaImportOptions(object):
|
||||
self.bone_mapping_mode = 'CASE_INSENSITIVE'
|
||||
self.fps_source = 'SEQUENCE'
|
||||
self.fps_custom: float = 30.0
|
||||
self.translation_scale: float = 1.0
|
||||
self.should_use_config_file = True
|
||||
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
|
||||
yield source_frame_count - 1
|
||||
|
||||
|
||||
def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step: float = 1.0) -> np.ndarray:
|
||||
"""
|
||||
Resamples the sequence data matrix to the target frame count.
|
||||
@@ -271,6 +273,10 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
|
||||
# Read the sequence data matrix from the PSA.
|
||||
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.
|
||||
for bone_index, import_bone in enumerate(import_bones):
|
||||
if import_bone is None:
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import typing
|
||||
from typing import Optional
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
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 .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 *
|
||||
|
||||
|
||||
class PskInputObjects(object):
|
||||
def __init__(self):
|
||||
self.mesh_objects = []
|
||||
self.mesh_objects: List[DfsObject] = []
|
||||
self.armature_object: Optional[Object] = None
|
||||
|
||||
|
||||
@@ -22,22 +24,51 @@ class PskBuildOptions(object):
|
||||
self.bone_collection_indices: List[int] = []
|
||||
self.object_eval_state = 'EVALUATED'
|
||||
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):
|
||||
for obj in collection.all_objects:
|
||||
if obj.type == 'MESH':
|
||||
yield obj
|
||||
def get_vector_from_axis_identifier(axis_identifier: str) -> Vector:
|
||||
match axis_identifier:
|
||||
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))
|
||||
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):
|
||||
for obj in context.view_layer.objects.selected:
|
||||
if obj.type == 'MESH':
|
||||
yield obj
|
||||
def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z') -> Matrix:
|
||||
forward = get_vector_from_axis_identifier(forward_axis)
|
||||
up = get_vector_from_axis_identifier(up_axis)
|
||||
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
|
||||
# object shared between all meshes.
|
||||
armature_modifier_objects = set()
|
||||
@@ -59,17 +90,14 @@ def get_armature_for_mesh_objects(mesh_objects: List[Object]) -> Optional[Object
|
||||
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:
|
||||
raise RuntimeError('At least one mesh must be selected')
|
||||
|
||||
for mesh_object in mesh_objects:
|
||||
if len(mesh_object.data.materials) == 0:
|
||||
raise RuntimeError(f'Mesh "{mesh_object.name}" must have at least one material')
|
||||
|
||||
input_objects = PskInputObjects()
|
||||
input_objects.mesh_objects = mesh_objects
|
||||
input_objects.armature_object = get_armature_for_mesh_objects(mesh_objects)
|
||||
input_objects.armature_object = get_armature_for_mesh_objects([x.obj for x in mesh_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)
|
||||
|
||||
|
||||
def get_psk_input_objects_for_collection(collection: Collection) -> PskInputObjects:
|
||||
mesh_objects = list(get_mesh_objects_for_collection(collection))
|
||||
def get_psk_input_objects_for_collection(collection: Collection, should_exclude_hidden_meshes: bool = True) -> PskInputObjects:
|
||||
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)
|
||||
|
||||
|
||||
@@ -97,6 +127,24 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
|
||||
psk = Psk()
|
||||
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 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.
|
||||
@@ -106,17 +154,13 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
|
||||
psk_bone.children_count = 0
|
||||
psk_bone.parent_index = 0
|
||||
psk_bone.location = Vector3.zero()
|
||||
psk_bone.rotation = Quaternion.identity()
|
||||
psk_bone.rotation = coordinate_system_default_rotation
|
||||
psk.bones.append(psk_bone)
|
||||
else:
|
||||
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_indices)
|
||||
armature_data = typing.cast(Armature, armature_object.data)
|
||||
bones = [armature_data.bones[bone_name] for bone_name in bone_names]
|
||||
|
||||
# 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:
|
||||
psk_bone = Psk.Bone()
|
||||
try:
|
||||
@@ -141,12 +185,30 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
|
||||
parent_tail = inverse_parent_rotation @ bone.parent.tail
|
||||
location = (parent_tail - parent_head) + bone.head
|
||||
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
|
||||
bone_rotation = bone.matrix.to_quaternion().conjugated()
|
||||
local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated()
|
||||
rotation = bone_rotation @ local_rotation
|
||||
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.y = location.y
|
||||
@@ -171,22 +233,53 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
|
||||
material.psk.mesh_triangle_bit_flags)
|
||||
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))
|
||||
|
||||
material_names = [m.name for m in options.materials]
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
match options.object_eval_state:
|
||||
case 'ORIGINAL':
|
||||
mesh_object = input_mesh_object
|
||||
mesh_data = input_mesh_object.data
|
||||
mesh_object = obj
|
||||
mesh_data = obj.data
|
||||
case 'EVALUATED':
|
||||
# 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()
|
||||
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('')
|
||||
bm.to_mesh(mesh_data)
|
||||
del bm
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
|
||||
# Restore the previous pose position on the armature.
|
||||
if old_pose_position is not None:
|
||||
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)
|
||||
matrix_world = scale_matrix @ export_space_matrix @ mesh_object.matrix_world
|
||||
|
||||
# VERTICES
|
||||
for vertex in mesh_data.vertices:
|
||||
point = Vector3()
|
||||
v = mesh_object.matrix_world @ vertex.co
|
||||
v = matrix_world @ vertex.co
|
||||
point.x = v.x
|
||||
point.y = v.y
|
||||
point.z = v.z
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
from typing import List
|
||||
from typing import List, Optional, cast, Iterable
|
||||
|
||||
import bpy
|
||||
from bpy.props import StringProperty, BoolProperty, EnumProperty
|
||||
from bpy.types import Operator, Context, Object
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Operator, Context, Object, Collection, SpaceProperties, Depsgraph, Material
|
||||
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, \
|
||||
get_psk_input_objects_for_collection
|
||||
from ..writer import write_psk
|
||||
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):
|
||||
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]):
|
||||
def get_materials_for_mesh_objects(depsgraph: Depsgraph, mesh_objects: Iterable[Object]):
|
||||
materials = []
|
||||
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
|
||||
if material is None:
|
||||
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)
|
||||
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()
|
||||
for index, material in enumerate(materials):
|
||||
m = material_list.add()
|
||||
m.material = material
|
||||
m.material_name = material.name
|
||||
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):
|
||||
bl_idname = 'psk_export.material_list_item_move_up'
|
||||
bl_label = 'Move Up'
|
||||
@@ -50,12 +108,12 @@ class PSK_OT_material_list_move_up(Operator):
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
return pg.material_list_index > 0
|
||||
return pg.material_name_list_index > 0
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
pg.material_list.move(pg.material_list_index, pg.material_list_index - 1)
|
||||
pg.material_list_index -= 1
|
||||
pg.material_name_list.move(pg.material_name_list_index, pg.material_name_list_index - 1)
|
||||
pg.material_name_list_index -= 1
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
@@ -68,15 +126,98 @@ class PSK_OT_material_list_move_down(Operator):
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
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):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
pg.material_list.move(pg.material_list_index, pg.material_list_index + 1)
|
||||
pg.material_list_index += 1
|
||||
pg.material_name_list.move(pg.material_name_list_index, pg.material_name_list_index + 1)
|
||||
pg.material_name_list_index += 1
|
||||
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):
|
||||
bl_idname = 'export.psk_collection'
|
||||
bl_label = 'Export'
|
||||
@@ -91,32 +232,16 @@ class PSK_OT_export_collection(Operator, ExportHelper):
|
||||
subtype='FILE_PATH')
|
||||
collection: StringProperty(options={'HIDDEN'})
|
||||
|
||||
object_eval_state: EnumProperty(
|
||||
items=object_eval_state_items,
|
||||
name='Object Evaluation State',
|
||||
default='EVALUATED'
|
||||
)
|
||||
should_enforce_bone_name_restrictions: BoolProperty(
|
||||
default=False,
|
||||
name='Enforce Bone Name Restrictions',
|
||||
description='Enforce that bone names must only contain letters, numbers, spaces, hyphens and underscores.\n\n'
|
||||
'Depending on the engine, improper bone names might not be referenced correctly by scripts'
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
collection = bpy.data.collections.get(self.collection)
|
||||
|
||||
try:
|
||||
input_objects = get_psk_input_objects_for_collection(collection)
|
||||
input_objects = get_psk_input_objects_for_collection(collection, self.should_exclude_hidden_meshes)
|
||||
except RuntimeError as e:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
options = PskBuildOptions()
|
||||
options.bone_filter_mode = 'ALL'
|
||||
options.object_eval_state = self.object_eval_state
|
||||
options.materials = get_materials_for_mesh_objects(input_objects.mesh_objects)
|
||||
options.should_enforce_bone_name_restrictions = self.should_enforce_bone_name_restrictions
|
||||
options = get_psk_build_options_from_property_group([x.obj for x in input_objects.mesh_objects], self)
|
||||
|
||||
try:
|
||||
result = build_psk(context, input_objects, options)
|
||||
@@ -136,6 +261,10 @@ class PSK_OT_export_collection(Operator, ExportHelper):
|
||||
def draw(self, context: Context):
|
||||
layout = self.layout
|
||||
|
||||
flow = layout.grid_flow(row_major=True)
|
||||
flow.use_property_split = True
|
||||
flow.use_property_decorate = False
|
||||
|
||||
# MESH
|
||||
mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False)
|
||||
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_decorate = False
|
||||
flow.prop(self, 'object_eval_state', text='Data')
|
||||
flow.prop(self, 'should_exclude_hidden_meshes')
|
||||
|
||||
# BONES
|
||||
bones_header, bones_panel = layout.panel('Bones', default_closed=False)
|
||||
bones_header.label(text='Bones', icon='BONE_DATA')
|
||||
if bones_panel:
|
||||
flow = bones_panel.grid_flow(row_major=True)
|
||||
draw_bone_filter_mode(bones_panel, self)
|
||||
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_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):
|
||||
bl_idname = 'export.psk'
|
||||
bl_label = 'Export'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
__doc__ = 'Export mesh and armature to PSK'
|
||||
bl_description = 'Export mesh and armature to PSK'
|
||||
filename_ext = '.psk'
|
||||
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'})
|
||||
|
||||
filepath: StringProperty(
|
||||
name='File Path',
|
||||
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))
|
||||
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')
|
||||
|
||||
populate_bone_collection_list(input_objects.armature_object, pg.bone_collection_list)
|
||||
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
|
||||
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:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
@@ -190,15 +356,6 @@ class PSK_OT_export(Operator, ExportHelper):
|
||||
|
||||
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):
|
||||
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.label(text='Bones', icon='BONE_DATA')
|
||||
if bones_panel:
|
||||
bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static
|
||||
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)
|
||||
|
||||
draw_bone_filter_mode(bones_panel, pg)
|
||||
if pg.bone_filter_mode == 'BONE_COLLECTIONS':
|
||||
row = bones_panel.row()
|
||||
rows = max(3, min(len(pg.bone_collection_list), 10))
|
||||
row.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', rows=rows)
|
||||
|
||||
bones_panel.prop(pg, 'should_enforce_bone_name_restrictions')
|
||||
|
||||
# MATERIALS
|
||||
materials_header, materials_panel = layout.panel('Materials', default_closed=False)
|
||||
materials_header.label(text='Materials', icon='MATERIAL')
|
||||
if materials_panel:
|
||||
row = materials_panel.row()
|
||||
rows = max(3, min(len(pg.bone_collection_list), 10))
|
||||
row.template_list('PSK_UL_materials', '', pg, 'material_list', pg, 'material_list_index', rows=rows)
|
||||
row.template_list('PSK_UL_material_names', '', pg, 'material_name_list', pg, 'material_name_list_index', rows=rows)
|
||||
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_down.bl_idname, text='', icon='TRIA_DOWN')
|
||||
@@ -247,13 +395,7 @@ class PSK_OT_export(Operator, ExportHelper):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
|
||||
input_objects = get_psk_input_objects_for_context(context)
|
||||
|
||||
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
|
||||
options = get_psk_build_options_from_property_group([x.obj for x in input_objects.mesh_objects], pg)
|
||||
|
||||
try:
|
||||
result = build_psk(context, input_objects, options)
|
||||
@@ -276,4 +418,8 @@ classes = (
|
||||
PSK_OT_material_list_move_down,
|
||||
PSK_OT_export,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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 ...shared.data import bone_filter_mode_items
|
||||
from ...shared.types import PSX_PG_bone_collection_list_item
|
||||
|
||||
empty_set = set()
|
||||
|
||||
|
||||
object_eval_state_items = (
|
||||
('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'),
|
||||
('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'),
|
||||
)
|
||||
|
||||
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):
|
||||
material: PointerProperty(type=Material)
|
||||
index: IntProperty()
|
||||
|
||||
class PSK_PG_material_name_list_item(PropertyGroup):
|
||||
material_name: StringProperty()
|
||||
index: IntProperty()
|
||||
|
||||
class PSK_PG_export(PropertyGroup):
|
||||
bone_filter_mode: EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=empty_set,
|
||||
description='',
|
||||
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(
|
||||
|
||||
|
||||
|
||||
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'
|
||||
)
|
||||
material_list: CollectionProperty(type=PSK_PG_material_list_item)
|
||||
material_list_index: IntProperty(default=0)
|
||||
should_enforce_bone_name_restrictions: BoolProperty(
|
||||
cls.__annotations__['should_exclude_hidden_meshes'] = 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'
|
||||
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):
|
||||
pass
|
||||
|
||||
|
||||
add_psk_export_properties(PSK_PG_export)
|
||||
|
||||
|
||||
classes = (
|
||||
PSK_PG_material_list_item,
|
||||
PSK_PG_material_name_list_item,
|
||||
PSK_PG_export,
|
||||
)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import bpy
|
||||
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):
|
||||
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 = (
|
||||
PSK_UL_materials,
|
||||
PSK_UL_material_names,
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ class PSK_OT_import(Operator, ImportHelper):
|
||||
bl_idname = 'import_scene.psk'
|
||||
bl_label = 'Import'
|
||||
bl_options = {'INTERNAL', 'UNDO', 'PRESET'}
|
||||
__doc__ = 'Load a PSK file'
|
||||
bl_description = 'Import a PSK file'
|
||||
filename_ext = '.psk'
|
||||
filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'})
|
||||
filepath: StringProperty(
|
||||
@@ -152,6 +152,7 @@ class PSK_OT_import(Operator, ImportHelper):
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(self, 'scale')
|
||||
col.prop(self, 'export_space')
|
||||
|
||||
mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False)
|
||||
mesh_header.prop(self, 'should_import_mesh')
|
||||
|
||||
@@ -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.
|
||||
# Attempt to load it using BDK addon's operator.
|
||||
material_reference = psk.material_references[material_index]
|
||||
if material_reference and bpy.ops.bdk.link_material(reference=material_reference, 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]
|
||||
else:
|
||||
# Just create a blank material.
|
||||
|
||||
@@ -36,37 +36,39 @@ def read_psk(path: str) -> Psk:
|
||||
while fp.read(1):
|
||||
fp.seek(-1, 1)
|
||||
section = Section.from_buffer_copy(fp.read(ctypes.sizeof(Section)))
|
||||
if section.name == b'ACTRHEAD':
|
||||
match section.name:
|
||||
case b'ACTRHEAD':
|
||||
pass
|
||||
elif section.name == b'PNTS0000':
|
||||
case b'PNTS0000':
|
||||
_read_types(fp, Vector3, section, psk.points)
|
||||
elif section.name == b'VTXW0000':
|
||||
case b'VTXW0000':
|
||||
if section.data_size == ctypes.sizeof(Psk.Wedge16):
|
||||
_read_types(fp, Psk.Wedge16, section, psk.wedges)
|
||||
elif section.data_size == ctypes.sizeof(Psk.Wedge32):
|
||||
_read_types(fp, Psk.Wedge32, section, psk.wedges)
|
||||
else:
|
||||
raise RuntimeError('Unrecognized wedge format')
|
||||
elif section.name == b'FACE0000':
|
||||
case b'FACE0000':
|
||||
_read_types(fp, Psk.Face, section, psk.faces)
|
||||
elif section.name == b'MATT0000':
|
||||
case b'MATT0000':
|
||||
_read_types(fp, Psk.Material, section, psk.materials)
|
||||
elif section.name == b'REFSKELT':
|
||||
case b'REFSKELT':
|
||||
_read_types(fp, Psk.Bone, section, psk.bones)
|
||||
elif section.name == b'RAWWEIGHTS':
|
||||
case b'RAWWEIGHTS':
|
||||
_read_types(fp, Psk.Weight, section, psk.weights)
|
||||
elif section.name == b'FACE3200':
|
||||
case b'FACE3200':
|
||||
_read_types(fp, Psk.Face32, section, psk.faces)
|
||||
elif section.name == b'VERTEXCOLOR':
|
||||
case b'VERTEXCOLOR':
|
||||
_read_types(fp, Color, section, psk.vertex_colors)
|
||||
elif section.name.startswith(b'EXTRAUVS'):
|
||||
_read_types(fp, Vector2, section, psk.extra_uvs)
|
||||
elif section.name == b'VTXNORMS':
|
||||
case b'VTXNORMS':
|
||||
_read_types(fp, Vector3, section, psk.vertex_normals)
|
||||
elif section.name == b'MRPHINFO':
|
||||
case b'MRPHINFO':
|
||||
_read_types(fp, Psk.MorphInfo, section, psk.morph_infos)
|
||||
elif section.name == b'MRPHDATA':
|
||||
case b'MRPHDATA':
|
||||
_read_types(fp, Psk.MorphData, section, psk.morph_data)
|
||||
case _:
|
||||
if section.name.startswith(b'EXTRAUVS'):
|
||||
_read_types(fp, Vector2, section, psk.extra_uvs)
|
||||
else:
|
||||
# Section is not handled, skip it.
|
||||
fp.seek(section.data_size * section.data_count, os.SEEK_CUR)
|
||||
|
||||
@@ -93,3 +93,9 @@ class Section(Structure):
|
||||
def __init__(self, *args, **kw):
|
||||
super().__init__(*args, **kw)
|
||||
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')
|
||||
)
|
||||
|
||||
155
io_scene_psk_psa/shared/dfs.py
Normal file
155
io_scene_psk_psa/shared/dfs.py
Normal 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()
|
||||
@@ -1,9 +1,9 @@
|
||||
import re
|
||||
import typing
|
||||
from typing import List, Iterable
|
||||
from typing import List, Iterable, cast
|
||||
|
||||
import bpy.types
|
||||
from bpy.types import NlaStrip, Object, AnimData
|
||||
import bpy
|
||||
from bpy.props import CollectionProperty
|
||||
from bpy.types import AnimData, Object
|
||||
from bpy.types import Armature
|
||||
|
||||
|
||||
def rgb_to_srgb(c: float):
|
||||
@@ -13,10 +13,9 @@ def rgb_to_srgb(c: float):
|
||||
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:
|
||||
return []
|
||||
strips = []
|
||||
return
|
||||
for nla_track in animation_data.nla_tracks:
|
||||
if nla_track.mute:
|
||||
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 \
|
||||
(frame_min <= strip.frame_start < frame_max) or \
|
||||
(frame_min < strip.frame_end <= frame_max):
|
||||
strips.append(strip)
|
||||
return strips
|
||||
yield strip
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -53,7 +51,7 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
|
||||
|
||||
bone_collection_list.clear()
|
||||
|
||||
armature = armature_object.data
|
||||
armature = cast(Armature, armature_object.data)
|
||||
|
||||
if armature is None:
|
||||
return
|
||||
@@ -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
|
||||
|
||||
|
||||
def check_bone_names(bone_names: Iterable[str]):
|
||||
pattern = re.compile(r'^[a-zA-Z\d_\- ]+$')
|
||||
invalid_bone_names = [x for x in bone_names if pattern.match(x) is None]
|
||||
if len(invalid_bone_names) > 0:
|
||||
raise RuntimeError(f'The following bone names are invalid: {invalid_bone_names}.\n'
|
||||
f'Bone names must only contain letters, numbers, spaces, hyphens and underscores.\n'
|
||||
f'You can bypass this by disabling "Enforce Bone Name Restrictions" in the export settings.')
|
||||
|
||||
|
||||
def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_collection_indices: List[int]) -> List[str]:
|
||||
def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_collection_indices: Iterable[int]) -> List[str]:
|
||||
"""
|
||||
Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone collections.
|
||||
|
||||
@@ -90,13 +79,13 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
|
||||
|
||||
:param armature_object: Blender object with type 'ARMATURE'
|
||||
: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.
|
||||
"""
|
||||
if armature_object is None or armature_object.type != 'ARMATURE':
|
||||
raise ValueError('An armature object must be supplied')
|
||||
|
||||
armature_data = typing.cast(bpy.types.Armature, armature_object.data)
|
||||
armature_data = cast(Armature, armature_object.data)
|
||||
bones = armature_data.bones
|
||||
bone_names = [x.name for x in bones]
|
||||
|
||||
@@ -163,7 +152,5 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
|
||||
return bone_names
|
||||
|
||||
|
||||
def is_bdk_addon_loaded():
|
||||
# TODO: this does not work anymore for *reasons*. Just check if bpy.ops.bdk.link_material exists.
|
||||
# return addon_utils.check('bdk_addon')[1]
|
||||
return bpy.ops.bdk.link_material is not None
|
||||
def is_bdk_addon_loaded() -> bool:
|
||||
return 'bdk' in dir(bpy.ops)
|
||||
|
||||
18
io_scene_psk_psa/shared/ui.py
Normal file
18
io_scene_psk_psa/shared/ui.py
Normal 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):
|
||||
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 = is_bone_filter_mode_item_available(pg, identifier)
|
||||
Reference in New Issue
Block a user