Compare commits
22 Commits
7.1.2
...
sampling-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b41815545 | ||
|
|
a83314c8b3 | ||
|
|
f8234b3892 | ||
|
|
35ac0bf86c | ||
|
|
515ee17203 | ||
|
|
0a5ebf4548 | ||
|
|
de1cf2316a | ||
|
|
c1d5a2229d | ||
|
|
ff5ded004a | ||
|
|
ea2ecc6a5a | ||
|
|
ef559d9475 | ||
|
|
491e042cec | ||
|
|
9b0df1c942 | ||
|
|
79ea131f64 | ||
|
|
77dc4e5d50 | ||
|
|
526df424e3 | ||
|
|
ed42b2e227 | ||
|
|
42a859e24b | ||
|
|
e791859217 | ||
|
|
0dba7bb262 | ||
|
|
77cc97107e | ||
|
|
1f2ec4c76b |
3
.github/workflows/main.yml
vendored
3
.github/workflows/main.yml
vendored
@@ -30,7 +30,8 @@ jobs:
|
||||
sudo apt-get install libxfixes3 -y
|
||||
sudo apt-get install libxi-dev -y
|
||||
sudo apt-get install libxkbcommon-x11-0 -y
|
||||
sudo apt-get install libgl1-mesa-glx -y
|
||||
sudo apt-get install libgl1 -y
|
||||
sudo apt-get install libglx-mesa0 -y
|
||||
- name: Download & Extract Blender
|
||||
run: |
|
||||
wget -q $BLENDER_URL
|
||||
|
||||
@@ -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.2"
|
||||
version = "8.0.0"
|
||||
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,7 @@
|
||||
from typing import Optional
|
||||
|
||||
from bpy.types import Armature, Bone, Action, PoseBone
|
||||
from bpy.types import Bone, Action, PoseBone
|
||||
from mathutils import Vector
|
||||
|
||||
from .data import *
|
||||
from ..shared.helpers import *
|
||||
@@ -13,7 +14,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 +30,11 @@ 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
|
||||
self.sampling_mode: str = 'INTERPOLATED' # One of ('INTERPOLATED', 'SUBFRAME')
|
||||
|
||||
|
||||
def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions):
|
||||
@@ -49,6 +53,8 @@ def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: O
|
||||
location = pose_bone_matrix.to_translation()
|
||||
rotation = pose_bone_matrix.to_quaternion().normalized()
|
||||
|
||||
location *= options.scale
|
||||
|
||||
if pose_bone.parent is not None:
|
||||
rotation.conjugate()
|
||||
|
||||
@@ -68,9 +74,6 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
# As a result, we need to reconstruct the list of pose bones in the same order as the
|
||||
# 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 +81,11 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
|
||||
# Make the bone lists contain only the bones that are going to be exported.
|
||||
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 +143,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 +159,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)
|
||||
@@ -182,24 +186,83 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
|
||||
frame = float(frame_start)
|
||||
|
||||
for _ in range(frame_count):
|
||||
context.scene.frame_set(frame=int(frame), subframe=frame % 1.0)
|
||||
def add_key(location: Vector, rotation: Quaternion):
|
||||
key = Psa.Key()
|
||||
key.location.x = location.x
|
||||
key.location.y = location.y
|
||||
key.location.z = location.z
|
||||
key.rotation.x = rotation.x
|
||||
key.rotation.y = rotation.y
|
||||
key.rotation.z = rotation.z
|
||||
key.rotation.w = rotation.w
|
||||
key.time = 1.0 / psa_sequence.fps
|
||||
psa.keys.append(key)
|
||||
|
||||
for pose_bone in pose_bones:
|
||||
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, armature_object, options)
|
||||
match options.sampling_mode:
|
||||
case 'INTERPOLATED':
|
||||
# Used as a store for the last frame's pose bone locations and rotations.
|
||||
last_frame: Optional[int] = None
|
||||
last_frame_bone_poses: List[Tuple[Vector, Quaternion]] = []
|
||||
|
||||
key = Psa.Key()
|
||||
key.location.x = location.x
|
||||
key.location.y = location.y
|
||||
key.location.z = location.z
|
||||
key.rotation.x = rotation.x
|
||||
key.rotation.y = rotation.y
|
||||
key.rotation.z = rotation.z
|
||||
key.rotation.w = rotation.w
|
||||
key.time = 1.0 / psa_sequence.fps
|
||||
psa.keys.append(key)
|
||||
next_frame: Optional[int] = None
|
||||
next_frame_bone_poses: List[Tuple[Vector, Quaternion]] = []
|
||||
|
||||
frame += frame_step
|
||||
for _ in range(frame_count):
|
||||
if last_frame is None or last_frame != int(frame):
|
||||
# Populate the bone poses for frame A.
|
||||
last_frame = int(frame)
|
||||
|
||||
# TODO: simplify this code and make it easier to follow!
|
||||
if next_frame == last_frame:
|
||||
# Simply transfer the data from next_frame to the last_frame so that we don't need to
|
||||
# resample anything.
|
||||
last_frame_bone_poses = next_frame_bone_poses.copy()
|
||||
else:
|
||||
last_frame_bone_poses.clear()
|
||||
context.scene.frame_set(frame=last_frame)
|
||||
for pose_bone in pose_bones:
|
||||
location, rotation = _get_pose_bone_location_and_rotation(pose_bone,
|
||||
export_sequence.armature_object,
|
||||
options)
|
||||
last_frame_bone_poses.append((location, rotation))
|
||||
|
||||
next_frame = None
|
||||
next_frame_bone_poses.clear()
|
||||
|
||||
# If this is not a subframe, just use the last frame's bone poses.
|
||||
if frame % 1.0 == 0:
|
||||
for i in range(len(pose_bones)):
|
||||
add_key(*last_frame_bone_poses[i])
|
||||
else:
|
||||
# Otherwise, this is a subframe, so we need to interpolate the pose between the next frame and the last frame.
|
||||
if next_frame is None:
|
||||
next_frame = last_frame + 1
|
||||
context.scene.frame_set(frame=next_frame)
|
||||
for pose_bone in pose_bones:
|
||||
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options)
|
||||
next_frame_bone_poses.append((location, rotation))
|
||||
|
||||
factor = frame % 1.0
|
||||
|
||||
for i in range(len(pose_bones)):
|
||||
last_location, last_rotation = last_frame_bone_poses[i]
|
||||
next_location, next_rotation = next_frame_bone_poses[i]
|
||||
|
||||
location = last_location.lerp(next_location, factor)
|
||||
rotation = last_rotation.slerp(next_rotation, factor)
|
||||
|
||||
add_key(location, rotation)
|
||||
|
||||
frame += frame_step
|
||||
case 'SUBFRAME':
|
||||
for _ in range(frame_count):
|
||||
context.scene.frame_set(frame=int(frame), subframe=frame % 1.0)
|
||||
|
||||
for pose_bone in pose_bones:
|
||||
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options)
|
||||
add_key(location, rotation)
|
||||
|
||||
frame += frame_step
|
||||
|
||||
frame_start_index += frame_count
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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,32 +7,62 @@ 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.helpers import populate_bone_collection_list, get_nla_strips_in_frame_range, SemanticVersion
|
||||
from ...shared.ui import draw_bone_filter_mode
|
||||
|
||||
|
||||
def get_sequences_propnames_from_source(sequence_source: str) -> Optional[Tuple[str, str]]:
|
||||
match sequence_source:
|
||||
case 'ACTIONS':
|
||||
return 'action_list', 'action_list_index'
|
||||
case 'TIMELINE_MARKERS':
|
||||
return 'marker_list', 'marker_list_index'
|
||||
case 'NLA_TRACK_STRIPS':
|
||||
return 'nla_strip_list', 'nla_strip_list_index'
|
||||
case 'ACTIVE_ACTION':
|
||||
return 'active_action_list', 'active_action_list_index'
|
||||
case _:
|
||||
raise ValueError(f'Unhandled sequence source: {sequence_source}')
|
||||
|
||||
|
||||
def is_action_for_armature(armature: Armature, action: Action):
|
||||
if len(action.fcurves) == 0:
|
||||
return False
|
||||
bone_names = set([x.name for x in armature.bones])
|
||||
for fcurve in action.fcurves:
|
||||
match = re.match(r'pose\.bones\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path)
|
||||
if not match:
|
||||
continue
|
||||
bone_name = match.group(1)
|
||||
if bone_name in bone_names:
|
||||
return True
|
||||
|
||||
version = SemanticVersion(bpy.app.version)
|
||||
|
||||
if version < SemanticVersion((4, 4, 0)):
|
||||
import re
|
||||
bone_names = set([x.name for x in armature.bones])
|
||||
for fcurve in action.fcurves:
|
||||
match = re.match(r'pose\.bones\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path)
|
||||
if not match:
|
||||
continue
|
||||
bone_name = match.group(1)
|
||||
if bone_name in bone_names:
|
||||
return True
|
||||
else:
|
||||
# Look up the armature by ID and check if its data block pointer matches the armature.
|
||||
for slot in filter(lambda x: x.id_root == 'OBJECT', action.slots):
|
||||
# Lop off the 'OB' prefix from the identifier for the lookup.
|
||||
object = bpy.data.objects.get(slot.identifier[2:], None)
|
||||
if object and object.data == armature:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
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 +71,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 +120,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:
|
||||
@@ -103,6 +149,17 @@ def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actio
|
||||
raise RuntimeError(f'Invalid FPS source "{fps_source}"')
|
||||
|
||||
|
||||
def get_sequence_compression_ratio(compression_ratio_source: str, compression_ratio_custom: float, actions: Iterable[Action]) -> float:
|
||||
match compression_ratio_source:
|
||||
case 'ACTION_METADATA':
|
||||
# Get the minimum value of action metadata compression ratio values.
|
||||
return min([action.psa_export.compression_ratio for action in actions])
|
||||
case 'CUSTOM':
|
||||
return compression_ratio_custom
|
||||
case _:
|
||||
raise RuntimeError(f'Invalid compression ratio source "{compression_ratio_source}"')
|
||||
|
||||
|
||||
def get_animation_data_object(context: Context) -> Object:
|
||||
pg: PSA_PG_export = getattr(context.scene, 'psa_export')
|
||||
|
||||
@@ -119,19 +176,11 @@ def get_animation_data_object(context: Context) -> Object:
|
||||
return animation_data_object
|
||||
|
||||
|
||||
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 +193,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 +217,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])
|
||||
@@ -223,7 +258,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,43 +302,59 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row.operator(PSA_OT_export_actions_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
|
||||
|
||||
flow = sequences_panel.grid_flow()
|
||||
flow.use_property_split = True
|
||||
flow.use_property_decorate = False
|
||||
flow.prop(pg, 'sequence_name_prefix', text='Name Prefix')
|
||||
flow.prop(pg, 'sequence_name_suffix')
|
||||
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)))
|
||||
|
||||
# Determine if there is going to be a naming conflict and display an error, if so.
|
||||
selected_items = [x for x in pg.action_list if x.is_selected]
|
||||
action_names = [x.name for x in selected_items]
|
||||
action_name_counts = Counter(action_names)
|
||||
for action_name, count in action_name_counts.items():
|
||||
if count > 1:
|
||||
layout.label(text=f'Duplicate action: {action_name}', icon='ERROR')
|
||||
break
|
||||
name_header, name_panel = layout.panel('Name', default_closed=False)
|
||||
name_header.label(text='Name')
|
||||
if name_panel:
|
||||
flow = name_panel.grid_flow()
|
||||
flow.use_property_split = True
|
||||
flow.use_property_decorate = False
|
||||
flow.prop(pg, 'sequence_name_prefix', text='Name Prefix')
|
||||
flow.prop(pg, 'sequence_name_suffix')
|
||||
|
||||
# FPS
|
||||
flow.prop(pg, 'fps_source')
|
||||
if pg.fps_source == 'CUSTOM':
|
||||
flow.prop(pg, 'fps_custom', text='Custom')
|
||||
# Determine if there is going to be a naming conflict and display an error, if so.
|
||||
selected_items = [x for x in pg.action_list if x.is_selected]
|
||||
action_names = [x.name for x in selected_items]
|
||||
action_name_counts = Counter(action_names)
|
||||
for action_name, count in action_name_counts.items():
|
||||
if count > 1:
|
||||
layout.label(text=f'Duplicate action: {action_name}', icon='ERROR')
|
||||
break
|
||||
|
||||
sampling_header, sampling_panel = layout.panel('Data Source', default_closed=False)
|
||||
sampling_header.label(text='Sampling')
|
||||
if sampling_panel:
|
||||
flow = sampling_panel.grid_flow()
|
||||
flow.use_property_split = True
|
||||
flow.use_property_decorate = False
|
||||
|
||||
# SAMPLING MODE
|
||||
flow.prop(pg, 'sampling_mode', text='Sampling Mode')
|
||||
|
||||
# FPS
|
||||
col = flow.row(align=True)
|
||||
col.prop(pg, 'fps_source', text='FPS')
|
||||
if pg.fps_source == 'CUSTOM':
|
||||
col.prop(pg, 'fps_custom', text='')
|
||||
|
||||
# COMPRESSION RATIO
|
||||
col = flow.row(align=True)
|
||||
col.prop(pg, 'compression_ratio_source', text='Compression Ratio')
|
||||
if pg.compression_ratio_source == 'CUSTOM':
|
||||
col.prop(pg, 'compression_ratio_custom', text='')
|
||||
|
||||
# BONES
|
||||
bones_header, bones_panel = layout.panel('Bones', default_closed=False)
|
||||
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 +365,16 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
bones_panel.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index',
|
||||
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 +382,19 @@ 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}\')')
|
||||
|
||||
if context.scene.is_nla_tweakmode:
|
||||
raise RuntimeError('Cannot export PSA while in NLA tweak mode')
|
||||
|
||||
def invoke(self, context, _event):
|
||||
try:
|
||||
@@ -351,7 +411,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,53 +439,68 @@ class PSA_OT_export(Operator, ExportHelper):
|
||||
|
||||
export_sequences: List[PsaBuildSequence] = []
|
||||
|
||||
if pg.sequence_source == '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.name = action_item.name
|
||||
export_sequence.nla_state.frame_start = action_item.frame_start
|
||||
export_sequence.nla_state.frame_end = action_item.frame_end
|
||||
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action_item.action])
|
||||
export_sequence.compression_ratio = action_item.action.psa_export.compression_ratio
|
||||
export_sequence.key_quota = action_item.action.psa_export.key_quota
|
||||
export_sequences.append(export_sequence)
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
for marker_item in filter(lambda x: x.is_selected, pg.marker_list):
|
||||
export_sequence = PsaBuildSequence()
|
||||
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':
|
||||
for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list):
|
||||
export_sequence = PsaBuildSequence()
|
||||
export_sequence.name = nla_strip_item.name
|
||||
export_sequence.nla_state.action = None
|
||||
export_sequence.nla_state.frame_start = nla_strip_item.frame_start
|
||||
export_sequence.nla_state.frame_end = nla_strip_item.frame_end
|
||||
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [nla_strip_item.action])
|
||||
export_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:
|
||||
raise ValueError(f'Unhandled sequence source: {pg.sequence_source}')
|
||||
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(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 = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, [action_item.action])
|
||||
export_sequence.key_quota = action_item.action.psa_export.key_quota
|
||||
export_sequences.append(export_sequence)
|
||||
case 'TIMELINE_MARKERS':
|
||||
for marker_item in filter(lambda x: x.is_selected, pg.marker_list):
|
||||
export_sequence = PsaBuildSequence(context.active_object, animation_data)
|
||||
export_sequence.name = marker_item.name
|
||||
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_sequence.compression_ratio = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, nla_strips_actions)
|
||||
export_sequences.append(export_sequence)
|
||||
case 'NLA_TRACK_STRIPS':
|
||||
for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list):
|
||||
export_sequence = PsaBuildSequence(context.active_object, animation_data)
|
||||
export_sequence.name = nla_strip_item.name
|
||||
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 = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, [nla_strip_item.action])
|
||||
export_sequence.key_quota = nla_strip_item.action.psa_export.key_quota
|
||||
export_sequences.append(export_sequence)
|
||||
case 'ACTIVE_ACTION':
|
||||
for active_action_item in filter(lambda x: x.is_selected, pg.active_action_list):
|
||||
export_sequence = PsaBuildSequence(active_action_item.armature_object, active_action_item.armature_object.animation_data)
|
||||
action = active_action_item.action
|
||||
export_sequence.name = action.name
|
||||
export_sequence.nla_state.action = action
|
||||
export_sequence.nla_state.frame_start = int(action.frame_range[0])
|
||||
export_sequence.nla_state.frame_end = int(action.frame_range[1])
|
||||
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action])
|
||||
export_sequence.compression_ratio = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, [action])
|
||||
export_sequence.key_quota = action.psa_export.key_quota
|
||||
export_sequences.append(export_sequence)
|
||||
case _:
|
||||
raise ValueError(f'Unhandled sequence source: {pg.sequence_source}')
|
||||
|
||||
options = PsaBuildOptions()
|
||||
options.animation_data = animation_data
|
||||
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
|
||||
options.sampling_mode = pg.sampling_mode
|
||||
|
||||
try:
|
||||
psa = build_psa(context, options)
|
||||
@@ -448,13 +523,17 @@ 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':
|
||||
return pg.action_list
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
return pg.marker_list
|
||||
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
|
||||
return pg.nla_strip_list
|
||||
return None
|
||||
match pg.sequence_source:
|
||||
case 'ACTIONS':
|
||||
return pg.action_list
|
||||
case 'TIMELINE_MARKERS':
|
||||
return pg.marker_list
|
||||
case 'NLA_TRACK_STRIPS':
|
||||
return pg.nla_strip_list
|
||||
case 'ACTIVE_ACTION':
|
||||
return pg.active_action_list
|
||||
case _:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
@@ -481,13 +560,17 @@ 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':
|
||||
return pg.action_list
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
return pg.marker_list
|
||||
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
|
||||
return pg.nla_strip_list
|
||||
return None
|
||||
match pg.sequence_source:
|
||||
case 'ACTIONS':
|
||||
return pg.action_list
|
||||
case 'TIMELINE_MARKERS':
|
||||
return pg.marker_list
|
||||
case 'NLA_TRACK_STRIPS':
|
||||
return pg.nla_strip_list
|
||||
case 'ACTIVE_ACTION':
|
||||
return pg.active_action_list
|
||||
case _:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
|
||||
@@ -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:
|
||||
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
|
||||
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 = 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,10 +94,9 @@ def get_animation_data(pg: 'PSA_PG_export', context: Context) -> Optional[AnimDa
|
||||
def nla_track_search_cb(self, context: Context, edit_text: str):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
animation_data = get_animation_data(pg, context)
|
||||
if animation_data is None:
|
||||
return
|
||||
for index, nla_track in enumerate(animation_data.nla_tracks):
|
||||
yield f'{index} - {nla_track.name}'
|
||||
if animation_data is not None:
|
||||
for index, nla_track in enumerate(animation_data.nla_tracks):
|
||||
yield f'{index} - {nla_track.name}'
|
||||
|
||||
|
||||
def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context):
|
||||
@@ -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(
|
||||
@@ -131,30 +156,32 @@ class PSA_PG_export(PropertyGroup):
|
||||
)
|
||||
fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=empty_set, step=100,
|
||||
soft_max=60.0)
|
||||
compression_ratio_source: EnumProperty(
|
||||
name='Compression Ratio Source',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('ACTION_METADATA', 'Action Metadata', 'The compression ratio will be determined by action\'s Compression Ratio property found in the PSA Export panel.\n\nIf the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used', 'ACTION', 1),
|
||||
('CUSTOM', 'Custom', '', 2)
|
||||
)
|
||||
)
|
||||
compression_ratio_custom: FloatProperty(default=1.0, min=0.0, max=1.0, subtype='FACTOR', description='The key sampling ratio of the exported sequence.\n\nA compression ratio of 1.0 will export all frames, while a compression ratio of 0.5 will export half of the frames')
|
||||
action_list: CollectionProperty(type=PSA_PG_export_action_list_item)
|
||||
action_list_index: IntProperty(default=0)
|
||||
marker_list: CollectionProperty(type=PSA_PG_export_timeline_markers)
|
||||
marker_list_index: IntProperty(default=0)
|
||||
nla_strip_list: CollectionProperty(type=PSA_PG_export_nla_strip_list_item)
|
||||
nla_strip_list_index: IntProperty(default=0)
|
||||
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 +210,23 @@ 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
|
||||
)
|
||||
sampling_mode: EnumProperty(
|
||||
name='Sampling Mode',
|
||||
options=empty_set,
|
||||
description='The method by which frames are sampled',
|
||||
items=(
|
||||
('INTERPOLATED', 'Interpolated', 'Sampling is performed by interpolating the evaluated bone poses from the adjacent whole frames.', 'INTERPOLATED', 0),
|
||||
('SUBFRAME', 'Subframe', 'Sampling is performed by evaluating the bone poses at the subframe time.\n\nNot recommended unless you are also animating with subframes enabled.', 'SUBFRAME', 1),
|
||||
),
|
||||
default='INTERPOLATED'
|
||||
)
|
||||
|
||||
|
||||
def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:
|
||||
@@ -222,5 +266,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()
|
||||
|
||||
@@ -123,7 +123,6 @@ class PSA_OT_import_multiple(Operator):
|
||||
files: CollectionProperty(type=OperatorFileListElement, options={'SKIP_SAVE', 'HIDDEN'})
|
||||
|
||||
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
warnings = []
|
||||
@@ -131,7 +130,7 @@ class PSA_OT_import_multiple(Operator):
|
||||
for file in self.files:
|
||||
psa_path = os.path.join(self.directory, file.name)
|
||||
psa_reader = PsaReader(psa_path)
|
||||
sequence_names = psa_reader.sequences.keys()
|
||||
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)
|
||||
|
||||
@@ -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,25 +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, should_exclude_hidden_meshes: bool = True):
|
||||
for obj in collection.all_objects:
|
||||
if obj.type != 'MESH':
|
||||
continue
|
||||
if should_exclude_hidden_meshes and obj.visible_get() is False:
|
||||
continue
|
||||
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()
|
||||
@@ -62,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
|
||||
|
||||
@@ -83,7 +108,9 @@ def get_psk_input_objects_for_context(context: Context) -> PskInputObjects:
|
||||
|
||||
|
||||
def get_psk_input_objects_for_collection(collection: Collection, should_exclude_hidden_meshes: bool = True) -> PskInputObjects:
|
||||
mesh_objects = list(get_mesh_objects_for_collection(collection, should_exclude_hidden_meshes))
|
||||
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)
|
||||
|
||||
|
||||
@@ -100,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.
|
||||
@@ -109,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:
|
||||
@@ -144,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
|
||||
@@ -174,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.
|
||||
|
||||
@@ -202,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
|
||||
@@ -222,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,23 +232,6 @@ 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'
|
||||
)
|
||||
should_exclude_hidden_meshes: BoolProperty(
|
||||
default=True,
|
||||
name='Visible Only',
|
||||
description='Export only visible meshes'
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
collection = bpy.data.collections.get(self.collection)
|
||||
|
||||
@@ -117,11 +241,7 @@ class PSK_OT_export_collection(Operator, ExportHelper):
|
||||
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)
|
||||
@@ -141,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')
|
||||
@@ -155,10 +279,41 @@ class PSK_OT_export_collection(Operator, ExportHelper):
|
||||
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, True)
|
||||
if self.bone_filter_mode == 'BONE_COLLECTIONS':
|
||||
bones_panel.operator(PSK_OT_populate_bone_collection_list.bl_idname, icon='FILE_REFRESH')
|
||||
rows = max(3, min(len(self.bone_collection_list), 10))
|
||||
bones_panel.template_list('PSX_UL_bone_collection_list', '', self, 'bone_collection_list', self, 'bone_collection_list_index', rows=rows)
|
||||
|
||||
# MATERIALS
|
||||
materials_header, materials_panel = layout.panel('Materials', default_closed=False)
|
||||
materials_header.label(text='Materials', icon='MATERIAL')
|
||||
|
||||
if materials_panel:
|
||||
materials_panel.operator(PSK_OT_populate_material_name_list.bl_idname, icon='FILE_REFRESH')
|
||||
rows = max(3, min(len(self.material_name_list), 10))
|
||||
row = materials_panel.row()
|
||||
row.template_list('PSK_UL_material_names', '', self, 'material_name_list', self, 'material_name_list_index', rows=rows)
|
||||
col = row.column(align=True)
|
||||
col.operator(PSK_OT_material_list_name_move_up.bl_idname, text='', icon='TRIA_UP')
|
||||
col.operator(PSK_OT_material_list_name_move_down.bl_idname, text='', icon='TRIA_DOWN')
|
||||
|
||||
# TRANSFORM
|
||||
transform_header, transform_panel = layout.panel('Transform', default_closed=False)
|
||||
transform_header.label(text='Transform')
|
||||
if transform_panel:
|
||||
flow = transform_panel.grid_flow(row_major=True)
|
||||
flow.use_property_split = True
|
||||
flow.use_property_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):
|
||||
@@ -168,7 +323,6 @@ class PSK_OT_export(Operator, ExportHelper):
|
||||
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',
|
||||
@@ -182,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'}
|
||||
@@ -196,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
|
||||
|
||||
@@ -223,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')
|
||||
@@ -253,14 +395,8 @@ class PSK_OT_export(Operator, ExportHelper):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
|
||||
input_objects = get_psk_input_objects_for_context(context)
|
||||
options = get_psk_build_options_from_property_group([x.obj for x in input_objects.mesh_objects], pg)
|
||||
|
||||
options = PskBuildOptions()
|
||||
options.bone_filter_mode = pg.bone_filter_mode
|
||||
options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected]
|
||||
options.object_eval_state = pg.object_eval_state
|
||||
options.materials = [m.material for m in pg.material_list]
|
||||
options.should_enforce_bone_name_restrictions = pg.should_enforce_bone_name_restrictions
|
||||
|
||||
try:
|
||||
result = build_psk(context, input_objects, options)
|
||||
for warning in result.warnings:
|
||||
@@ -273,7 +409,7 @@ class PSK_OT_export(Operator, ExportHelper):
|
||||
except RuntimeError as e:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
@@ -282,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()
|
||||
|
||||
|
||||
|
||||
|
||||
def forward_axis_update(self, _context):
|
||||
if self.forward_axis == self.up_axis:
|
||||
# Automatically set the up axis to the next available axis
|
||||
self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z')
|
||||
|
||||
|
||||
def up_axis_update(self, _context):
|
||||
if self.up_axis == self.forward_axis:
|
||||
# Automatically set the forward axis to the next available axis
|
||||
self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X')
|
||||
|
||||
|
||||
|
||||
# In order to share the same properties between the PSA and PSK export properties, we need to define the properties in a
|
||||
# separate function and then apply them to the classes. This is because the collection exporter cannot have
|
||||
# PointerProperties, so we must effectively duplicate the storage of the properties.
|
||||
def add_psk_export_properties(cls):
|
||||
cls.__annotations__['object_eval_state'] = EnumProperty(
|
||||
items=object_eval_state_items,
|
||||
name='Object Evaluation State',
|
||||
default='EVALUATED'
|
||||
)
|
||||
cls.__annotations__['should_exclude_hidden_meshes'] = BoolProperty(
|
||||
default=False,
|
||||
name='Visible Only',
|
||||
description='Export only visible meshes'
|
||||
)
|
||||
cls.__annotations__['scale'] = FloatProperty(
|
||||
name='Scale',
|
||||
default=1.0,
|
||||
description='Scale factor to apply to the exported mesh and armature',
|
||||
min=0.0001,
|
||||
soft_max=100.0
|
||||
)
|
||||
cls.__annotations__['export_space'] = EnumProperty(
|
||||
name='Export Space',
|
||||
description='Space to export the mesh in',
|
||||
items=export_space_items,
|
||||
default='WORLD'
|
||||
)
|
||||
cls.__annotations__['bone_filter_mode'] = EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=bone_filter_mode_items,
|
||||
)
|
||||
cls.__annotations__['bone_collection_list'] = CollectionProperty(type=PSX_PG_bone_collection_list_item)
|
||||
cls.__annotations__['bone_collection_list_index'] = IntProperty(default=0)
|
||||
cls.__annotations__['forward_axis'] = EnumProperty(
|
||||
name='Forward',
|
||||
items=forward_items,
|
||||
default='X',
|
||||
update=forward_axis_update
|
||||
)
|
||||
cls.__annotations__['up_axis'] = EnumProperty(
|
||||
name='Up',
|
||||
items=up_items,
|
||||
default='Z',
|
||||
update=up_axis_update
|
||||
)
|
||||
cls.__annotations__['material_name_list'] = CollectionProperty(type=PSK_PG_material_name_list_item)
|
||||
cls.__annotations__['material_name_list_index'] = IntProperty(default=0)
|
||||
|
||||
|
||||
class PSK_PG_export(PropertyGroup):
|
||||
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(
|
||||
items=object_eval_state_items,
|
||||
name='Object Evaluation State',
|
||||
default='EVALUATED'
|
||||
)
|
||||
material_list: CollectionProperty(type=PSK_PG_material_list_item)
|
||||
material_list_index: IntProperty(default=0)
|
||||
should_enforce_bone_name_restrictions: BoolProperty(
|
||||
default=False,
|
||||
name='Enforce Bone Name Restrictions',
|
||||
description='Enforce that bone names must only contain letters, numbers, spaces, hyphens and underscores.\n\n'
|
||||
'Depending on the engine, improper bone names might not be referenced correctly by scripts'
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -36,41 +36,43 @@ 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':
|
||||
pass
|
||||
elif section.name == b'PNTS0000':
|
||||
_read_types(fp, Vector3, section, psk.points)
|
||||
elif section.name == 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':
|
||||
_read_types(fp, Psk.Face, section, psk.faces)
|
||||
elif section.name == b'MATT0000':
|
||||
_read_types(fp, Psk.Material, section, psk.materials)
|
||||
elif section.name == b'REFSKELT':
|
||||
_read_types(fp, Psk.Bone, section, psk.bones)
|
||||
elif section.name == b'RAWWEIGHTS':
|
||||
_read_types(fp, Psk.Weight, section, psk.weights)
|
||||
elif section.name == b'FACE3200':
|
||||
_read_types(fp, Psk.Face32, section, psk.faces)
|
||||
elif section.name == 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':
|
||||
_read_types(fp, Vector3, section, psk.vertex_normals)
|
||||
elif section.name == b'MRPHINFO':
|
||||
_read_types(fp, Psk.MorphInfo, section, psk.morph_infos)
|
||||
elif section.name == b'MRPHDATA':
|
||||
_read_types(fp, Psk.MorphData, section, psk.morph_data)
|
||||
else:
|
||||
# Section is not handled, skip it.
|
||||
fp.seek(section.data_size * section.data_count, os.SEEK_CUR)
|
||||
warnings.warn(f'Unrecognized section "{section.name} at position {fp.tell():15}"')
|
||||
match section.name:
|
||||
case b'ACTRHEAD':
|
||||
pass
|
||||
case b'PNTS0000':
|
||||
_read_types(fp, Vector3, section, psk.points)
|
||||
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')
|
||||
case b'FACE0000':
|
||||
_read_types(fp, Psk.Face, section, psk.faces)
|
||||
case b'MATT0000':
|
||||
_read_types(fp, Psk.Material, section, psk.materials)
|
||||
case b'REFSKELT':
|
||||
_read_types(fp, Psk.Bone, section, psk.bones)
|
||||
case b'RAWWEIGHTS':
|
||||
_read_types(fp, Psk.Weight, section, psk.weights)
|
||||
case b'FACE3200':
|
||||
_read_types(fp, Psk.Face32, section, psk.faces)
|
||||
case b'VERTEXCOLOR':
|
||||
_read_types(fp, Color, section, psk.vertex_colors)
|
||||
case b'VTXNORMS':
|
||||
_read_types(fp, Vector3, section, psk.vertex_normals)
|
||||
case b'MRPHINFO':
|
||||
_read_types(fp, Psk.MorphInfo, section, psk.morph_infos)
|
||||
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)
|
||||
warnings.warn(f'Unrecognized section "{section.name} at position {fp.tell():15}"')
|
||||
|
||||
'''
|
||||
UEViewer exports a sidecar file (*.props.txt) with fully-qualified reference paths for each material
|
||||
|
||||
@@ -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, Tuple
|
||||
|
||||
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
|
||||
@@ -65,7 +63,7 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
|
||||
item.count = sum(map(lambda bone: 1 if len(bone.collections) == 0 else 0, armature.bones))
|
||||
item.is_selected = unassigned_collection_is_selected
|
||||
|
||||
for bone_collection_index, bone_collection in enumerate(armature.collections):
|
||||
for bone_collection_index, bone_collection in enumerate(armature.collections_all):
|
||||
item = bone_collection_list.add()
|
||||
item.name = bone_collection.name
|
||||
item.index = bone_collection_index
|
||||
@@ -73,16 +71,7 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
|
||||
item.is_selected = bone_collection.name in selected_assigned_collection_names if has_selected_collections else True
|
||||
|
||||
|
||||
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,20 +79,20 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
|
||||
|
||||
:param armature_object: Blender object with type 'ARMATURE'
|
||||
:param 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]
|
||||
|
||||
# Get a list of the bone indices that we are explicitly including.
|
||||
bone_index_stack = []
|
||||
is_exporting_unassigned_bone_collections = -1 in bone_collection_indices
|
||||
bone_collections = list(armature_data.collections)
|
||||
bone_collections = list(armature_data.collections_all)
|
||||
|
||||
for bone_index, bone in enumerate(bones):
|
||||
# Check if this bone is in any of the collections in the bone collection indices list.
|
||||
@@ -164,4 +153,58 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
|
||||
|
||||
|
||||
def is_bdk_addon_loaded() -> bool:
|
||||
return bpy.ops.bdk is not None and bpy.ops.bdk.link_material is not None
|
||||
return 'bdk' in dir(bpy.ops)
|
||||
|
||||
|
||||
class SemanticVersion(object):
|
||||
def __init__(self, version: Tuple[int, int, int]):
|
||||
self.major, self.minor, self.patch = version
|
||||
|
||||
def __iter__(self):
|
||||
yield self.major
|
||||
yield self.minor
|
||||
yield self.patch
|
||||
|
||||
@staticmethod
|
||||
def compare(lhs: 'SemanticVersion', rhs: 'SemanticVersion') -> int:
|
||||
"""
|
||||
Compares two semantic versions.
|
||||
|
||||
Returns:
|
||||
-1 if lhs < rhs
|
||||
0 if lhs == rhs
|
||||
1 if lhs > rhs
|
||||
"""
|
||||
for l, r in zip(lhs, rhs):
|
||||
if l < r:
|
||||
return -1
|
||||
if l > r:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.major}.{self.minor}.{self.patch}'
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.compare(self, other) == 0
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
def __lt__(self, other):
|
||||
return self.compare(self, other) == -1
|
||||
|
||||
def __le__(self, other):
|
||||
return self.compare(self, other) <= 0
|
||||
|
||||
def __gt__(self, other):
|
||||
return self.compare(self, other) == 1
|
||||
|
||||
def __ge__(self, other):
|
||||
return self.compare(self, other) >= 0
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.major, self.minor, self.patch))
|
||||
|
||||
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, should_always_show_bone_collections=False):
|
||||
row = layout.row(align=True)
|
||||
for item_identifier, _, _ in bone_filter_mode_items:
|
||||
identifier = item_identifier
|
||||
item_layout = row.row(align=True)
|
||||
item_layout.prop_enum(pg, 'bone_filter_mode', item_identifier)
|
||||
item_layout.enabled = should_always_show_bone_collections or is_bone_filter_mode_item_available(pg, identifier)
|
||||
Reference in New Issue
Block a user