mirror of
https://github.com/DarklightGames/io_scene_psk_psa.git
synced 2024-11-27 16:10:48 +01:00
Merge branch 'feature-original-sequence-names'
This commit is contained in:
commit
19a8f88686
@ -1,7 +1,7 @@
|
||||
bl_info = {
|
||||
"name": "PSK/PSA Importer/Exporter",
|
||||
"author": "Colin Basnett",
|
||||
"version": (2, 1, 0),
|
||||
"version": (3, 0, 0),
|
||||
"blender": (2, 80, 0),
|
||||
# "location": "File > Export > PSK Export (.psk)",
|
||||
"description": "PSK/PSA Import/Export (.psk/.psa)",
|
||||
@ -13,6 +13,7 @@ bl_info = {
|
||||
|
||||
if 'bpy' in locals():
|
||||
import importlib
|
||||
|
||||
importlib.reload(psx_data)
|
||||
importlib.reload(psx_helpers)
|
||||
importlib.reload(psx_types)
|
||||
@ -42,15 +43,14 @@ else:
|
||||
from .psa import reader as psa_reader
|
||||
from .psa import importer as psa_importer
|
||||
|
||||
|
||||
import bpy
|
||||
from bpy.props import PointerProperty
|
||||
|
||||
classes = psx_types.__classes__ + \
|
||||
psk_importer.__classes__ + \
|
||||
psk_exporter.__classes__ + \
|
||||
psa_exporter.__classes__ + \
|
||||
psa_importer.__classes__
|
||||
classes = (psx_types.classes +
|
||||
psk_importer.classes +
|
||||
psk_exporter.classes +
|
||||
psa_exporter.classes +
|
||||
psa_importer.classes)
|
||||
|
||||
|
||||
def psk_export_menu_func(self, context):
|
||||
|
@ -55,6 +55,10 @@ class Vector3(Structure):
|
||||
def __repr__(self):
|
||||
return repr(tuple(self))
|
||||
|
||||
@classmethod
|
||||
def zero(cls):
|
||||
return Vector3(0, 0, 0)
|
||||
|
||||
|
||||
class Quaternion(Structure):
|
||||
_fields_ = [
|
||||
@ -73,6 +77,10 @@ class Quaternion(Structure):
|
||||
def __repr__(self):
|
||||
return repr(tuple(self))
|
||||
|
||||
@classmethod
|
||||
def identity(cls):
|
||||
return Quaternion(0, 0, 0, 1)
|
||||
|
||||
|
||||
class Section(Structure):
|
||||
_fields_ = [
|
||||
|
@ -1,5 +1,27 @@
|
||||
import datetime
|
||||
from collections import Counter
|
||||
from typing import List
|
||||
|
||||
from bpy.types import NlaStrip
|
||||
|
||||
|
||||
class Timer:
|
||||
def __enter__(self):
|
||||
self.start = datetime.datetime.now()
|
||||
self.interval = None
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.end = datetime.datetime.now()
|
||||
self.interval = self.end - self.start
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
if self.interval is not None:
|
||||
return self.interval
|
||||
else:
|
||||
return datetime.datetime.now() - self.start
|
||||
|
||||
|
||||
def rgb_to_srgb(c):
|
||||
if c > 0.0031308:
|
||||
@ -8,33 +30,67 @@ def rgb_to_srgb(c):
|
||||
return 12.92 * c
|
||||
|
||||
|
||||
def get_nla_strips_ending_at_frame(object, frame) -> List[NlaStrip]:
|
||||
if object is None or object.animation_data is None:
|
||||
return []
|
||||
strips = []
|
||||
for nla_track in object.animation_data.nla_tracks:
|
||||
for strip in nla_track.strips:
|
||||
if strip.frame_end == frame:
|
||||
strips.append(strip)
|
||||
return strips
|
||||
|
||||
|
||||
def get_nla_strips_in_timeframe(object, frame_min, frame_max) -> List[NlaStrip]:
|
||||
if object is None or object.animation_data is None:
|
||||
return []
|
||||
strips = []
|
||||
for nla_track in object.animation_data.nla_tracks:
|
||||
if nla_track.mute:
|
||||
continue
|
||||
for strip in nla_track.strips:
|
||||
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
|
||||
|
||||
|
||||
def populate_bone_group_list(armature_object, bone_group_list):
|
||||
bone_group_list.clear()
|
||||
|
||||
item = bone_group_list.add()
|
||||
item.name = '(unassigned)'
|
||||
item.index = -1
|
||||
item.is_selected = True
|
||||
|
||||
if armature_object and armature_object.pose:
|
||||
bone_group_counts = Counter(map(lambda x: x.bone_group, armature_object.pose.bones))
|
||||
|
||||
item = bone_group_list.add()
|
||||
item.name = 'Unassigned'
|
||||
item.index = -1
|
||||
item.count = 0 if None not in bone_group_counts else bone_group_counts[None]
|
||||
item.is_selected = True
|
||||
|
||||
for bone_group_index, bone_group in enumerate(armature_object.pose.bone_groups):
|
||||
item = bone_group_list.add()
|
||||
item.name = bone_group.name
|
||||
item.index = bone_group_index
|
||||
item.count = 0 if bone_group not in bone_group_counts else bone_group_counts[bone_group]
|
||||
item.is_selected = True
|
||||
|
||||
|
||||
def add_bone_groups_to_layout(layout):
|
||||
pass
|
||||
def get_psa_sequence_name(action, should_use_original_sequence_name):
|
||||
if should_use_original_sequence_name and 'psa_sequence_name' in action:
|
||||
return action['psa_sequence_name']
|
||||
else:
|
||||
return action.name
|
||||
|
||||
|
||||
def get_export_bone_indices_for_bone_groups(armature_object, bone_group_indices: List[int]) -> List[int]:
|
||||
def get_export_bone_names(armature_object, bone_filter_mode, bone_group_indices: List[int]) -> List[str]:
|
||||
"""
|
||||
Returns a sorted list of bone indices that should be exported for the given bone groups.
|
||||
Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone groups.
|
||||
|
||||
Note that the ancestors of bones within the bone groups will also be present in the returned list.
|
||||
|
||||
:param armature_object: Blender object with type 'ARMATURE'
|
||||
:param bone_filter_mode: One of ['ALL', 'BONE_GROUPS']
|
||||
:param bone_group_indices: List of bone group indices to be exported.
|
||||
:return: A sorted list of bone indices that should be exported.
|
||||
"""
|
||||
@ -45,24 +101,58 @@ def get_export_bone_indices_for_bone_groups(armature_object, bone_group_indices:
|
||||
pose_bones = armature_object.pose.bones
|
||||
bone_names = [x.name for x in bones]
|
||||
|
||||
# Get a list of the bone indices that are explicitly part of the bone groups we are including.
|
||||
# Get a list of the bone indices that we are explicitly including.
|
||||
bone_index_stack = []
|
||||
is_exporting_none_bone_groups = -1 in bone_group_indices
|
||||
for bone_index, pose_bone in enumerate(pose_bones):
|
||||
if (pose_bone.bone_group is None and is_exporting_none_bone_groups) or \
|
||||
if bone_filter_mode == 'ALL' or \
|
||||
(pose_bone.bone_group is None and is_exporting_none_bone_groups) or \
|
||||
(pose_bone.bone_group is not None and pose_bone.bone_group_index in bone_group_indices):
|
||||
bone_index_stack.append(bone_index)
|
||||
bone_index_stack.append((bone_index, None))
|
||||
|
||||
# For each bone that is explicitly being added, recursively walk up the hierarchy and ensure that all of
|
||||
# those ancestor bone indices are also in the list.
|
||||
bone_indices = set()
|
||||
bone_indices = dict()
|
||||
while len(bone_index_stack) > 0:
|
||||
bone_index = bone_index_stack.pop()
|
||||
bone_index, instigator_bone_index = bone_index_stack.pop()
|
||||
bone = bones[bone_index]
|
||||
if bone.parent is not None:
|
||||
parent_bone_index = bone_names.index(bone.parent.name)
|
||||
if parent_bone_index not in bone_indices:
|
||||
bone_index_stack.append(parent_bone_index)
|
||||
bone_indices.add(bone_index)
|
||||
bone_index_stack.append((parent_bone_index, bone_index))
|
||||
bone_indices[bone_index] = instigator_bone_index
|
||||
|
||||
return list(sorted(list(bone_indices)))
|
||||
# Sort the bone index list in-place.
|
||||
bone_indices = [(x[0], x[1]) for x in bone_indices.items()]
|
||||
bone_indices.sort(key=lambda x: x[0])
|
||||
|
||||
# Split out the bone indices and the instigator bone names into separate lists.
|
||||
# We use the bone names for the return values because the bone name is a more universal way of referencing them.
|
||||
# For example, users of this function may modify bone lists, which would invalidate the indices and require a
|
||||
# index mapping scheme to resolve it. Using strings is more comfy and results in less code downstream.
|
||||
instigator_bone_names = [bones[x[1]].name if x[1] is not None else None for x in bone_indices]
|
||||
bone_names = [bones[x[0]].name for x in bone_indices]
|
||||
|
||||
# Ensure that the hierarchy we are sending back has a single root bone.
|
||||
bone_indices = [x[0] for x in bone_indices]
|
||||
root_bones = [bones[bone_index] for bone_index in bone_indices if bones[bone_index].parent is None]
|
||||
if len(root_bones) > 1:
|
||||
# There is more than one root bone.
|
||||
# Print out why each root bone was included by linking it to one of the explicitly included bones.
|
||||
root_bone_names = [bone.name for bone in root_bones]
|
||||
for root_bone_name in root_bone_names:
|
||||
bone_name = root_bone_name
|
||||
while True:
|
||||
# Traverse the instigator chain until the end to find the true instigator bone.
|
||||
# TODO: in future, it would be preferential to have a readout of *all* instigator bones.
|
||||
instigator_bone_name = instigator_bone_names[bone_names.index(bone_name)]
|
||||
if instigator_bone_name is None:
|
||||
print(f'Root bone "{root_bone_name}" was included because {bone_name} was marked for export')
|
||||
break
|
||||
bone_name = instigator_bone_name
|
||||
|
||||
raise RuntimeError('Exported bone hierarchy must have a single root bone.\n'
|
||||
f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}.\n'
|
||||
f'Additional debugging information has been written to the console.')
|
||||
|
||||
return bone_names
|
||||
|
@ -1,29 +1,75 @@
|
||||
from typing import Dict, Iterable
|
||||
|
||||
from bpy.types import Action
|
||||
|
||||
from .data import *
|
||||
from ..helpers import *
|
||||
|
||||
|
||||
class PsaBuilderOptions(object):
|
||||
def __init__(self):
|
||||
self.fps_source = 'SCENE'
|
||||
self.fps_custom = 30.0
|
||||
self.sequence_source = 'ACTIONS'
|
||||
self.actions = []
|
||||
self.marker_names = []
|
||||
self.bone_filter_mode = 'ALL'
|
||||
self.bone_group_indices = []
|
||||
self.should_use_original_sequence_names = False
|
||||
self.should_trim_timeline_marker_sequences = True
|
||||
self.sequence_name_prefix = ''
|
||||
self.sequence_name_suffix = ''
|
||||
|
||||
|
||||
class PsaBuilderPerformance:
|
||||
def __init__(self):
|
||||
self.frame_set_duration = datetime.timedelta()
|
||||
self.key_build_duration = datetime.timedelta()
|
||||
self.key_add_duration = datetime.timedelta()
|
||||
|
||||
|
||||
class PsaBuilder(object):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def build(self, context, options: PsaBuilderOptions) -> Psa:
|
||||
object = context.view_layer.objects.active
|
||||
def get_sequence_fps(self, context, options: PsaBuilderOptions, actions: Iterable[Action]) -> float:
|
||||
if options.fps_source == 'SCENE':
|
||||
return context.scene.render.fps
|
||||
if options.fps_source == 'CUSTOM':
|
||||
return options.fps_custom
|
||||
elif options.fps_source == 'ACTION_METADATA':
|
||||
# Get the minimum value of action metadata FPS values.
|
||||
fps_list = []
|
||||
for action in filter(lambda x: 'psa_sequence_fps' in x, actions):
|
||||
fps = action['psa_sequence_fps']
|
||||
if type(fps) == int or type(fps) == float:
|
||||
fps_list.append(fps)
|
||||
if len(fps_list) > 0:
|
||||
return min(fps_list)
|
||||
else:
|
||||
# No valid action metadata to use, fallback to scene FPS
|
||||
return context.scene.render.fps
|
||||
else:
|
||||
raise RuntimeError(f'Invalid FPS source "{options.fps_source}"')
|
||||
|
||||
if object.type != 'ARMATURE':
|
||||
def build(self, context, options: PsaBuilderOptions) -> Psa:
|
||||
performance = PsaBuilderPerformance()
|
||||
active_object = context.view_layer.objects.active
|
||||
|
||||
if active_object.type != 'ARMATURE':
|
||||
raise RuntimeError('Selected object must be an Armature')
|
||||
|
||||
armature = object
|
||||
armature = active_object
|
||||
|
||||
if armature.animation_data is None:
|
||||
raise RuntimeError('No animation data for armature')
|
||||
|
||||
# Ensure that we actually have items that we are going to be exporting.
|
||||
if options.sequence_source == 'ACTIONS' and len(options.actions) == 0:
|
||||
raise RuntimeError('No actions were selected for export')
|
||||
elif options.sequence_source == 'TIMELINE_MARKERS' and len(options.marker_names) == 0:
|
||||
raise RuntimeError('No timeline markers were selected for export')
|
||||
|
||||
psa = Psa()
|
||||
|
||||
bones = list(armature.data.bones)
|
||||
@ -33,54 +79,45 @@ class PsaBuilder(object):
|
||||
# armature bones.
|
||||
bone_names = [x.name for x in bones]
|
||||
pose_bones = [(bone_names.index(bone.name), bone) for bone in armature.pose.bones]
|
||||
del bone_names
|
||||
pose_bones.sort(key=lambda x: x[0])
|
||||
pose_bones = [x[1] for x in pose_bones]
|
||||
|
||||
bone_indices = list(range(len(bones)))
|
||||
|
||||
# If bone groups are specified, get only the bones that are in that specified bone groups and their ancestors.
|
||||
if options.bone_filter_mode == 'BONE_GROUPS':
|
||||
bone_indices = get_export_bone_indices_for_bone_groups(armature, options.bone_group_indices)
|
||||
# Get a list of all the bone indices and instigator bones for the bone filter settings.
|
||||
export_bone_names = get_export_bone_names(armature, options.bone_filter_mode, options.bone_group_indices)
|
||||
bone_indices = [bone_names.index(x) for x in export_bone_names]
|
||||
|
||||
# Make the bone lists contain only the bones that are going to be exported.
|
||||
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:
|
||||
# No bones are going to be exported.
|
||||
raise RuntimeError('No bones available for export')
|
||||
|
||||
# Ensure that the exported hierarchy has a single root bone.
|
||||
root_bones = [x for x in bones if x.parent is None]
|
||||
if len(root_bones) > 1:
|
||||
root_bone_names = [x.name for x in root_bones]
|
||||
raise RuntimeError('Exported bone hierarchy must have a single root bone.'
|
||||
f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}')
|
||||
|
||||
for pose_bone in bones:
|
||||
# Build list of PSA bones.
|
||||
for bone in bones:
|
||||
psa_bone = Psa.Bone()
|
||||
psa_bone.name = bytes(pose_bone.name, encoding='utf-8')
|
||||
psa_bone.name = bytes(bone.name, encoding='utf-8')
|
||||
|
||||
try:
|
||||
parent_index = bones.index(pose_bone.parent)
|
||||
parent_index = bones.index(bone.parent)
|
||||
psa_bone.parent_index = parent_index
|
||||
psa.bones[parent_index].children_count += 1
|
||||
except ValueError:
|
||||
psa_bone.parent_index = -1
|
||||
|
||||
if pose_bone.parent is not None:
|
||||
rotation = pose_bone.matrix.to_quaternion()
|
||||
if bone.parent is not None:
|
||||
rotation = bone.matrix.to_quaternion()
|
||||
rotation.x = -rotation.x
|
||||
rotation.y = -rotation.y
|
||||
rotation.z = -rotation.z
|
||||
quat_parent = pose_bone.parent.matrix.to_quaternion().inverted()
|
||||
parent_head = quat_parent @ pose_bone.parent.head
|
||||
parent_tail = quat_parent @ pose_bone.parent.tail
|
||||
location = (parent_tail - parent_head) + pose_bone.head
|
||||
quat_parent = bone.parent.matrix.to_quaternion().inverted()
|
||||
parent_head = quat_parent @ bone.parent.head
|
||||
parent_tail = quat_parent @ bone.parent.tail
|
||||
location = (parent_tail - parent_head) + bone.head
|
||||
else:
|
||||
location = armature.matrix_local @ pose_bone.head
|
||||
rot_matrix = pose_bone.matrix @ armature.matrix_local.to_3x3()
|
||||
location = armature.matrix_local @ bone.head
|
||||
rot_matrix = bone.matrix @ armature.matrix_local.to_3x3()
|
||||
rotation = rot_matrix.to_quaternion()
|
||||
|
||||
psa_bone.location.x = location.x
|
||||
@ -94,60 +131,153 @@ class PsaBuilder(object):
|
||||
|
||||
psa.bones.append(psa_bone)
|
||||
|
||||
# Populate the export sequence list.
|
||||
class NlaState:
|
||||
def __init__(self):
|
||||
self.frame_min = 0
|
||||
self.frame_max = 0
|
||||
self.action = None
|
||||
|
||||
class ExportSequence:
|
||||
def __init__(self):
|
||||
self.name = ''
|
||||
self.nla_state = NlaState()
|
||||
self.fps = 30.0
|
||||
|
||||
export_sequences = []
|
||||
|
||||
if options.sequence_source == 'ACTIONS':
|
||||
for action in options.actions:
|
||||
if len(action.fcurves) == 0:
|
||||
continue
|
||||
export_sequence = ExportSequence()
|
||||
export_sequence.nla_state.action = action
|
||||
export_sequence.name = get_psa_sequence_name(action, options.should_use_original_sequence_names)
|
||||
frame_min, frame_max = [int(x) for x in action.frame_range]
|
||||
export_sequence.nla_state.frame_min = frame_min
|
||||
export_sequence.nla_state.frame_max = frame_max
|
||||
export_sequence.fps = self.get_sequence_fps(context, options, [action])
|
||||
export_sequences.append(export_sequence)
|
||||
pass
|
||||
elif options.sequence_source == 'TIMELINE_MARKERS':
|
||||
sequence_frame_ranges = self.get_timeline_marker_sequence_frame_ranges(armature, context, options)
|
||||
|
||||
for name, (frame_min, frame_max) in sequence_frame_ranges.items():
|
||||
export_sequence = ExportSequence()
|
||||
export_sequence.name = name
|
||||
export_sequence.nla_state.action = None
|
||||
export_sequence.nla_state.frame_min = frame_min
|
||||
export_sequence.nla_state.frame_max = frame_max
|
||||
nla_strips_actions = set(
|
||||
map(lambda x: x.action, get_nla_strips_in_timeframe(active_object, frame_min, frame_max)))
|
||||
export_sequence.fps = self.get_sequence_fps(context, options, nla_strips_actions)
|
||||
export_sequences.append(export_sequence)
|
||||
else:
|
||||
raise ValueError(f'Unhandled sequence source: {options.sequence_source}')
|
||||
|
||||
# Add prefixes and suffices to the names of the export sequences and strip whitespace.
|
||||
for export_sequence in export_sequences:
|
||||
export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}'.strip()
|
||||
|
||||
# Now build the PSA sequences.
|
||||
# We actually alter the timeline frame and simply record the resultant pose bone matrices.
|
||||
frame_start_index = 0
|
||||
|
||||
for action in options.actions:
|
||||
if len(action.fcurves) == 0:
|
||||
continue
|
||||
|
||||
armature.animation_data.action = action
|
||||
for export_sequence in export_sequences:
|
||||
armature.animation_data.action = export_sequence.nla_state.action
|
||||
context.view_layer.update()
|
||||
|
||||
frame_min, frame_max = [int(x) for x in action.frame_range]
|
||||
psa_sequence = Psa.Sequence()
|
||||
|
||||
sequence = Psa.Sequence()
|
||||
sequence.name = bytes(action.name, encoding='utf-8')
|
||||
sequence.frame_count = frame_max - frame_min + 1
|
||||
sequence.frame_start_index = frame_start_index
|
||||
sequence.fps = context.scene.render.fps
|
||||
frame_min = export_sequence.nla_state.frame_min
|
||||
frame_max = export_sequence.nla_state.frame_max
|
||||
frame_count = frame_max - frame_min + 1
|
||||
|
||||
psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252')
|
||||
psa_sequence.frame_count = frame_count
|
||||
psa_sequence.frame_start_index = frame_start_index
|
||||
psa_sequence.fps = export_sequence.fps
|
||||
|
||||
frame_count = frame_max - frame_min + 1
|
||||
|
||||
for frame in range(frame_count):
|
||||
context.scene.frame_set(frame_min + frame)
|
||||
with Timer() as t:
|
||||
context.scene.frame_set(frame_min + frame)
|
||||
performance.frame_set_duration += t.duration
|
||||
|
||||
for pose_bone in pose_bones:
|
||||
key = Psa.Key()
|
||||
pose_bone_matrix = pose_bone.matrix
|
||||
with Timer() as t:
|
||||
key = Psa.Key()
|
||||
pose_bone_matrix = pose_bone.matrix
|
||||
|
||||
if pose_bone.parent is not None:
|
||||
pose_bone_parent_matrix = pose_bone.parent.matrix
|
||||
pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix
|
||||
if pose_bone.parent is not None:
|
||||
pose_bone_parent_matrix = pose_bone.parent.matrix
|
||||
pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix
|
||||
|
||||
location = pose_bone_matrix.to_translation()
|
||||
rotation = pose_bone_matrix.to_quaternion().normalized()
|
||||
location = pose_bone_matrix.to_translation()
|
||||
rotation = pose_bone_matrix.to_quaternion().normalized()
|
||||
|
||||
if pose_bone.parent is not None:
|
||||
rotation.x = -rotation.x
|
||||
rotation.y = -rotation.y
|
||||
rotation.z = -rotation.z
|
||||
if pose_bone.parent is not None:
|
||||
rotation.x = -rotation.x
|
||||
rotation.y = -rotation.y
|
||||
rotation.z = -rotation.z
|
||||
|
||||
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 / sequence.fps
|
||||
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
|
||||
performance.key_build_duration += t.duration
|
||||
|
||||
psa.keys.append(key)
|
||||
with Timer() as t:
|
||||
psa.keys.append(key)
|
||||
performance.key_add_duration += t.duration
|
||||
|
||||
frame_start_index += 1
|
||||
psa_sequence.bone_count = len(pose_bones)
|
||||
psa_sequence.track_time = frame_count
|
||||
|
||||
sequence.bone_count = len(pose_bones)
|
||||
sequence.track_time = frame_count
|
||||
frame_start_index += frame_count
|
||||
|
||||
psa.sequences[action.name] = sequence
|
||||
psa.sequences[export_sequence.name] = psa_sequence
|
||||
|
||||
return psa
|
||||
|
||||
def get_timeline_marker_sequence_frame_ranges(self, object, context, options: PsaBuilderOptions) -> 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))
|
||||
|
||||
for marker_name in options.marker_names:
|
||||
marker = context.scene.timeline_markers[marker_name]
|
||||
frame_min = marker.frame
|
||||
# Determine the final frame of the sequence based on the next marker.
|
||||
# If no subsequent marker exists, use the maximum frame_end from all NLA strips.
|
||||
marker_index = sorted_timeline_marker_names.index(marker_name)
|
||||
next_marker_index = marker_index + 1
|
||||
frame_max = 0
|
||||
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_max = sorted_timeline_markers[next_marker_index].frame
|
||||
if options.should_trim_timeline_marker_sequences:
|
||||
nla_strips = get_nla_strips_in_timeframe(object, marker.frame, frame_max)
|
||||
frame_max = min(frame_max, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips)))
|
||||
frame_min = max(frame_min, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips)))
|
||||
else:
|
||||
# There is no next marker.
|
||||
# Find the final frame of all the NLA strips and use that as the last frame of this sequence.
|
||||
for nla_track in object.animation_data.nla_tracks:
|
||||
if nla_track.mute:
|
||||
continue
|
||||
for strip in nla_track.strips:
|
||||
frame_max = max(frame_max, strip.frame_end)
|
||||
|
||||
if frame_min == frame_max:
|
||||
continue
|
||||
|
||||
sequence_frame_ranges[marker_name] = int(frame_min), int(frame_max)
|
||||
|
||||
return sequence_frame_ranges
|
||||
|
@ -1,6 +1,7 @@
|
||||
import typing
|
||||
from typing import List
|
||||
from collections import OrderedDict
|
||||
from typing import List
|
||||
|
||||
from ..data import *
|
||||
|
||||
"""
|
||||
|
@ -1,13 +1,19 @@
|
||||
import bpy
|
||||
from bpy.types import Operator, PropertyGroup, Action, UIList, BoneGroup
|
||||
from bpy.props import CollectionProperty, IntProperty, PointerProperty, StringProperty, BoolProperty, EnumProperty
|
||||
from bpy_extras.io_utils import ExportHelper
|
||||
import fnmatch
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from typing import Type
|
||||
|
||||
import bpy
|
||||
from bpy.props import BoolProperty, CollectionProperty, EnumProperty, FloatProperty, IntProperty, PointerProperty, \
|
||||
StringProperty
|
||||
from bpy.types import Action, Operator, PropertyGroup, UIList
|
||||
from bpy_extras.io_utils import ExportHelper
|
||||
|
||||
from .builder import PsaBuilder, PsaBuilderOptions
|
||||
from .data import *
|
||||
from ..types import BoneGroupListItem
|
||||
from ..helpers import *
|
||||
import re
|
||||
from ..types import BoneGroupListItem
|
||||
|
||||
|
||||
class PsaExporter(object):
|
||||
@ -37,31 +43,94 @@ class PsaExporter(object):
|
||||
|
||||
class PsaExportActionListItem(PropertyGroup):
|
||||
action: PointerProperty(type=Action)
|
||||
action_name: StringProperty()
|
||||
name: StringProperty()
|
||||
is_selected: BoolProperty(default=False)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.action.name
|
||||
|
||||
class PsaExportTimelineMarkerListItem(PropertyGroup):
|
||||
marker_index: IntProperty()
|
||||
name: StringProperty()
|
||||
is_selected: BoolProperty(default=True)
|
||||
|
||||
|
||||
def update_action_names(context):
|
||||
pg = context.scene.psa_export
|
||||
for item in pg.action_list:
|
||||
action = item.action
|
||||
item.action_name = get_psa_sequence_name(action, pg.should_use_original_sequence_names)
|
||||
|
||||
|
||||
def should_use_original_sequence_names_updated(_, context):
|
||||
update_action_names(context)
|
||||
|
||||
|
||||
class PsaExportPropertyGroup(PropertyGroup):
|
||||
sequence_source: EnumProperty(
|
||||
name='Source',
|
||||
options=set(),
|
||||
description='',
|
||||
items=(
|
||||
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
|
||||
('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers', 'MARKER_HLT',
|
||||
1),
|
||||
)
|
||||
)
|
||||
fps_source: EnumProperty(
|
||||
name='FPS Source',
|
||||
options=set(),
|
||||
description='',
|
||||
items=(
|
||||
('SCENE', 'Scene', '', 'SCENE_DATA', 0),
|
||||
('ACTION_METADATA', 'Action Metadata',
|
||||
'The frame rate will be determined by action\'s "psa_sequence_fps" custom property, if it exists. If the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used. If no metadata is available, the scene\'s frame rate will be used.',
|
||||
'PROPERTIES', 1),
|
||||
('CUSTOM', 'Custom', '', 2)
|
||||
)
|
||||
)
|
||||
fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=set(), step=100,
|
||||
soft_max=60.0)
|
||||
action_list: CollectionProperty(type=PsaExportActionListItem)
|
||||
action_list_index: IntProperty(default=0)
|
||||
marker_list: CollectionProperty(type=PsaExportTimelineMarkerListItem)
|
||||
marker_list_index: IntProperty(default=0)
|
||||
bone_filter_mode: EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=set(),
|
||||
description='',
|
||||
items=(
|
||||
('ALL', 'All', 'All bones will be exported.'),
|
||||
('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will be exported.')
|
||||
('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will '
|
||||
'be exported.'),
|
||||
)
|
||||
)
|
||||
bone_group_list: CollectionProperty(type=BoneGroupListItem)
|
||||
bone_group_list_index: IntProperty(default=0)
|
||||
bone_group_list_index: IntProperty(default=0, name='', description='')
|
||||
should_use_original_sequence_names: BoolProperty(
|
||||
default=False,
|
||||
name='Original Names',
|
||||
options=set(),
|
||||
update=should_use_original_sequence_names_updated,
|
||||
description='If the action was imported from the PSA Import panel, the original name of the sequence will be '
|
||||
'used instead of the Blender action name',
|
||||
)
|
||||
should_trim_timeline_marker_sequences: BoolProperty(
|
||||
default=True,
|
||||
name='Trim Sequences',
|
||||
options=set(),
|
||||
description='Frames without NLA track information at the boundaries of timeline markers will be excluded from '
|
||||
'the exported sequences '
|
||||
)
|
||||
sequence_name_prefix: StringProperty(name='Prefix', options=set())
|
||||
sequence_name_suffix: StringProperty(name='Suffix', options=set())
|
||||
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
|
||||
sequence_use_filter_invert: BoolProperty(default=False, options=set())
|
||||
sequence_filter_asset: BoolProperty(default=False, name='Show assets',
|
||||
description='Show actions that belong to an asset library', options=set())
|
||||
sequence_use_filter_sort_reverse: BoolProperty(default=True, options=set())
|
||||
|
||||
|
||||
def is_bone_filter_mode_item_available(context, identifier):
|
||||
if identifier == "BONE_GROUPS":
|
||||
if identifier == 'BONE_GROUPS':
|
||||
obj = context.active_object
|
||||
if not obj.pose or not obj.pose.bone_groups:
|
||||
return False
|
||||
@ -69,8 +138,9 @@ def is_bone_filter_mode_item_available(context, identifier):
|
||||
|
||||
|
||||
class PsaExportOperator(Operator, ExportHelper):
|
||||
bl_idname = 'export.psa'
|
||||
bl_idname = 'psa_export.operator'
|
||||
bl_label = 'Export'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
__doc__ = 'Export actions to PSA'
|
||||
filename_ext = '.psa'
|
||||
filter_glob: StringProperty(default='*.psa', options={'HIDDEN'})
|
||||
@ -85,35 +155,73 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
property_group = context.scene.psa_export
|
||||
pg = context.scene.psa_export
|
||||
|
||||
# FPS
|
||||
layout.prop(pg, 'fps_source', text='FPS')
|
||||
if pg.fps_source == 'CUSTOM':
|
||||
layout.prop(pg, 'fps_custom', text='Custom')
|
||||
|
||||
# SOURCE
|
||||
layout.prop(pg, 'sequence_source', text='Source')
|
||||
|
||||
# SELECT ALL/NONE
|
||||
row = layout.row(align=True)
|
||||
row.label(text='Select')
|
||||
row.operator(PsaExportActionsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row.operator(PsaExportActionsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
|
||||
# ACTIONS
|
||||
box = layout.box()
|
||||
box.label(text='Actions', icon='ACTION')
|
||||
row = box.row()
|
||||
row.template_list('PSA_UL_ExportActionList', 'asd', property_group, 'action_list', property_group, 'action_list_index', rows=10)
|
||||
row = box.row(align=True)
|
||||
row.label(text='Select')
|
||||
row.operator('psa_export.actions_select_all', text='All')
|
||||
row.operator('psa_export.actions_deselect_all', text='None')
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
rows = max(3, min(len(pg.action_list), 10))
|
||||
|
||||
layout.template_list('PSA_UL_ExportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows)
|
||||
|
||||
col = layout.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_use_original_sequence_names')
|
||||
col.prop(pg, 'sequence_name_prefix')
|
||||
col.prop(pg, 'sequence_name_suffix')
|
||||
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
rows = max(3, min(len(pg.marker_list), 10))
|
||||
layout.template_list('PSA_UL_ExportTimelineMarkerList', '', pg, 'marker_list', pg, 'marker_list_index',
|
||||
rows=rows)
|
||||
|
||||
col = layout.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_trim_timeline_marker_sequences')
|
||||
col.prop(pg, 'sequence_name_prefix')
|
||||
col.prop(pg, 'sequence_name_suffix')
|
||||
|
||||
# Determine if there is going to be a naming conflict and display an error, if so.
|
||||
selected_items = [x for x in pg.action_list if x.is_selected]
|
||||
action_names = [x.name for x in selected_items]
|
||||
action_name_counts = Counter(action_names)
|
||||
for action_name, count in action_name_counts.items():
|
||||
if count > 1:
|
||||
layout.label(text=f'Duplicate action: {action_name}', icon='ERROR')
|
||||
break
|
||||
|
||||
layout.separator()
|
||||
|
||||
# BONES
|
||||
box = layout.box()
|
||||
box.label(text='Bones', icon='BONE_DATA')
|
||||
bone_filter_mode_items = property_group.bl_rna.properties['bone_filter_mode'].enum_items_static
|
||||
row = box.row(align=True)
|
||||
row = layout.row(align=True)
|
||||
row.prop(pg, 'bone_filter_mode', text='Bones')
|
||||
|
||||
for item in bone_filter_mode_items:
|
||||
identifier = item.identifier
|
||||
item_layout = row.row(align=True)
|
||||
item_layout.prop_enum(property_group, 'bone_filter_mode', item.identifier)
|
||||
item_layout.enabled = is_bone_filter_mode_item_available(context, identifier)
|
||||
|
||||
if property_group.bone_filter_mode == 'BONE_GROUPS':
|
||||
row = box.row()
|
||||
rows = max(3, min(len(property_group.bone_group_list), 10))
|
||||
row.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, 'bone_group_list_index', rows=rows)
|
||||
if pg.bone_filter_mode == 'BONE_GROUPS':
|
||||
row = layout.row(align=True)
|
||||
row.label(text='Select')
|
||||
row.operator(PsaExportBoneGroupsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row.operator(PsaExportBoneGroupsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||
layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index',
|
||||
rows=rows)
|
||||
|
||||
def should_action_be_selected_by_default(self, action):
|
||||
return action is not None and action.asset_data is None
|
||||
|
||||
def is_action_for_armature(self, action):
|
||||
if len(action.fcurves) == 0:
|
||||
@ -129,7 +237,7 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
return False
|
||||
|
||||
def invoke(self, context, event):
|
||||
property_group = context.scene.psa_export
|
||||
pg = context.scene.psa_export
|
||||
|
||||
if context.view_layer.objects.active is None:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'An armature must be selected')
|
||||
@ -142,113 +250,250 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
self.armature = context.view_layer.objects.active
|
||||
|
||||
# Populate actions list.
|
||||
property_group.action_list.clear()
|
||||
pg.action_list.clear()
|
||||
for action in bpy.data.actions:
|
||||
item = property_group.action_list.add()
|
||||
if not self.is_action_for_armature(action):
|
||||
continue
|
||||
item = pg.action_list.add()
|
||||
item.action = action
|
||||
item.action_name = action.name
|
||||
if self.is_action_for_armature(action):
|
||||
item.is_selected = True
|
||||
item.name = action.name
|
||||
item.is_selected = self.should_action_be_selected_by_default(action)
|
||||
|
||||
if len(property_group.action_list) == 0:
|
||||
update_action_names(context)
|
||||
|
||||
# Populate timeline markers list.
|
||||
pg.marker_list.clear()
|
||||
for marker in context.scene.timeline_markers:
|
||||
item = pg.marker_list.add()
|
||||
item.name = marker.name
|
||||
|
||||
if len(pg.action_list) == 0 and len(pg.marker_list) == 0:
|
||||
# If there are no actions at all, we have nothing to export, so just cancel the operation.
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions to export.')
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions or timeline markers to export.')
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Populate bone groups list.
|
||||
populate_bone_group_list(self.armature, property_group.bone_group_list)
|
||||
populate_bone_group_list(self.armature, pg.bone_group_list)
|
||||
|
||||
context.window_manager.fileselect_add(self)
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def execute(self, context):
|
||||
property_group = context.scene.psa_export
|
||||
actions = [x.action for x in property_group.action_list if x.is_selected]
|
||||
pg = context.scene.psa_export
|
||||
|
||||
if len(actions) == 0:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'No actions were selected for export.')
|
||||
return {'CANCELLED'}
|
||||
actions = [x.action for x in pg.action_list if x.is_selected]
|
||||
marker_names = [x.name for x in pg.marker_list if x.is_selected]
|
||||
|
||||
options = PsaBuilderOptions()
|
||||
options.fps_source = pg.fps_source
|
||||
options.fps_custom = pg.fps_custom
|
||||
options.sequence_source = pg.sequence_source
|
||||
options.actions = actions
|
||||
options.bone_filter_mode = property_group.bone_filter_mode
|
||||
options.bone_group_indices = [x.index for x in property_group.bone_group_list if x.is_selected]
|
||||
options.marker_names = marker_names
|
||||
options.bone_filter_mode = pg.bone_filter_mode
|
||||
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
|
||||
options.should_use_original_sequence_names = pg.should_use_original_sequence_names
|
||||
options.should_trim_timeline_marker_sequences = pg.should_trim_timeline_marker_sequences
|
||||
options.sequence_name_prefix = pg.sequence_name_prefix
|
||||
options.sequence_name_suffix = pg.sequence_name_suffix
|
||||
|
||||
builder = PsaBuilder()
|
||||
|
||||
try:
|
||||
psa = builder.build(context, options)
|
||||
except RuntimeError as e:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
exporter = PsaExporter(psa)
|
||||
exporter.export(self.filepath)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSA_UL_ExportActionList(UIList):
|
||||
class PSA_UL_ExportTimelineMarkerList(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
layout.alignment = 'LEFT'
|
||||
layout.prop(item, 'is_selected', icon_only=True)
|
||||
layout.label(text=item.action_name)
|
||||
layout.prop(item, 'is_selected', icon_only=True, text=item.name)
|
||||
|
||||
def filter_items(self, context, data, property):
|
||||
actions = getattr(data, property)
|
||||
flt_flags = []
|
||||
flt_neworder = []
|
||||
if self.filter_name:
|
||||
flt_flags = bpy.types.UI_UL_list.filter_items_by_name(
|
||||
self.filter_name,
|
||||
self.bitflag_filter_item,
|
||||
actions,
|
||||
'action_name',
|
||||
reverse=self.use_filter_invert
|
||||
)
|
||||
pg = context.scene.psa_export
|
||||
sequences = getattr(data, property)
|
||||
flt_flags = filter_sequences(pg, sequences)
|
||||
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'name')
|
||||
return flt_flags, flt_neworder
|
||||
|
||||
|
||||
class PsaExportSelectAll(bpy.types.Operator):
|
||||
bl_idname = 'psa_export.actions_select_all'
|
||||
def filter_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[int]:
|
||||
bitflag_filter_item = 1 << 30
|
||||
flt_flags = [bitflag_filter_item] * len(sequences)
|
||||
|
||||
if pg.sequence_filter_name is not None:
|
||||
# Filter name is non-empty.
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not fnmatch.fnmatch(sequence.name, f'*{pg.sequence_filter_name}*'):
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if not pg.sequence_filter_asset:
|
||||
for i, sequence in enumerate(sequences):
|
||||
if hasattr(sequence, 'action') and sequence.action.asset_data is not None:
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if pg.sequence_use_filter_invert:
|
||||
# Invert filter flags for all items.
|
||||
for i, sequence in enumerate(sequences):
|
||||
flt_flags[i] ^= bitflag_filter_item
|
||||
|
||||
return flt_flags
|
||||
|
||||
|
||||
def get_visible_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[
|
||||
PsaExportActionListItem]:
|
||||
visible_sequences = []
|
||||
for i, flag in enumerate(filter_sequences(pg, sequences)):
|
||||
if bool(flag & (1 << 30)):
|
||||
visible_sequences.append(sequences[i])
|
||||
return visible_sequences
|
||||
|
||||
|
||||
class PSA_UL_ExportActionList(UIList):
|
||||
|
||||
def __init__(self):
|
||||
super(PSA_UL_ExportActionList, self).__init__()
|
||||
# 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):
|
||||
layout.prop(item, 'is_selected', icon_only=True, text=item.name)
|
||||
if item.action.asset_data is not None:
|
||||
layout.label(text='', icon='ASSET_MANAGER')
|
||||
|
||||
def draw_filter(self, context, layout):
|
||||
pg = context.scene.psa_export
|
||||
row = layout.row()
|
||||
subrow = row.row(align=True)
|
||||
subrow.prop(pg, 'sequence_filter_name', text="")
|
||||
subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
||||
subrow = row.row(align=True)
|
||||
subrow.prop(pg, 'sequence_filter_asset', icon_only=True, icon='ASSET_MANAGER')
|
||||
# subrow.prop(pg, 'sequence_use_filter_sort_reverse', text='', icon='SORT_ASC')
|
||||
|
||||
def filter_items(self, context, data, property):
|
||||
pg = context.scene.psa_export
|
||||
actions = getattr(data, property)
|
||||
flt_flags = filter_sequences(pg, actions)
|
||||
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name')
|
||||
return flt_flags, flt_neworder
|
||||
|
||||
|
||||
class PsaExportActionsSelectAll(Operator):
|
||||
bl_idname = 'psa_export.sequences_select_all'
|
||||
bl_label = 'Select All'
|
||||
bl_description = 'Select all actions'
|
||||
bl_description = 'Select all visible sequences'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@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
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
property_group = context.scene.psa_export
|
||||
action_list = property_group.action_list
|
||||
has_unselected_actions = any(map(lambda action: not action.is_selected, action_list))
|
||||
return len(action_list) > 0 and has_unselected_actions
|
||||
pg = context.scene.psa_export
|
||||
item_list = cls.get_item_list(context)
|
||||
visible_sequences = get_visible_sequences(pg, item_list)
|
||||
has_unselected_sequences = any(map(lambda item: not item.is_selected, visible_sequences))
|
||||
return has_unselected_sequences
|
||||
|
||||
def execute(self, context):
|
||||
property_group = context.scene.psa_export
|
||||
for action in property_group.action_list:
|
||||
action.is_selected = True
|
||||
pg = context.scene.psa_export
|
||||
sequences = self.get_item_list(context)
|
||||
for sequence in get_visible_sequences(pg, sequences):
|
||||
sequence.is_selected = True
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaExportDeselectAll(bpy.types.Operator):
|
||||
bl_idname = 'psa_export.actions_deselect_all'
|
||||
class PsaExportActionsDeselectAll(Operator):
|
||||
bl_idname = 'psa_export.sequences_deselect_all'
|
||||
bl_label = 'Deselect All'
|
||||
bl_description = 'Deselect all actions'
|
||||
bl_description = 'Deselect all visible sequences'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@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
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
property_group = context.scene.psa_export
|
||||
action_list = property_group.action_list
|
||||
has_selected_actions = any(map(lambda action: action.is_selected, action_list))
|
||||
return len(action_list) > 0 and has_selected_actions
|
||||
item_list = cls.get_item_list(context)
|
||||
has_selected_items = any(map(lambda item: item.is_selected, item_list))
|
||||
return len(item_list) > 0 and has_selected_items
|
||||
|
||||
def execute(self, context):
|
||||
property_group = context.scene.psa_export
|
||||
for action in property_group.action_list:
|
||||
pg = context.scene.psa_export
|
||||
item_list = self.get_item_list(context)
|
||||
for sequence in get_visible_sequences(pg, item_list):
|
||||
sequence.is_selected = False
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaExportBoneGroupsSelectAll(Operator):
|
||||
bl_idname = 'psa_export.bone_groups_select_all'
|
||||
bl_label = 'Select All'
|
||||
bl_description = 'Select all bone groups'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = context.scene.psa_export
|
||||
item_list = pg.bone_group_list
|
||||
has_unselected_items = any(map(lambda action: not action.is_selected, item_list))
|
||||
return len(item_list) > 0 and has_unselected_items
|
||||
|
||||
def execute(self, context):
|
||||
pg = context.scene.psa_export
|
||||
for item in pg.bone_group_list:
|
||||
item.is_selected = True
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaExportBoneGroupsDeselectAll(Operator):
|
||||
bl_idname = 'psa_export.bone_groups_deselect_all'
|
||||
bl_label = 'Deselect All'
|
||||
bl_description = 'Deselect all bone groups'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = context.scene.psa_export
|
||||
item_list = pg.bone_group_list
|
||||
has_selected_actions = any(map(lambda action: action.is_selected, item_list))
|
||||
return len(item_list) > 0 and has_selected_actions
|
||||
|
||||
def execute(self, context):
|
||||
pg = context.scene.psa_export
|
||||
for action in pg.bone_group_list:
|
||||
action.is_selected = False
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
__classes__ = [
|
||||
classes = (
|
||||
PsaExportActionListItem,
|
||||
PsaExportTimelineMarkerListItem,
|
||||
PsaExportPropertyGroup,
|
||||
PsaExportOperator,
|
||||
PSA_UL_ExportActionList,
|
||||
PsaExportSelectAll,
|
||||
PsaExportDeselectAll,
|
||||
]
|
||||
PSA_UL_ExportTimelineMarkerList,
|
||||
PsaExportActionsSelectAll,
|
||||
PsaExportActionsDeselectAll,
|
||||
PsaExportBoneGroupsSelectAll,
|
||||
PsaExportBoneGroupsDeselectAll,
|
||||
)
|
||||
|
@ -1,12 +1,16 @@
|
||||
import bpy
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Vector, Quaternion, Matrix
|
||||
from .data import Psa
|
||||
from typing import List, AnyStr, Optional
|
||||
from bpy.types import Operator, Action, UIList, PropertyGroup, Panel, Armature, FileSelectParams
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty
|
||||
from bpy.types import Operator, UIList, PropertyGroup, Panel
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
from mathutils import Vector, Quaternion
|
||||
|
||||
from .data import Psa
|
||||
from .reader import PsaReader
|
||||
|
||||
|
||||
@ -16,6 +20,10 @@ class PsaImportOptions(object):
|
||||
self.should_use_fake_user = False
|
||||
self.should_stash = False
|
||||
self.sequence_names = []
|
||||
self.should_overwrite = False
|
||||
self.should_write_keyframes = True
|
||||
self.should_write_metadata = True
|
||||
self.action_name_prefix = ''
|
||||
|
||||
|
||||
class PsaImporter(object):
|
||||
@ -69,7 +77,8 @@ class PsaImporter(object):
|
||||
# Report if there are missing bones in the target armature.
|
||||
missing_bone_names = set(psa_bone_names).difference(set(armature_bone_names))
|
||||
if len(missing_bone_names) > 0:
|
||||
print(f'The armature object \'{armature_object.name}\' is missing the following bones that exist in the PSA:')
|
||||
print(
|
||||
f'The armature object \'{armature_object.name}\' is missing the following bones that exist in the PSA:')
|
||||
print(list(sorted(missing_bone_names)))
|
||||
del armature_bone_names
|
||||
|
||||
@ -115,75 +124,92 @@ class PsaImporter(object):
|
||||
actions = []
|
||||
for sequence in sequences:
|
||||
# Add the action.
|
||||
action = bpy.data.actions.new(name=sequence.name.decode())
|
||||
action.use_fake_user = options.should_use_fake_user
|
||||
|
||||
# Create f-curves for the rotation and location of each bone.
|
||||
for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items():
|
||||
import_bone = import_bones[psa_bone_index]
|
||||
pose_bone = import_bone.pose_bone
|
||||
rotation_data_path = pose_bone.path_from_id('rotation_quaternion')
|
||||
location_data_path = pose_bone.path_from_id('location')
|
||||
import_bone.fcurves = [
|
||||
action.fcurves.new(rotation_data_path, index=0), # Qw
|
||||
action.fcurves.new(rotation_data_path, index=1), # Qx
|
||||
action.fcurves.new(rotation_data_path, index=2), # Qy
|
||||
action.fcurves.new(rotation_data_path, index=3), # Qz
|
||||
action.fcurves.new(location_data_path, index=0), # Lx
|
||||
action.fcurves.new(location_data_path, index=1), # Ly
|
||||
action.fcurves.new(location_data_path, index=2), # Lz
|
||||
]
|
||||
|
||||
sequence_name = sequence.name.decode('windows-1252')
|
||||
action_name = options.action_name_prefix + sequence_name
|
||||
|
||||
# Read the sequence data matrix from the PSA.
|
||||
sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name)
|
||||
keyframe_write_matrix = np.ones(sequence_data_matrix.shape, dtype=np.int8)
|
||||
if options.should_overwrite and action_name in bpy.data.actions:
|
||||
action = bpy.data.actions[action_name]
|
||||
else:
|
||||
action = bpy.data.actions.new(name=action_name)
|
||||
|
||||
# Convert the sequence's data from world-space to local-space.
|
||||
for bone_index, import_bone in enumerate(import_bones):
|
||||
if import_bone is None:
|
||||
continue
|
||||
for frame_index in range(sequence.frame_count):
|
||||
# This bone has writeable keyframes for this frame.
|
||||
key_data = sequence_data_matrix[frame_index, bone_index]
|
||||
# Calculate the local-space key data for the bone.
|
||||
sequence_data_matrix[frame_index, bone_index] = calculate_fcurve_data(import_bone, key_data)
|
||||
if options.should_write_keyframes:
|
||||
# Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2)
|
||||
while len(action.fcurves) > 0:
|
||||
action.fcurves.remove(action.fcurves[-1])
|
||||
|
||||
# Clean the keyframe data. This is accomplished by writing zeroes to the write matrix when there is an
|
||||
# insufficiently large change in the data from frame-to-frame.
|
||||
if options.should_clean_keys:
|
||||
threshold = 0.001
|
||||
# Create f-curves for the rotation and location of each bone.
|
||||
for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items():
|
||||
import_bone = import_bones[psa_bone_index]
|
||||
pose_bone = import_bone.pose_bone
|
||||
rotation_data_path = pose_bone.path_from_id('rotation_quaternion')
|
||||
location_data_path = pose_bone.path_from_id('location')
|
||||
import_bone.fcurves = [
|
||||
action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name), # Qw
|
||||
action.fcurves.new(rotation_data_path, index=1, action_group=pose_bone.name), # Qx
|
||||
action.fcurves.new(rotation_data_path, index=2, action_group=pose_bone.name), # Qy
|
||||
action.fcurves.new(rotation_data_path, index=3, action_group=pose_bone.name), # Qz
|
||||
action.fcurves.new(location_data_path, index=0, action_group=pose_bone.name), # Lx
|
||||
action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name), # Ly
|
||||
action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name), # Lz
|
||||
]
|
||||
|
||||
# Read the sequence data matrix from the PSA.
|
||||
sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name)
|
||||
keyframe_write_matrix = np.ones(sequence_data_matrix.shape, dtype=np.int8)
|
||||
|
||||
# Convert the sequence's data from world-space to local-space.
|
||||
for bone_index, import_bone in enumerate(import_bones):
|
||||
if import_bone is None:
|
||||
continue
|
||||
for fcurve_index in range(len(import_bone.fcurves)):
|
||||
# Get all the keyframe data for the bone's f-curve data from the sequence data matrix.
|
||||
fcurve_frame_data = sequence_data_matrix[:, bone_index, fcurve_index]
|
||||
last_written_datum = 0
|
||||
for frame_index, datum in enumerate(fcurve_frame_data):
|
||||
# If the f-curve data is not different enough to the last written frame, un-mark this data for writing.
|
||||
if frame_index > 0 and abs(datum - last_written_datum) < threshold:
|
||||
keyframe_write_matrix[frame_index, bone_index, fcurve_index] = 0
|
||||
else:
|
||||
last_written_datum = datum
|
||||
|
||||
# Write the keyframes out!
|
||||
for frame_index in range(sequence.frame_count):
|
||||
for bone_index, import_bone in enumerate(import_bones):
|
||||
if import_bone is None:
|
||||
continue
|
||||
bone_has_writeable_keyframes = any(keyframe_write_matrix[frame_index, bone_index])
|
||||
if bone_has_writeable_keyframes:
|
||||
for frame_index in range(sequence.frame_count):
|
||||
# This bone has writeable keyframes for this frame.
|
||||
key_data = sequence_data_matrix[frame_index, bone_index]
|
||||
for fcurve, should_write, datum in zip(import_bone.fcurves, keyframe_write_matrix[frame_index, bone_index], key_data):
|
||||
if should_write:
|
||||
fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'})
|
||||
# Calculate the local-space key data for the bone.
|
||||
sequence_data_matrix[frame_index, bone_index] = calculate_fcurve_data(import_bone, key_data)
|
||||
|
||||
# Clean the keyframe data. This is accomplished by writing zeroes to the write matrix when there is an
|
||||
# insufficiently large change in the data from the last written frame.
|
||||
if options.should_clean_keys:
|
||||
threshold = 0.001
|
||||
for bone_index, import_bone in enumerate(import_bones):
|
||||
if import_bone is None:
|
||||
continue
|
||||
for fcurve_index in range(len(import_bone.fcurves)):
|
||||
# Get all the keyframe data for the bone's f-curve data from the sequence data matrix.
|
||||
fcurve_frame_data = sequence_data_matrix[:, bone_index, fcurve_index]
|
||||
last_written_datum = 0
|
||||
for frame_index, datum in enumerate(fcurve_frame_data):
|
||||
# If the f-curve data is not different enough to the last written frame, un-mark this data for writing.
|
||||
if frame_index > 0 and abs(datum - last_written_datum) < threshold:
|
||||
keyframe_write_matrix[frame_index, bone_index, fcurve_index] = 0
|
||||
else:
|
||||
last_written_datum = datum
|
||||
|
||||
# Write the keyframes out!
|
||||
for frame_index in range(sequence.frame_count):
|
||||
for bone_index, import_bone in enumerate(import_bones):
|
||||
if import_bone is None:
|
||||
continue
|
||||
bone_has_writeable_keyframes = any(keyframe_write_matrix[frame_index, bone_index])
|
||||
if bone_has_writeable_keyframes:
|
||||
# This bone has writeable keyframes for this frame.
|
||||
key_data = sequence_data_matrix[frame_index, bone_index]
|
||||
for fcurve, should_write, datum in zip(import_bone.fcurves,
|
||||
keyframe_write_matrix[frame_index, bone_index],
|
||||
key_data):
|
||||
if should_write:
|
||||
fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'})
|
||||
|
||||
# Write
|
||||
if options.should_write_metadata:
|
||||
action['psa_sequence_name'] = sequence_name
|
||||
action['psa_sequence_fps'] = sequence.fps
|
||||
|
||||
action.use_fake_user = options.should_use_fake_user
|
||||
|
||||
actions.append(action)
|
||||
|
||||
# If the user specifies, store the new animations as strips on a non-contributing NLA stack.
|
||||
# If the user specifies, store the new animations as strips on a non-contributing NLA track.
|
||||
if options.should_stash:
|
||||
if armature_object.animation_data is None:
|
||||
armature_object.animation_data_create()
|
||||
@ -194,57 +220,119 @@ class PsaImporter(object):
|
||||
nla_track.strips.new(name=action.name, start=0, action=action)
|
||||
|
||||
|
||||
class PsaImportPsaBoneItem(PropertyGroup):
|
||||
bone_name: StringProperty()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.bone_name
|
||||
|
||||
|
||||
class PsaImportActionListItem(PropertyGroup):
|
||||
action_name: StringProperty()
|
||||
frame_count: IntProperty()
|
||||
is_selected: BoolProperty(default=False)
|
||||
action_name: StringProperty(options=set())
|
||||
is_selected: BoolProperty(default=False, options=set())
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.action_name
|
||||
|
||||
def load_psa_file(context):
|
||||
pg = context.scene.psa_import
|
||||
pg.sequence_list.clear()
|
||||
pg.psa.bones.clear()
|
||||
pg.psa_error = ''
|
||||
try:
|
||||
# Read the file and populate the action list.
|
||||
p = os.path.abspath(pg.psa_file_path)
|
||||
psa_reader = PsaReader(p)
|
||||
for sequence in psa_reader.sequences.values():
|
||||
item = pg.sequence_list.add()
|
||||
item.action_name = sequence.name.decode('windows-1252')
|
||||
for psa_bone in psa_reader.bones:
|
||||
item = pg.psa.bones.add()
|
||||
item.bone_name = psa_bone.name.decode('windows-1252')
|
||||
except Exception as e:
|
||||
pg.psa_error = str(e)
|
||||
|
||||
|
||||
def on_psa_file_path_updated(property, context):
|
||||
property_group = context.scene.psa_import
|
||||
property_group.action_list.clear()
|
||||
property_group.psa_bones.clear()
|
||||
try:
|
||||
# Read the file and populate the action list.
|
||||
p = os.path.abspath(property_group.psa_file_path)
|
||||
psa_reader = PsaReader(p)
|
||||
for sequence in psa_reader.sequences.values():
|
||||
item = property_group.action_list.add()
|
||||
item.action_name = sequence.name.decode('windows-1252')
|
||||
item.frame_count = sequence.frame_count
|
||||
item.is_selected = True
|
||||
for psa_bone in psa_reader.bones:
|
||||
item = property_group.psa_bones.add()
|
||||
item.bone_name = psa_bone.name
|
||||
except IOError as e:
|
||||
# TODO: set an error somewhere so the user knows the PSA could not be read.
|
||||
pass
|
||||
load_psa_file(context)
|
||||
|
||||
|
||||
class PsaBonePropertyGroup(PropertyGroup):
|
||||
bone_name: StringProperty(options=set())
|
||||
|
||||
|
||||
class PsaDataPropertyGroup(PropertyGroup):
|
||||
bones: CollectionProperty(type=PsaBonePropertyGroup)
|
||||
sequence_count: IntProperty(default=0)
|
||||
|
||||
|
||||
class PsaImportPropertyGroup(PropertyGroup):
|
||||
psa_file_path: StringProperty(default='', update=on_psa_file_path_updated, name='PSA File Path')
|
||||
psa_bones: CollectionProperty(type=PsaImportPsaBoneItem)
|
||||
action_list: CollectionProperty(type=PsaImportActionListItem)
|
||||
action_list_index: IntProperty(name='', default=0)
|
||||
action_filter_name: StringProperty(default='')
|
||||
should_clean_keys: BoolProperty(default=True, name='Clean Keyframes', description='Exclude unnecessary keyframes from being written to the actions.')
|
||||
should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is saved even it has no users.')
|
||||
should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track')
|
||||
psa_file_path: StringProperty(default='', options=set(), update=on_psa_file_path_updated, name='PSA File Path')
|
||||
psa_error: StringProperty(default='')
|
||||
psa: PointerProperty(type=PsaDataPropertyGroup)
|
||||
sequence_list: CollectionProperty(type=PsaImportActionListItem)
|
||||
sequence_list_index: IntProperty(name='', default=0)
|
||||
should_clean_keys: BoolProperty(default=True, name='Clean Keyframes',
|
||||
description='Exclude unnecessary keyframes from being written to the actions',
|
||||
options=set())
|
||||
should_use_fake_user: BoolProperty(default=True, name='Fake User',
|
||||
description='Assign each imported action a fake user so that the data block is saved even it has no users',
|
||||
options=set())
|
||||
should_stash: BoolProperty(default=False, name='Stash',
|
||||
description='Stash each imported action as a strip on a new non-contributing NLA track',
|
||||
options=set())
|
||||
should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=set())
|
||||
action_name_prefix: StringProperty(default='', name='Prefix', options=set())
|
||||
should_overwrite: BoolProperty(default=False, name='Reuse Existing Actions', options=set(),
|
||||
description='If an action with a matching name already exists, the existing action will have it\'s data overwritten instead of a new action being created')
|
||||
should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=set())
|
||||
should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(),
|
||||
description='Additional data will be written to the custom properties of the Action (e.g., frame rate)')
|
||||
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
|
||||
sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected',
|
||||
description='Only show selected sequences')
|
||||
sequence_use_filter_invert: BoolProperty(default=False, options=set())
|
||||
sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression',
|
||||
description='Filter using regular expressions', options=set())
|
||||
select_text: PointerProperty(type=bpy.types.Text)
|
||||
|
||||
|
||||
class PSA_UL_ImportActionList(UIList):
|
||||
def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[int]:
|
||||
bitflag_filter_item = 1 << 30
|
||||
flt_flags = [bitflag_filter_item] * len(sequences)
|
||||
|
||||
if pg.sequence_filter_name is not None:
|
||||
# Filter name is non-empty.
|
||||
if pg.sequence_use_filter_regex:
|
||||
# Use regular expression. If regex pattern doesn't compile, just ignore it.
|
||||
try:
|
||||
regex = re.compile(pg.sequence_filter_name)
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not regex.match(sequence.action_name):
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
except re.error:
|
||||
pass
|
||||
else:
|
||||
# User regular text matching.
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not fnmatch.fnmatch(sequence.action_name, f'*{pg.sequence_filter_name}*'):
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if pg.sequence_filter_is_selected:
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not sequence.is_selected:
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if pg.sequence_use_filter_invert:
|
||||
# Invert filter flags for all items.
|
||||
for i, sequence in enumerate(sequences):
|
||||
flt_flags[i] ^= bitflag_filter_item
|
||||
|
||||
return flt_flags
|
||||
|
||||
|
||||
def get_visible_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[
|
||||
PsaImportActionListItem]:
|
||||
bitflag_filter_item = 1 << 30
|
||||
visible_sequences = []
|
||||
for i, flag in enumerate(filter_sequences(pg, sequences)):
|
||||
if bool(flag & bitflag_filter_item):
|
||||
visible_sequences.append(sequences[i])
|
||||
return visible_sequences
|
||||
|
||||
|
||||
class PSA_UL_SequenceList(UIList):
|
||||
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
row = layout.row(align=True)
|
||||
@ -255,66 +343,129 @@ class PSA_UL_ImportActionList(UIList):
|
||||
column.label(text=item.action_name)
|
||||
|
||||
def draw_filter(self, context, layout):
|
||||
pg = context.scene.psa_import
|
||||
row = layout.row()
|
||||
subrow = row.row(align=True)
|
||||
subrow.prop(self, 'filter_name', text="")
|
||||
subrow.prop(self, 'use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
||||
subrow = row.row(align=True)
|
||||
subrow.prop(self, 'use_filter_sort_reverse', text='', icon='SORT_ASC')
|
||||
subrow.prop(pg, 'sequence_filter_name', text="")
|
||||
subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
||||
subrow.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT')
|
||||
subrow.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT')
|
||||
|
||||
def filter_items(self, context, data, property):
|
||||
actions = getattr(data, property)
|
||||
flt_flags = []
|
||||
flt_neworder = []
|
||||
if self.filter_name:
|
||||
flt_flags = bpy.types.UI_UL_list.filter_items_by_name(
|
||||
self.filter_name,
|
||||
self.bitflag_filter_item,
|
||||
actions,
|
||||
'action_name',
|
||||
reverse=self.use_filter_invert
|
||||
)
|
||||
pg = context.scene.psa_import
|
||||
sequences = getattr(data, property)
|
||||
flt_flags = filter_sequences(pg, sequences)
|
||||
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'action_name')
|
||||
return flt_flags, flt_neworder
|
||||
|
||||
|
||||
class PsaImportSelectAll(bpy.types.Operator):
|
||||
bl_idname = 'psa_import.actions_select_all'
|
||||
class PSA_UL_ImportSequenceList(PSA_UL_SequenceList, UIList):
|
||||
pass
|
||||
|
||||
|
||||
class PSA_UL_ImportActionList(PSA_UL_SequenceList, UIList):
|
||||
pass
|
||||
|
||||
|
||||
class PsaImportSequencesFromText(Operator):
|
||||
bl_idname = 'psa_import.sequences_select_from_text'
|
||||
bl_label = 'Select By Text List'
|
||||
bl_description = 'Select sequences by name from text list'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = context.scene.psa_import
|
||||
return len(pg.sequence_list) > 0
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self, width=256)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
pg = context.scene.psa_import
|
||||
layout.label(icon='INFO', text='Each sequence name should be on a new line.')
|
||||
layout.prop(pg, 'select_text', text='')
|
||||
|
||||
def execute(self, context):
|
||||
pg = context.scene.psa_import
|
||||
contents = pg.select_text.as_string()
|
||||
count = 0
|
||||
for line in contents.split('\n'):
|
||||
for sequence in pg.sequence_list:
|
||||
if sequence.action_name == line:
|
||||
sequence.is_selected = True
|
||||
count += 1
|
||||
self.report({'INFO'}, f'Selected {count} sequence(s)')
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaImportSequencesSelectAll(Operator):
|
||||
bl_idname = 'psa_import.sequences_select_all'
|
||||
bl_label = 'All'
|
||||
bl_description = 'Select all actions'
|
||||
bl_description = 'Select all visible sequences'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
property_group = context.scene.psa_import
|
||||
action_list = property_group.action_list
|
||||
has_unselected_actions = any(map(lambda action: not action.is_selected, action_list))
|
||||
return len(action_list) > 0 and has_unselected_actions
|
||||
pg = context.scene.psa_import
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
has_unselected_actions = any(map(lambda action: not action.is_selected, visible_sequences))
|
||||
return len(visible_sequences) > 0 and has_unselected_actions
|
||||
|
||||
def execute(self, context):
|
||||
property_group = context.scene.psa_import
|
||||
for action in property_group.action_list:
|
||||
action.is_selected = True
|
||||
pg = context.scene.psa_import
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
for sequence in visible_sequences:
|
||||
sequence.is_selected = True
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaImportDeselectAll(bpy.types.Operator):
|
||||
bl_idname = 'psa_import.actions_deselect_all'
|
||||
class PsaImportSequencesDeselectAll(Operator):
|
||||
bl_idname = 'psa_import.sequences_deselect_all'
|
||||
bl_label = 'None'
|
||||
bl_description = 'Deselect all actions'
|
||||
bl_description = 'Deselect all visible sequences'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
property_group = context.scene.psa_import
|
||||
action_list = property_group.action_list
|
||||
has_selected_actions = any(map(lambda action: action.is_selected, action_list))
|
||||
return len(action_list) > 0 and has_selected_actions
|
||||
pg = context.scene.psa_import
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
has_selected_sequences = any(map(lambda sequence: sequence.is_selected, visible_sequences))
|
||||
return len(visible_sequences) > 0 and has_selected_sequences
|
||||
|
||||
def execute(self, context):
|
||||
property_group = context.scene.psa_import
|
||||
for action in property_group.action_list:
|
||||
action.is_selected = False
|
||||
pg = context.scene.psa_import
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
for sequence in visible_sequences:
|
||||
sequence.is_selected = False
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSA_PT_ImportPanel_Advanced(Panel):
|
||||
bl_space_type = 'PROPERTIES'
|
||||
bl_region_type = 'WINDOW'
|
||||
bl_label = 'Advanced'
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
bl_parent_id = 'PSA_PT_ImportPanel'
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
pg = context.scene.psa_import
|
||||
|
||||
col = layout.column(heading="Options")
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_clean_keys')
|
||||
col.separator()
|
||||
col.prop(pg, 'should_use_fake_user')
|
||||
col.prop(pg, 'should_stash')
|
||||
col.prop(pg, 'should_use_action_name_prefix')
|
||||
|
||||
if pg.should_use_action_name_prefix:
|
||||
col.prop(pg, 'action_name_prefix')
|
||||
|
||||
|
||||
class PSA_PT_ImportPanel(Panel):
|
||||
bl_space_type = 'PROPERTIES'
|
||||
bl_region_type = 'WINDOW'
|
||||
@ -329,41 +480,73 @@ class PSA_PT_ImportPanel(Panel):
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
property_group = context.scene.psa_import
|
||||
pg = context.scene.psa_import
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.operator(PsaImportSelectFile.bl_idname, text='', icon='FILEBROWSER')
|
||||
row.prop(pg, 'psa_file_path', text='')
|
||||
row.operator(PsaImportFileReload.bl_idname, text='', icon='FILE_REFRESH')
|
||||
|
||||
if pg.psa_error != '':
|
||||
row = layout.row()
|
||||
row.label(text='File could not be read', icon='ERROR')
|
||||
|
||||
box = layout.box()
|
||||
|
||||
box.label(text=f'Sequences ({len(pg.sequence_list)})', icon='ARMATURE_DATA')
|
||||
|
||||
# select
|
||||
rows = max(3, min(len(pg.sequence_list), 10))
|
||||
|
||||
row = box.row()
|
||||
col = row.column()
|
||||
|
||||
row2 = col.row(align=True)
|
||||
row2.label(text='Select')
|
||||
row2.operator(PsaImportSequencesFromText.bl_idname, text='', icon='TEXT')
|
||||
row2.operator(PsaImportSequencesSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row2.operator(PsaImportSequencesDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
|
||||
col = col.row()
|
||||
col.template_list('PSA_UL_ImportSequenceList', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows)
|
||||
|
||||
col = layout.column(heading='')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_overwrite')
|
||||
|
||||
col = layout.column(heading='Write')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_write_keyframes')
|
||||
col.prop(pg, 'should_write_metadata')
|
||||
|
||||
selected_sequence_count = sum(map(lambda x: x.is_selected, pg.sequence_list))
|
||||
|
||||
row = layout.row()
|
||||
row.prop(property_group, 'psa_file_path', text='')
|
||||
row.enabled = False
|
||||
|
||||
row = layout.row()
|
||||
row.operator('psa_import.select_file', text='Select PSA File', icon='FILEBROWSER')
|
||||
import_button_text = 'Import'
|
||||
if selected_sequence_count > 0:
|
||||
import_button_text = f'Import ({selected_sequence_count})'
|
||||
|
||||
if len(property_group.action_list) > 0:
|
||||
box = layout.box()
|
||||
box.label(text=f'Actions ({len(property_group.action_list)})', icon='ACTION')
|
||||
row = box.row()
|
||||
rows = max(3, min(len(property_group.action_list), 10))
|
||||
row.template_list('PSA_UL_ImportActionList', '', property_group, 'action_list', property_group, 'action_list_index', rows=rows)
|
||||
row = box.row(align=True)
|
||||
row.label(text='Select')
|
||||
row.operator('psa_import.actions_select_all', text='All')
|
||||
row.operator('psa_import.actions_deselect_all', text='None')
|
||||
row.operator(PsaImportOperator.bl_idname, text=import_button_text)
|
||||
|
||||
row = layout.row()
|
||||
row.prop(property_group, 'should_clean_keys')
|
||||
|
||||
# DATA
|
||||
row = layout.row()
|
||||
row.prop(property_group, 'should_use_fake_user')
|
||||
row.prop(property_group, 'should_stash')
|
||||
class PsaImportFileReload(Operator):
|
||||
bl_idname = 'psa_import.file_reload'
|
||||
bl_label = 'Refresh'
|
||||
bl_options = {'INTERNAL'}
|
||||
bl_description = 'Refresh the PSA file'
|
||||
|
||||
layout.operator('psa_import.import', text=f'Import')
|
||||
def execute(self, context):
|
||||
load_psa_file(context)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PsaImportSelectFile(Operator):
|
||||
bl_idname = 'psa_import.select_file'
|
||||
bl_label = 'Select'
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
bl_options = {'INTERNAL'}
|
||||
bl_description = 'Select a PSA file from which to import animations'
|
||||
filepath: bpy.props.StringProperty(subtype='FILE_PATH')
|
||||
filter_glob: bpy.props.StringProperty(default="*.psa", options={'HIDDEN'})
|
||||
@ -381,32 +564,42 @@ class PsaImportOperator(Operator):
|
||||
bl_idname = 'psa_import.import'
|
||||
bl_label = 'Import'
|
||||
bl_description = 'Import the selected animations into the scene as actions'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
property_group = context.scene.psa_import
|
||||
pg = context.scene.psa_import
|
||||
active_object = context.view_layer.objects.active
|
||||
action_list = property_group.action_list
|
||||
has_selected_actions = any(map(lambda action: action.is_selected, action_list))
|
||||
return has_selected_actions and active_object is not None and active_object.type == 'ARMATURE'
|
||||
if active_object is None or active_object.type != 'ARMATURE':
|
||||
return False
|
||||
return any(map(lambda x: x.is_selected, pg.sequence_list))
|
||||
|
||||
def execute(self, context):
|
||||
property_group = context.scene.psa_import
|
||||
psa_reader = PsaReader(property_group.psa_file_path)
|
||||
sequence_names = [x.action_name for x in property_group.action_list if x.is_selected]
|
||||
pg = context.scene.psa_import
|
||||
psa_reader = PsaReader(pg.psa_file_path)
|
||||
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected]
|
||||
|
||||
options = PsaImportOptions()
|
||||
options.sequence_names = sequence_names
|
||||
options.should_clean_keys = property_group.should_clean_keys
|
||||
options.should_use_fake_user = property_group.should_use_fake_user
|
||||
options.should_stash = property_group.should_stash
|
||||
options.should_clean_keys = pg.should_clean_keys
|
||||
options.should_use_fake_user = pg.should_use_fake_user
|
||||
options.should_stash = pg.should_stash
|
||||
options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else ''
|
||||
options.should_overwrite = pg.should_overwrite
|
||||
options.should_write_metadata = pg.should_write_metadata
|
||||
options.should_write_keyframes = pg.should_write_keyframes
|
||||
|
||||
PsaImporter().import_psa(psa_reader, context.view_layer.objects.active, options)
|
||||
|
||||
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaImportFileSelectOperator(Operator, ImportHelper):
|
||||
bl_idname = 'psa_import.file_select'
|
||||
bl_label = 'File Select'
|
||||
bl_options = {'INTERNAL'}
|
||||
filename_ext = '.psa'
|
||||
filter_glob: StringProperty(default='*.psa', options={'HIDDEN'})
|
||||
filepath: StringProperty(
|
||||
@ -420,21 +613,26 @@ class PsaImportFileSelectOperator(Operator, ImportHelper):
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def execute(self, context):
|
||||
property_group = context.scene.psa_import
|
||||
property_group.psa_file_path = self.filepath
|
||||
# Load the sequence names from the selected file
|
||||
pg = context.scene.psa_import
|
||||
pg.psa_file_path = self.filepath
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
__classes__ = [
|
||||
PsaImportPsaBoneItem,
|
||||
classes = (
|
||||
PsaImportActionListItem,
|
||||
PsaBonePropertyGroup,
|
||||
PsaDataPropertyGroup,
|
||||
PsaImportPropertyGroup,
|
||||
PSA_UL_SequenceList,
|
||||
PSA_UL_ImportSequenceList,
|
||||
PSA_UL_ImportActionList,
|
||||
PsaImportSelectAll,
|
||||
PsaImportDeselectAll,
|
||||
PsaImportSequencesSelectAll,
|
||||
PsaImportSequencesDeselectAll,
|
||||
PsaImportSequencesFromText,
|
||||
PsaImportFileReload,
|
||||
PSA_PT_ImportPanel,
|
||||
PSA_PT_ImportPanel_Advanced,
|
||||
PsaImportOperator,
|
||||
PsaImportFileSelectOperator,
|
||||
PsaImportSelectFile,
|
||||
]
|
||||
)
|
||||
|
@ -1,14 +1,17 @@
|
||||
from .data import *
|
||||
import ctypes
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .data import *
|
||||
|
||||
|
||||
class PsaReader(object):
|
||||
"""
|
||||
This class will read the sequences and bone information immediately upon instantiation and hold onto a file handle.
|
||||
This class reads the sequences and bone information immediately upon instantiation and hold onto a file handle.
|
||||
The key data is not read into memory upon instantiation due to it's potentially very large size.
|
||||
To read the key data for a particular sequence, call `read_sequence_keys`.
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
self.keys_data_offset: int = 0
|
||||
self.fp = open(path, 'rb')
|
||||
@ -19,18 +22,9 @@ class PsaReader(object):
|
||||
return self.psa.bones
|
||||
|
||||
@property
|
||||
def sequences(self):
|
||||
def sequences(self) -> OrderedDict[Psa.Sequence]:
|
||||
return self.psa.sequences
|
||||
|
||||
@staticmethod
|
||||
def _read_types(fp, data_class: ctypes.Structure, section: Section, data):
|
||||
buffer_length = section.data_size * section.data_count
|
||||
buffer = fp.read(buffer_length)
|
||||
offset = 0
|
||||
for _ in range(section.data_count):
|
||||
data.append(data_class.from_buffer_copy(buffer, offset))
|
||||
offset += section.data_size
|
||||
|
||||
def read_sequence_data_matrix(self, sequence_name: str):
|
||||
sequence = self.psa.sequences[sequence_name]
|
||||
keys = self.read_sequence_keys(sequence_name)
|
||||
@ -65,6 +59,15 @@ class PsaReader(object):
|
||||
offset += data_size
|
||||
return keys
|
||||
|
||||
@staticmethod
|
||||
def _read_types(fp, data_class: ctypes.Structure, section: Section, data):
|
||||
buffer_length = section.data_size * section.data_count
|
||||
buffer = fp.read(buffer_length)
|
||||
offset = 0
|
||||
for _ in range(section.data_count):
|
||||
data.append(data_class.from_buffer_copy(buffer, offset))
|
||||
offset += section.data_size
|
||||
|
||||
def _read(self, fp) -> Psa:
|
||||
psa = Psa()
|
||||
while fp.read(1):
|
||||
@ -88,4 +91,3 @@ class PsaReader(object):
|
||||
else:
|
||||
raise RuntimeError(f'Unrecognized section "{section.name}"')
|
||||
return psa
|
||||
1
|
@ -1,6 +1,5 @@
|
||||
import bpy
|
||||
import bmesh
|
||||
from collections import OrderedDict
|
||||
|
||||
from .data import *
|
||||
from ..helpers import *
|
||||
|
||||
@ -70,34 +69,20 @@ class PskBuilder(object):
|
||||
# If the mesh has no armature object, simply assign it a dummy bone at the root to satisfy the requirement
|
||||
# that a PSK file must have at least one bone.
|
||||
psk_bone = Psk.Bone()
|
||||
psk_bone.name = bytes('static', encoding='utf-8')
|
||||
psk_bone.name = bytes('root', encoding='windows-1252')
|
||||
psk_bone.flags = 0
|
||||
psk_bone.children_count = 0
|
||||
psk_bone.parent_index = 0
|
||||
psk_bone.location = Vector3(0, 0, 0)
|
||||
psk_bone.rotation = Quaternion(0, 0, 0, 1)
|
||||
psk_bone.location = Vector3.zero()
|
||||
psk_bone.rotation = Quaternion.identity()
|
||||
psk.bones.append(psk_bone)
|
||||
else:
|
||||
bones = list(armature_object.data.bones)
|
||||
|
||||
# If we are filtering by bone groups, get only the bones that are in the specified bone groups and their
|
||||
# ancestors.
|
||||
if options.bone_filter_mode == 'BONE_GROUPS':
|
||||
bone_indices = get_export_bone_indices_for_bone_groups(armature_object, options.bone_group_indices)
|
||||
bones = [bones[bone_index] for bone_index in bone_indices]
|
||||
|
||||
# Ensure that the exported hierarchy has a single root bone.
|
||||
root_bones = [x for x in bones if x.parent is None]
|
||||
print('root bones')
|
||||
print(root_bones)
|
||||
if len(root_bones) > 1:
|
||||
root_bone_names = [x.name for x in root_bones]
|
||||
raise RuntimeError('Exported bone hierarchy must have a single root bone.'
|
||||
f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}')
|
||||
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices)
|
||||
bones = [armature_object.data.bones[bone_name] for bone_name in bone_names]
|
||||
|
||||
for bone in bones:
|
||||
psk_bone = Psk.Bone()
|
||||
psk_bone.name = bytes(bone.name, encoding='utf-8')
|
||||
psk_bone.name = bytes(bone.name, encoding='windows-1252')
|
||||
psk_bone.flags = 0
|
||||
psk_bone.children_count = 0
|
||||
|
||||
@ -133,9 +118,9 @@ class PskBuilder(object):
|
||||
|
||||
psk.bones.append(psk_bone)
|
||||
|
||||
vertex_offset = 0
|
||||
|
||||
for object in input_objects.mesh_objects:
|
||||
vertex_offset = len(psk.points)
|
||||
|
||||
# VERTICES
|
||||
for vertex in object.data.vertices:
|
||||
point = Vector3()
|
||||
@ -153,8 +138,10 @@ class PskBuilder(object):
|
||||
if m is None:
|
||||
raise RuntimeError('Material cannot be empty (index ' + str(i) + ')')
|
||||
if m.name in materials:
|
||||
# Material already evaluated, just get its index.
|
||||
material_index = list(materials.keys()).index(m.name)
|
||||
else:
|
||||
# New material.
|
||||
material = Psk.Material()
|
||||
material.name = bytes(m.name, encoding='utf-8')
|
||||
material.texture_index = len(psk.materials)
|
||||
@ -230,9 +217,9 @@ class PskBuilder(object):
|
||||
bone = bone.parent
|
||||
for vertex_group_index, vertex_group in enumerate(object.vertex_groups):
|
||||
if vertex_group_index not in vertex_group_bone_indices:
|
||||
# Vertex group has no associated bone, skip it.
|
||||
continue
|
||||
bone_index = vertex_group_bone_indices[vertex_group_index]
|
||||
# TODO: exclude vertex group if it doesn't match to a bone we are exporting
|
||||
for vertex_index in range(len(object.data.vertices)):
|
||||
try:
|
||||
weight = vertex_group.weight(vertex_index)
|
||||
@ -246,6 +233,4 @@ class PskBuilder(object):
|
||||
w.weight = weight
|
||||
psk.weights.append(w)
|
||||
|
||||
vertex_offset = len(psk.points)
|
||||
|
||||
return psk
|
||||
|
@ -1,9 +1,9 @@
|
||||
from typing import List
|
||||
|
||||
from ..data import *
|
||||
|
||||
|
||||
class Psk(object):
|
||||
|
||||
class Wedge(object):
|
||||
def __init__(self):
|
||||
self.point_index: int = 0
|
||||
|
@ -1,11 +1,13 @@
|
||||
from .data import *
|
||||
from ..types import BoneGroupListItem
|
||||
from ..helpers import populate_bone_group_list
|
||||
from .builder import PskBuilder, PskBuilderOptions
|
||||
from typing import Type
|
||||
|
||||
from bpy.props import StringProperty, CollectionProperty, IntProperty, EnumProperty
|
||||
from bpy.types import Operator, PropertyGroup
|
||||
from bpy_extras.io_utils import ExportHelper
|
||||
from bpy.props import StringProperty, CollectionProperty, IntProperty, BoolProperty, EnumProperty
|
||||
|
||||
from .builder import PskBuilder, PskBuilderOptions
|
||||
from .data import *
|
||||
from ..helpers import populate_bone_group_list
|
||||
from ..types import BoneGroupListItem
|
||||
|
||||
MAX_WEDGE_COUNT = 65536
|
||||
MAX_POINT_COUNT = 4294967296
|
||||
@ -73,6 +75,7 @@ def is_bone_filter_mode_item_available(context, identifier):
|
||||
class PskExportOperator(Operator, ExportHelper):
|
||||
bl_idname = 'export.psk'
|
||||
bl_label = 'Export'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
__doc__ = 'Export mesh and armature to PSK'
|
||||
filename_ext = '.psk'
|
||||
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'})
|
||||
@ -90,10 +93,10 @@ class PskExportOperator(Operator, ExportHelper):
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
property_group = context.scene.psk_export
|
||||
pg = context.scene.psk_export
|
||||
|
||||
# Populate bone groups list.
|
||||
populate_bone_group_list(input_objects.armature_object, property_group.bone_group_list)
|
||||
populate_bone_group_list(input_objects.armature_object, pg.bone_group_list)
|
||||
|
||||
context.window_manager.fileselect_add(self)
|
||||
|
||||
@ -102,29 +105,30 @@ class PskExportOperator(Operator, ExportHelper):
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
scene = context.scene
|
||||
property_group = scene.psk_export
|
||||
pg = scene.psk_export
|
||||
|
||||
# BONES
|
||||
box = layout.box()
|
||||
box.label(text='Bones', icon='BONE_DATA')
|
||||
bone_filter_mode_items = property_group.bl_rna.properties['bone_filter_mode'].enum_items_static
|
||||
bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static
|
||||
row = box.row(align=True)
|
||||
for item in bone_filter_mode_items:
|
||||
identifier = item.identifier
|
||||
item_layout = row.row(align=True)
|
||||
item_layout.prop_enum(property_group, 'bone_filter_mode', item.identifier)
|
||||
item_layout.prop_enum(pg, 'bone_filter_mode', item.identifier)
|
||||
item_layout.enabled = is_bone_filter_mode_item_available(context, identifier)
|
||||
|
||||
if property_group.bone_filter_mode == 'BONE_GROUPS':
|
||||
if pg.bone_filter_mode == 'BONE_GROUPS':
|
||||
row = box.row()
|
||||
rows = max(3, min(len(property_group.bone_group_list), 10))
|
||||
row.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, 'bone_group_list_index', rows=rows)
|
||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||
row.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows)
|
||||
|
||||
def execute(self, context):
|
||||
property_group = context.scene.psk_export
|
||||
pg = context.scene.psk_export
|
||||
builder = PskBuilder()
|
||||
options = PskBuilderOptions()
|
||||
options.bone_group_indices = [x.index for x in property_group.bone_group_list if x.is_selected]
|
||||
options.bone_filter_mode = pg.bone_filter_mode
|
||||
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
|
||||
try:
|
||||
psk = builder.build(context, options)
|
||||
exporter = PskExporter(psk)
|
||||
@ -138,17 +142,19 @@ class PskExportOperator(Operator, ExportHelper):
|
||||
class PskExportPropertyGroup(PropertyGroup):
|
||||
bone_filter_mode: EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=set(),
|
||||
description='',
|
||||
items=(
|
||||
('ALL', 'All', 'All bones will be exported.'),
|
||||
('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will be exported.')
|
||||
('BONE_GROUPS', 'Bone Groups',
|
||||
'Only bones belonging to the selected bone groups and their ancestors will be exported.')
|
||||
)
|
||||
)
|
||||
bone_group_list: CollectionProperty(type=BoneGroupListItem)
|
||||
bone_group_list_index: IntProperty(default=0)
|
||||
|
||||
|
||||
__classes__ = [
|
||||
classes = (
|
||||
PskExportOperator,
|
||||
PskExportPropertyGroup
|
||||
]
|
||||
)
|
||||
|
@ -1,16 +1,19 @@
|
||||
import os
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
import sys
|
||||
from math import inf
|
||||
from typing import Optional
|
||||
from .data import Psk
|
||||
from ..helpers import rgb_to_srgb
|
||||
from mathutils import Quaternion, Vector, Matrix
|
||||
from .reader import PskReader
|
||||
from bpy.props import StringProperty, EnumProperty, BoolProperty
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
from bpy.props import BoolProperty, EnumProperty, FloatProperty, StringProperty
|
||||
from bpy.types import Operator, PropertyGroup
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
from mathutils import Quaternion, Vector, Matrix
|
||||
|
||||
from .data import Psk
|
||||
from .reader import PskReader
|
||||
from ..helpers import rgb_to_srgb
|
||||
|
||||
|
||||
class PskImportOptions(object):
|
||||
@ -20,6 +23,7 @@ class PskImportOptions(object):
|
||||
self.vertex_color_space = 'sRGB'
|
||||
self.should_import_vertex_normals = True
|
||||
self.should_import_extra_uvs = True
|
||||
self.bone_length = 1.0
|
||||
|
||||
|
||||
class PskImporter(object):
|
||||
@ -60,7 +64,6 @@ class PskImporter(object):
|
||||
self.post_quat: Quaternion = Quaternion()
|
||||
|
||||
import_bones = []
|
||||
new_bone_size = 8.0
|
||||
|
||||
for bone_index, psk_bone in enumerate(psk.bones):
|
||||
import_bone = ImportBone(bone_index, psk_bone)
|
||||
@ -93,7 +96,7 @@ class PskImporter(object):
|
||||
else:
|
||||
import_bone.local_rotation.conjugate()
|
||||
|
||||
edit_bone.tail = Vector((0.0, new_bone_size, 0.0))
|
||||
edit_bone.tail = Vector((0.0, options.bone_length, 0.0))
|
||||
edit_bone_matrix = import_bone.local_rotation.conjugated()
|
||||
edit_bone_matrix.rotate(import_bone.world_matrix)
|
||||
edit_bone_matrix = edit_bone_matrix.to_matrix().to_4x4()
|
||||
@ -209,7 +212,8 @@ class PskImporter(object):
|
||||
# Get a list of all bones that have weights associated with them.
|
||||
vertex_group_bone_indices = set(map(lambda weight: weight.bone_index, psk.weights))
|
||||
for import_bone in map(lambda x: import_bones[x], sorted(list(vertex_group_bone_indices))):
|
||||
import_bone.vertex_group = mesh_object.vertex_groups.new(name=import_bone.psk_bone.name.decode('windows-1252'))
|
||||
import_bone.vertex_group = mesh_object.vertex_groups.new(
|
||||
name=import_bone.psk_bone.name.decode('windows-1252'))
|
||||
|
||||
for weight in psk.weights:
|
||||
import_bones[weight.bone_index].vertex_group.add((weight.point_index,), weight.weight, 'ADD')
|
||||
@ -228,9 +232,15 @@ class PskImporter(object):
|
||||
|
||||
|
||||
class PskImportPropertyGroup(PropertyGroup):
|
||||
should_import_vertex_colors: BoolProperty(default=True, name='Vertex Colors', description='Import vertex colors from PSKX files, if available')
|
||||
should_import_vertex_colors: BoolProperty(
|
||||
default=True,
|
||||
options=set(),
|
||||
name='Vertex Colors',
|
||||
description='Import vertex colors from PSKX files, if available'
|
||||
)
|
||||
vertex_color_space: EnumProperty(
|
||||
name='Vertex Color Space',
|
||||
options=set(),
|
||||
description='The source vertex color space',
|
||||
default='SRGBA',
|
||||
items=(
|
||||
@ -238,13 +248,33 @@ class PskImportPropertyGroup(PropertyGroup):
|
||||
('SRGBA', 'sRGBA', ''),
|
||||
)
|
||||
)
|
||||
should_import_vertex_normals: BoolProperty(default=True, name='Vertex Normals', description='Import vertex normals from PSKX files, if available')
|
||||
should_import_extra_uvs: BoolProperty(default=True, name='Extra UVs', description='Import extra UV maps from PSKX files, if available')
|
||||
should_import_vertex_normals: BoolProperty(
|
||||
default=True,
|
||||
name='Vertex Normals',
|
||||
options=set(),
|
||||
description='Import vertex normals from PSKX files, if available'
|
||||
)
|
||||
should_import_extra_uvs: BoolProperty(
|
||||
default=True,
|
||||
name='Extra UVs',
|
||||
options=set(),
|
||||
description='Import extra UV maps from PSKX files, if available'
|
||||
)
|
||||
bone_length: FloatProperty(
|
||||
default=1.0,
|
||||
min=sys.float_info.epsilon,
|
||||
step=100,
|
||||
soft_min=1.0,
|
||||
name='Bone Length',
|
||||
options=set(),
|
||||
description='Length of the bones'
|
||||
)
|
||||
|
||||
|
||||
class PskImportOperator(Operator, ImportHelper):
|
||||
bl_idname = 'import.psk'
|
||||
bl_label = 'Export'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
__doc__ = 'Load a PSK file'
|
||||
filename_ext = '.psk'
|
||||
filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'})
|
||||
@ -260,7 +290,11 @@ class PskImportOperator(Operator, ImportHelper):
|
||||
psk = reader.read(self.filepath)
|
||||
options = PskImportOptions()
|
||||
options.name = os.path.splitext(os.path.basename(self.filepath))[0]
|
||||
options.should_import_extra_uvs = pg.should_import_extra_uvs
|
||||
options.should_import_vertex_colors = pg.should_import_vertex_colors
|
||||
options.should_import_vertex_normals = pg.should_import_vertex_normals
|
||||
options.vertex_color_space = pg.vertex_color_space
|
||||
options.bone_length = pg.bone_length
|
||||
PskImporter().import_psk(psk, context, options)
|
||||
return {'FINISHED'}
|
||||
|
||||
@ -274,9 +308,10 @@ class PskImportOperator(Operator, ImportHelper):
|
||||
layout.prop(pg, 'should_import_vertex_colors')
|
||||
if pg.should_import_vertex_colors:
|
||||
layout.prop(pg, 'vertex_color_space')
|
||||
layout.prop(pg, 'bone_length')
|
||||
|
||||
|
||||
__classes__ = [
|
||||
classes = (
|
||||
PskImportOperator,
|
||||
PskImportPropertyGroup,
|
||||
]
|
||||
)
|
||||
|
@ -1,6 +1,7 @@
|
||||
from .data import *
|
||||
import ctypes
|
||||
|
||||
from .data import *
|
||||
|
||||
|
||||
class PskReader(object):
|
||||
|
||||
|
@ -1,17 +1,18 @@
|
||||
from bpy.types import PropertyGroup, UIList
|
||||
from bpy.props import StringProperty, IntProperty, BoolProperty
|
||||
from bpy.types import PropertyGroup, UIList
|
||||
|
||||
|
||||
class PSX_UL_BoneGroupList(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
layout.alignment = 'LEFT'
|
||||
layout.prop(item, 'is_selected', icon_only=True)
|
||||
layout.label(text=item.name, icon='GROUP_BONE' if item.index >= 0 else 'NONE')
|
||||
row = layout.row()
|
||||
row.prop(item, 'is_selected', text=item.name)
|
||||
row.label(text=str(item.count), icon='BONE_DATA')
|
||||
|
||||
|
||||
class BoneGroupListItem(PropertyGroup):
|
||||
name: StringProperty()
|
||||
index: IntProperty()
|
||||
count: IntProperty()
|
||||
is_selected: BoolProperty(default=False)
|
||||
|
||||
@property
|
||||
@ -19,7 +20,7 @@ class BoneGroupListItem(PropertyGroup):
|
||||
return self.name
|
||||
|
||||
|
||||
__classes__ = [
|
||||
classes = (
|
||||
BoneGroupListItem,
|
||||
PSX_UL_BoneGroupList
|
||||
]
|
||||
PSX_UL_BoneGroupList,
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user