Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
980042fc7f | ||
|
|
02082b9594 | ||
|
|
4181a15d0e | ||
|
|
b6ef3dda44 | ||
|
|
f7290e6808 | ||
|
|
65d3104ea9 | ||
|
|
1a48128cb9 | ||
|
|
88c22c9e80 | ||
|
|
ffc0d99374 | ||
|
|
cd490af431 | ||
|
|
96001651c6 | ||
|
|
d215ceb10c | ||
|
|
07f1c45000 | ||
|
|
02c4253c1d | ||
|
|
3627abe6fc | ||
|
|
f58d4c5539 | ||
|
|
494c5b116b | ||
|
|
4d1cd19a61 | ||
|
|
472a743c89 | ||
|
|
7c9d13686f | ||
|
|
4937f8f779 | ||
|
|
616593d0fb | ||
|
|
edafa1cfd7 | ||
|
|
a1bbf4fb1e | ||
|
|
0d779b8174 | ||
|
|
50f6dca565 | ||
|
|
fb42fbfa33 | ||
|
|
4a6f47b11a | ||
|
|
83a13fa1f4 | ||
|
|
1c1d90bc84 | ||
|
|
d4a58caafe | ||
|
|
df6bdb96a4 | ||
|
|
8495482345 | ||
|
|
1ac0870b31 | ||
|
|
6493ace078 | ||
|
|
0d9e2a4b60 | ||
|
|
248440052b | ||
|
|
ded6fc8980 | ||
|
|
19ff47cc83 | ||
|
|
31c0ec16ab |
@@ -1,4 +1,4 @@
|
|||||||
This Blender add-on allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats).
|
This Blender 2.80+ add-on allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many version of the Unreal Engine.
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
* Full PSK/PSA import and export capabilities
|
* Full PSK/PSA import and export capabilities
|
||||||
@@ -6,8 +6,8 @@ This Blender add-on allows you to import and export meshes and animations to and
|
|||||||
* Fine-grained PSA sequence importing for efficient workflow when working with large PSA files
|
* Fine-grained PSA sequence importing for efficient workflow when working with large PSA files
|
||||||
* Automatic keyframe reduction on PSA import
|
* Automatic keyframe reduction on PSA import
|
||||||
* PSA sequence metadata (e.g., frame rate, sequence name) is preserved on import, allowing this data to be reused on export
|
* PSA sequence metadata (e.g., frame rate, sequence name) is preserved on import, allowing this data to be reused on export
|
||||||
* An armature's [bone groups](https://docs.blender.org/manual/en/latest/animation/armatures/properties/bone_groups.html) can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers)
|
* Specific [bone groups](https://docs.blender.org/manual/en/latest/animation/armatures/properties/bone_groups.html) can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers)
|
||||||
* PSA sequences can be exported directly from actions or delineated using a scene's [timeline markers](https://docs.blender.org/manual/en/latest/animation/markers.html), allowing use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences
|
* PSA sequences can be exported directly from actions or delineated using a scene's [timeline markers](https://docs.blender.org/manual/en/latest/animation/markers.html), allowing direct use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
1. Download the zip file for the latest version from the [releases](https://github.com/DarklightGames/io_export_psk_psa/releases) page.
|
1. Download the zip file for the latest version from the [releases](https://github.com/DarklightGames/io_export_psk_psa/releases) page.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
bl_info = {
|
bl_info = {
|
||||||
"name": "PSK/PSA Importer/Exporter",
|
"name": "PSK/PSA Importer/Exporter",
|
||||||
"author": "Colin Basnett",
|
"author": "Colin Basnett, Yurii Ti",
|
||||||
"version": (3, 0, 0),
|
"version": (4, 1, 0),
|
||||||
"blender": (2, 80, 0),
|
"blender": (2, 80, 0),
|
||||||
# "location": "File > Export > PSK Export (.psk)",
|
# "location": "File > Export > PSK Export (.psk)",
|
||||||
"description": "PSK/PSA Import/Export (.psk/.psa)",
|
"description": "PSK/PSA Import/Export (.psk/.psa)",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import List
|
from typing import List, Iterable
|
||||||
|
|
||||||
from bpy.types import NlaStrip
|
import bpy.types
|
||||||
|
from bpy.types import NlaStrip, Object
|
||||||
|
|
||||||
|
|
||||||
class Timer:
|
class Timer:
|
||||||
@@ -30,22 +32,11 @@ def rgb_to_srgb(c):
|
|||||||
return 12.92 * c
|
return 12.92 * c
|
||||||
|
|
||||||
|
|
||||||
def get_nla_strips_ending_at_frame(object, frame) -> List[NlaStrip]:
|
def get_nla_strips_in_timeframe(animation_data, frame_min, frame_max) -> List[NlaStrip]:
|
||||||
if object is None or object.animation_data is None:
|
if animation_data is None:
|
||||||
return []
|
return []
|
||||||
strips = []
|
strips = []
|
||||||
for nla_track in object.animation_data.nla_tracks:
|
for nla_track in 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:
|
if nla_track.mute:
|
||||||
continue
|
continue
|
||||||
for strip in nla_track.strips:
|
for strip in nla_track.strips:
|
||||||
@@ -56,7 +47,26 @@ def get_nla_strips_in_timeframe(object, frame_min, frame_max) -> List[NlaStrip]:
|
|||||||
return strips
|
return strips
|
||||||
|
|
||||||
|
|
||||||
def populate_bone_group_list(armature_object, bone_group_list):
|
def populate_bone_group_list(armature_object: Object, bone_group_list: bpy.types.Collection) -> None:
|
||||||
|
"""
|
||||||
|
Updates the bone group collection.
|
||||||
|
|
||||||
|
Bone group selections are preserved between updates unless none of the groups were previously selected;
|
||||||
|
otherwise, all groups are selected by default.
|
||||||
|
"""
|
||||||
|
has_selected_groups = any([g.is_selected for g in bone_group_list])
|
||||||
|
unassigned_group_is_selected, selected_assigned_group_names = True, []
|
||||||
|
|
||||||
|
if has_selected_groups:
|
||||||
|
# Preserve group selections before clearing the list.
|
||||||
|
# We handle selections for the unassigned group separately to cover the edge case
|
||||||
|
# where there might be an actual group with 'Unassigned' as its name.
|
||||||
|
unassigned_group_idx, unassigned_group_is_selected = next(iter([
|
||||||
|
(i, g.is_selected) for i, g in enumerate(bone_group_list) if g.index == -1]), (-1, False))
|
||||||
|
|
||||||
|
selected_assigned_group_names = [
|
||||||
|
g.name for i, g in enumerate(bone_group_list) if i != unassigned_group_idx and g.is_selected]
|
||||||
|
|
||||||
bone_group_list.clear()
|
bone_group_list.clear()
|
||||||
|
|
||||||
if armature_object and armature_object.pose:
|
if armature_object and armature_object.pose:
|
||||||
@@ -66,14 +76,14 @@ def populate_bone_group_list(armature_object, bone_group_list):
|
|||||||
item.name = 'Unassigned'
|
item.name = 'Unassigned'
|
||||||
item.index = -1
|
item.index = -1
|
||||||
item.count = 0 if None not in bone_group_counts else bone_group_counts[None]
|
item.count = 0 if None not in bone_group_counts else bone_group_counts[None]
|
||||||
item.is_selected = True
|
item.is_selected = unassigned_group_is_selected
|
||||||
|
|
||||||
for bone_group_index, bone_group in enumerate(armature_object.pose.bone_groups):
|
for bone_group_index, bone_group in enumerate(armature_object.pose.bone_groups):
|
||||||
item = bone_group_list.add()
|
item = bone_group_list.add()
|
||||||
item.name = bone_group.name
|
item.name = bone_group.name
|
||||||
item.index = bone_group_index
|
item.index = bone_group_index
|
||||||
item.count = 0 if bone_group not in bone_group_counts else bone_group_counts[bone_group]
|
item.count = 0 if bone_group not in bone_group_counts else bone_group_counts[bone_group]
|
||||||
item.is_selected = True
|
item.is_selected = bone_group.name in selected_assigned_group_names if has_selected_groups else True
|
||||||
|
|
||||||
|
|
||||||
def get_psa_sequence_name(action, should_use_original_sequence_name):
|
def get_psa_sequence_name(action, should_use_original_sequence_name):
|
||||||
@@ -83,6 +93,14 @@ def get_psa_sequence_name(action, should_use_original_sequence_name):
|
|||||||
return action.name
|
return action.name
|
||||||
|
|
||||||
|
|
||||||
|
def check_bone_names(bone_names: Iterable[str]):
|
||||||
|
pattern = re.compile(r'^[a-zA-Z0-9_\- ]+$')
|
||||||
|
invalid_bone_names = [x for x in bone_names if pattern.match(x) is None]
|
||||||
|
if len(invalid_bone_names) > 0:
|
||||||
|
raise RuntimeError(f'The following bone names are invalid: {invalid_bone_names}.\n'
|
||||||
|
f'Bone names must only contain letters, numbers, spaces, hyphens and underscores.')
|
||||||
|
|
||||||
|
|
||||||
def get_export_bone_names(armature_object, bone_filter_mode, bone_group_indices: List[int]) -> List[str]:
|
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 filter mode and bone groups.
|
Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone groups.
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
from typing import Dict, Iterable
|
from typing import Dict
|
||||||
|
|
||||||
from bpy.types import Action
|
from bpy.types import Action, Armature, Bone
|
||||||
|
|
||||||
from .data import *
|
from .data import *
|
||||||
from ..helpers import *
|
from ..helpers import *
|
||||||
|
|
||||||
|
|
||||||
class PsaBuilderOptions(object):
|
class PsaBuildOptions(object):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self.should_override_animation_data = False
|
||||||
|
self.animation_data_override = None
|
||||||
self.fps_source = 'SCENE'
|
self.fps_source = 'SCENE'
|
||||||
self.fps_custom = 30.0
|
self.fps_custom = 30.0
|
||||||
self.sequence_source = 'ACTIONS'
|
self.sequence_source = 'ACTIONS'
|
||||||
@@ -17,267 +19,277 @@ class PsaBuilderOptions(object):
|
|||||||
self.bone_group_indices = []
|
self.bone_group_indices = []
|
||||||
self.should_use_original_sequence_names = False
|
self.should_use_original_sequence_names = False
|
||||||
self.should_trim_timeline_marker_sequences = True
|
self.should_trim_timeline_marker_sequences = True
|
||||||
|
self.should_ignore_bone_name_restrictions = False
|
||||||
self.sequence_name_prefix = ''
|
self.sequence_name_prefix = ''
|
||||||
self.sequence_name_suffix = ''
|
self.sequence_name_suffix = ''
|
||||||
|
self.root_motion = False
|
||||||
|
|
||||||
|
|
||||||
class PsaBuilderPerformance:
|
def get_sequence_fps(context, options: PsaBuildOptions, actions: Iterable[Action]) -> float:
|
||||||
def __init__(self):
|
if options.fps_source == 'SCENE':
|
||||||
self.frame_set_duration = datetime.timedelta()
|
return context.scene.render.fps
|
||||||
self.key_build_duration = datetime.timedelta()
|
if options.fps_source == 'CUSTOM':
|
||||||
self.key_add_duration = datetime.timedelta()
|
return options.fps_custom
|
||||||
|
elif options.fps_source == 'ACTION_METADATA':
|
||||||
|
# Get the minimum value of action metadata FPS values.
|
||||||
class PsaBuilder(object):
|
fps_list = []
|
||||||
def __init__(self):
|
for action in filter(lambda x: 'psa_sequence_fps' in x, actions):
|
||||||
pass
|
fps = action['psa_sequence_fps']
|
||||||
|
if type(fps) == int or type(fps) == float:
|
||||||
def get_sequence_fps(self, context, options: PsaBuilderOptions, actions: Iterable[Action]) -> float:
|
fps_list.append(fps)
|
||||||
if options.fps_source == 'SCENE':
|
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
|
return context.scene.render.fps
|
||||||
if options.fps_source == 'CUSTOM':
|
else:
|
||||||
return options.fps_custom
|
raise RuntimeError(f'Invalid FPS source "{options.fps_source}"')
|
||||||
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}"')
|
|
||||||
|
|
||||||
def build(self, context, options: PsaBuilderOptions) -> Psa:
|
|
||||||
performance = PsaBuilderPerformance()
|
|
||||||
active_object = context.view_layer.objects.active
|
|
||||||
|
|
||||||
if active_object.type != 'ARMATURE':
|
def get_timeline_marker_sequence_frame_ranges(animation_data, context, options: PsaBuildOptions) -> Dict:
|
||||||
raise RuntimeError('Selected object must be an Armature')
|
# 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))
|
||||||
|
|
||||||
armature = active_object
|
for marker_name in options.marker_names:
|
||||||
|
marker = context.scene.timeline_markers[marker_name]
|
||||||
if armature.animation_data is None:
|
frame_min = marker.frame
|
||||||
raise RuntimeError('No animation data for armature')
|
# 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.
|
||||||
# Ensure that we actually have items that we are going to be exporting.
|
marker_index = sorted_timeline_marker_names.index(marker_name)
|
||||||
if options.sequence_source == 'ACTIONS' and len(options.actions) == 0:
|
next_marker_index = marker_index + 1
|
||||||
raise RuntimeError('No actions were selected for export')
|
frame_max = 0
|
||||||
elif options.sequence_source == 'TIMELINE_MARKERS' and len(options.marker_names) == 0:
|
if next_marker_index < len(sorted_timeline_markers):
|
||||||
raise RuntimeError('No timeline markers were selected for export')
|
# 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
|
||||||
psa = Psa()
|
if options.should_trim_timeline_marker_sequences:
|
||||||
|
nla_strips = get_nla_strips_in_timeframe(animation_data, marker.frame, frame_max)
|
||||||
bones = list(armature.data.bones)
|
if len(nla_strips) > 0:
|
||||||
|
|
||||||
# The order of the armature bones and the pose bones is not guaranteed to be the same.
|
|
||||||
# As as a result, we need to reconstruct the list of pose bones in the same order as the
|
|
||||||
# armature bones.
|
|
||||||
bone_names = [x.name for x in bones]
|
|
||||||
pose_bones = [(bone_names.index(bone.name), bone) for bone in armature.pose.bones]
|
|
||||||
pose_bones.sort(key=lambda x: x[0])
|
|
||||||
pose_bones = [x[1] for x in pose_bones]
|
|
||||||
|
|
||||||
# Get a list of all the bone indices and instigator bones for the bone filter settings.
|
|
||||||
export_bone_names = get_export_bone_names(armature, 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:
|
|
||||||
raise RuntimeError('No bones available for export')
|
|
||||||
|
|
||||||
# Build list of PSA bones.
|
|
||||||
for bone in bones:
|
|
||||||
psa_bone = Psa.Bone()
|
|
||||||
psa_bone.name = bytes(bone.name, encoding='utf-8')
|
|
||||||
|
|
||||||
try:
|
|
||||||
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 bone.parent is not None:
|
|
||||||
rotation = bone.matrix.to_quaternion()
|
|
||||||
rotation.x = -rotation.x
|
|
||||||
rotation.y = -rotation.y
|
|
||||||
rotation.z = -rotation.z
|
|
||||||
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 @ bone.head
|
|
||||||
rot_matrix = bone.matrix @ armature.matrix_local.to_3x3()
|
|
||||||
rotation = rot_matrix.to_quaternion()
|
|
||||||
|
|
||||||
psa_bone.location.x = location.x
|
|
||||||
psa_bone.location.y = location.y
|
|
||||||
psa_bone.location.z = location.z
|
|
||||||
|
|
||||||
psa_bone.rotation.x = rotation.x
|
|
||||||
psa_bone.rotation.y = rotation.y
|
|
||||||
psa_bone.rotation.z = rotation.z
|
|
||||||
psa_bone.rotation.w = rotation.w
|
|
||||||
|
|
||||||
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 export_sequence in export_sequences:
|
|
||||||
armature.animation_data.action = export_sequence.nla_state.action
|
|
||||||
context.view_layer.update()
|
|
||||||
|
|
||||||
psa_sequence = Psa.Sequence()
|
|
||||||
|
|
||||||
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):
|
|
||||||
with Timer() as t:
|
|
||||||
context.scene.frame_set(frame_min + frame)
|
|
||||||
performance.frame_set_duration += t.duration
|
|
||||||
|
|
||||||
for pose_bone in pose_bones:
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
with Timer() as t:
|
|
||||||
psa.keys.append(key)
|
|
||||||
performance.key_add_duration += t.duration
|
|
||||||
|
|
||||||
psa_sequence.bone_count = len(pose_bones)
|
|
||||||
psa_sequence.track_time = frame_count
|
|
||||||
|
|
||||||
frame_start_index += frame_count
|
|
||||||
|
|
||||||
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_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)))
|
frame_min = max(frame_min, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips)))
|
||||||
else:
|
else:
|
||||||
# There is no next marker.
|
# No strips in between this marker and the next, just export this as a one-frame animation.
|
||||||
# Find the final frame of all the NLA strips and use that as the last frame of this sequence.
|
frame_max = frame_min
|
||||||
for nla_track in object.animation_data.nla_tracks:
|
else:
|
||||||
if nla_track.mute:
|
# There is no next marker.
|
||||||
continue
|
# Find the final frame of all the NLA strips and use that as the last frame of this sequence.
|
||||||
for strip in nla_track.strips:
|
for nla_track in animation_data.nla_tracks:
|
||||||
frame_max = max(frame_max, strip.frame_end)
|
if nla_track.mute:
|
||||||
|
continue
|
||||||
|
for strip in nla_track.strips:
|
||||||
|
frame_max = max(frame_max, strip.frame_end)
|
||||||
|
|
||||||
if frame_min == frame_max:
|
if frame_min > frame_max:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sequence_frame_ranges[marker_name] = int(frame_min), int(frame_max)
|
||||||
|
|
||||||
|
return sequence_frame_ranges
|
||||||
|
|
||||||
|
|
||||||
|
def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||||
|
active_object = context.view_layer.objects.active
|
||||||
|
|
||||||
|
if active_object.type != 'ARMATURE':
|
||||||
|
raise RuntimeError('Selected object must be an Armature')
|
||||||
|
|
||||||
|
if options.should_override_animation_data:
|
||||||
|
animation_data_object = options.animation_data_override
|
||||||
|
else:
|
||||||
|
animation_data_object = active_object
|
||||||
|
|
||||||
|
animation_data = animation_data_object.animation_data
|
||||||
|
|
||||||
|
if animation_data is None:
|
||||||
|
raise RuntimeError(f'No animation data for object \'{animation_data_object.name}\'')
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
armature = active_object
|
||||||
|
armature_data = typing.cast(Armature, armature)
|
||||||
|
bones: List[Bone] = list(iter(armature_data.bones))
|
||||||
|
|
||||||
|
# The order of the armature bones and the pose bones is not guaranteed to be the same.
|
||||||
|
# As a result, we need to reconstruct the list of pose bones in the same order as the
|
||||||
|
# armature bones.
|
||||||
|
bone_names = [x.name for x in bones]
|
||||||
|
pose_bones = [(bone_names.index(bone.name), bone) for bone in armature.pose.bones]
|
||||||
|
pose_bones.sort(key=lambda x: x[0])
|
||||||
|
pose_bones = [x[1] for x in pose_bones]
|
||||||
|
|
||||||
|
# Get a list of all the bone indices and instigator bones for the bone filter settings.
|
||||||
|
export_bone_names = get_export_bone_names(armature, 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:
|
||||||
|
raise RuntimeError('No bones available for export')
|
||||||
|
|
||||||
|
# Check that all bone names are valid.
|
||||||
|
if not options.should_ignore_bone_name_restrictions:
|
||||||
|
check_bone_names(map(lambda bone: bone.name, bones))
|
||||||
|
|
||||||
|
# Build list of PSA bones.
|
||||||
|
for bone in bones:
|
||||||
|
psa_bone = Psa.Bone()
|
||||||
|
psa_bone.name = bytes(bone.name, encoding='windows-1252')
|
||||||
|
|
||||||
|
try:
|
||||||
|
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 bone.parent is not None:
|
||||||
|
rotation = bone.matrix.to_quaternion().conjugated()
|
||||||
|
inverse_parent_rotation = bone.parent.matrix.to_quaternion().inverted()
|
||||||
|
parent_head = inverse_parent_rotation @ bone.parent.head
|
||||||
|
parent_tail = inverse_parent_rotation @ bone.parent.tail
|
||||||
|
location = (parent_tail - parent_head) + bone.head
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
psa_bone.location.y = location.y
|
||||||
|
psa_bone.location.z = location.z
|
||||||
|
|
||||||
|
psa_bone.rotation.x = rotation.x
|
||||||
|
psa_bone.rotation.y = rotation.y
|
||||||
|
psa_bone.rotation.z = rotation.z
|
||||||
|
psa_bone.rotation.w = rotation.w
|
||||||
|
|
||||||
|
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
|
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 = get_sequence_fps(context, options, [action])
|
||||||
|
export_sequences.append(export_sequence)
|
||||||
|
pass
|
||||||
|
elif options.sequence_source == 'TIMELINE_MARKERS':
|
||||||
|
sequence_frame_ranges = get_timeline_marker_sequence_frame_ranges(animation_data, context, options)
|
||||||
|
|
||||||
sequence_frame_ranges[marker_name] = int(frame_min), int(frame_max)
|
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
|
||||||
|
|
||||||
return sequence_frame_ranges
|
nla_strips_actions = set(
|
||||||
|
map(lambda x: x.action, get_nla_strips_in_timeframe(animation_data, frame_min, frame_max)))
|
||||||
|
export_sequence.fps = 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}'
|
||||||
|
export_sequence.name = export_sequence.name.strip()
|
||||||
|
|
||||||
|
# Save the current action and frame so that we can restore the state once we are done.
|
||||||
|
saved_frame_current = context.scene.frame_current
|
||||||
|
saved_action = animation_data.action
|
||||||
|
|
||||||
|
# Now build the PSA sequences.
|
||||||
|
# We actually alter the timeline frame and simply record the resultant pose bone matrices.
|
||||||
|
frame_start_index = 0
|
||||||
|
|
||||||
|
for export_sequence in export_sequences:
|
||||||
|
# Link the action to the animation data and update view layer.
|
||||||
|
animation_data.action = export_sequence.nla_state.action
|
||||||
|
context.view_layer.update()
|
||||||
|
|
||||||
|
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 = Psa.Sequence()
|
||||||
|
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
|
||||||
|
|
||||||
|
for frame in range(frame_count):
|
||||||
|
context.scene.frame_set(frame_min + frame)
|
||||||
|
|
||||||
|
for pose_bone in pose_bones:
|
||||||
|
key = Psa.Key()
|
||||||
|
|
||||||
|
if pose_bone.parent is not None:
|
||||||
|
pose_bone_matrix = pose_bone.matrix
|
||||||
|
pose_bone_parent_matrix = pose_bone.parent.matrix
|
||||||
|
pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix
|
||||||
|
else:
|
||||||
|
if options.root_motion:
|
||||||
|
# Export root motion
|
||||||
|
pose_bone_matrix = armature.matrix_world @ pose_bone.matrix
|
||||||
|
else:
|
||||||
|
pose_bone_matrix = pose_bone.matrix
|
||||||
|
|
||||||
|
location = pose_bone_matrix.to_translation()
|
||||||
|
rotation = pose_bone_matrix.to_quaternion().normalized()
|
||||||
|
|
||||||
|
if pose_bone.parent is not None:
|
||||||
|
rotation.conjugate()
|
||||||
|
|
||||||
|
key.location.x = location.x
|
||||||
|
key.location.y = location.y
|
||||||
|
key.location.z = location.z
|
||||||
|
key.rotation.x = rotation.x
|
||||||
|
key.rotation.y = rotation.y
|
||||||
|
key.rotation.z = rotation.z
|
||||||
|
key.rotation.w = rotation.w
|
||||||
|
key.time = 1.0 / psa_sequence.fps
|
||||||
|
|
||||||
|
psa.keys.append(key)
|
||||||
|
|
||||||
|
psa_sequence.bone_count = len(pose_bones)
|
||||||
|
psa_sequence.track_time = frame_count
|
||||||
|
|
||||||
|
frame_start_index += frame_count
|
||||||
|
|
||||||
|
psa.sequences[export_sequence.name] = psa_sequence
|
||||||
|
|
||||||
|
# Restore the previous action & frame.
|
||||||
|
animation_data.action = saved_action
|
||||||
|
context.scene.frame_set(saved_frame_current)
|
||||||
|
|
||||||
|
return psa
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from ..data import *
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
Note that keys are not stored within the Psa object.
|
Note that keys are not stored within the Psa object.
|
||||||
Use the PsaReader::get_sequence_keys to get a the keys for a sequence.
|
Use the PsaReader::get_sequence_keys to get the keys for a sequence.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -60,5 +60,5 @@ class Psa(object):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.bones: List[Psa.Bone] = []
|
self.bones: List[Psa.Bone] = []
|
||||||
self.sequences: typing.OrderedDict[Psa.Sequence] = OrderedDict()
|
self.sequences: typing.OrderedDict[str, Psa.Sequence] = OrderedDict()
|
||||||
self.keys: List[Psa.Key] = []
|
self.keys: List[Psa.Key] = []
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import fnmatch
|
import fnmatch
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from collections import Counter
|
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
@@ -10,35 +8,30 @@ from bpy.props import BoolProperty, CollectionProperty, EnumProperty, FloatPrope
|
|||||||
from bpy.types import Action, Operator, PropertyGroup, UIList
|
from bpy.types import Action, Operator, PropertyGroup, UIList
|
||||||
from bpy_extras.io_utils import ExportHelper
|
from bpy_extras.io_utils import ExportHelper
|
||||||
|
|
||||||
from .builder import PsaBuilder, PsaBuilderOptions
|
from .builder import PsaBuildOptions, build_psa
|
||||||
from .data import *
|
from .data import *
|
||||||
from ..helpers import *
|
from ..helpers import *
|
||||||
from ..types import BoneGroupListItem
|
from ..types import BoneGroupListItem
|
||||||
|
|
||||||
|
|
||||||
class PsaExporter(object):
|
def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None):
|
||||||
def __init__(self, psa: Psa):
|
section = Section()
|
||||||
self.psa: Psa = psa
|
section.name = name
|
||||||
|
if data_type is not None and data is not None:
|
||||||
|
section.data_size = sizeof(data_type)
|
||||||
|
section.data_count = len(data)
|
||||||
|
fp.write(section)
|
||||||
|
if data is not None:
|
||||||
|
for datum in data:
|
||||||
|
fp.write(datum)
|
||||||
|
|
||||||
# This method is shared by both PSA/K file formats, move this?
|
|
||||||
@staticmethod
|
|
||||||
def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None):
|
|
||||||
section = Section()
|
|
||||||
section.name = name
|
|
||||||
if data_type is not None and data is not None:
|
|
||||||
section.data_size = sizeof(data_type)
|
|
||||||
section.data_count = len(data)
|
|
||||||
fp.write(section)
|
|
||||||
if data is not None:
|
|
||||||
for datum in data:
|
|
||||||
fp.write(datum)
|
|
||||||
|
|
||||||
def export(self, path: str):
|
def export_psa(psa: Psa, path: str):
|
||||||
with open(path, 'wb') as fp:
|
with open(path, 'wb') as fp:
|
||||||
self.write_section(fp, b'ANIMHEAD')
|
write_section(fp, b'ANIMHEAD')
|
||||||
self.write_section(fp, b'BONENAMES', Psa.Bone, self.psa.bones)
|
write_section(fp, b'BONENAMES', Psa.Bone, psa.bones)
|
||||||
self.write_section(fp, b'ANIMINFO', Psa.Sequence, list(self.psa.sequences.values()))
|
write_section(fp, b'ANIMINFO', Psa.Sequence, list(psa.sequences.values()))
|
||||||
self.write_section(fp, b'ANIMKEYS', Psa.Key, self.psa.keys)
|
write_section(fp, b'ANIMKEYS', Psa.Key, psa.keys)
|
||||||
|
|
||||||
|
|
||||||
class PsaExportActionListItem(PropertyGroup):
|
class PsaExportActionListItem(PropertyGroup):
|
||||||
@@ -64,10 +57,33 @@ def should_use_original_sequence_names_updated(_, context):
|
|||||||
update_action_names(context)
|
update_action_names(context)
|
||||||
|
|
||||||
|
|
||||||
|
def psa_export_property_group_animation_data_override_poll(_context, obj):
|
||||||
|
return obj.animation_data is not None
|
||||||
|
|
||||||
|
|
||||||
|
empty_set = set()
|
||||||
|
|
||||||
|
|
||||||
class PsaExportPropertyGroup(PropertyGroup):
|
class PsaExportPropertyGroup(PropertyGroup):
|
||||||
|
root_motion: BoolProperty(
|
||||||
|
name='Root Motion',
|
||||||
|
options=empty_set,
|
||||||
|
default=False,
|
||||||
|
description='The root bone will be transformed as it appears in the scene',
|
||||||
|
)
|
||||||
|
should_override_animation_data: BoolProperty(
|
||||||
|
name='Override Animation Data',
|
||||||
|
options=empty_set,
|
||||||
|
default=False,
|
||||||
|
description='Use the animation data from a different object instead of the selected object'
|
||||||
|
)
|
||||||
|
animation_data_override: PointerProperty(
|
||||||
|
type=bpy.types.Object,
|
||||||
|
poll=psa_export_property_group_animation_data_override_poll
|
||||||
|
)
|
||||||
sequence_source: EnumProperty(
|
sequence_source: EnumProperty(
|
||||||
name='Source',
|
name='Source',
|
||||||
options=set(),
|
options=empty_set,
|
||||||
description='',
|
description='',
|
||||||
items=(
|
items=(
|
||||||
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
|
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
|
||||||
@@ -77,7 +93,7 @@ class PsaExportPropertyGroup(PropertyGroup):
|
|||||||
)
|
)
|
||||||
fps_source: EnumProperty(
|
fps_source: EnumProperty(
|
||||||
name='FPS Source',
|
name='FPS Source',
|
||||||
options=set(),
|
options=empty_set,
|
||||||
description='',
|
description='',
|
||||||
items=(
|
items=(
|
||||||
('SCENE', 'Scene', '', 'SCENE_DATA', 0),
|
('SCENE', 'Scene', '', 'SCENE_DATA', 0),
|
||||||
@@ -87,7 +103,7 @@ class PsaExportPropertyGroup(PropertyGroup):
|
|||||||
('CUSTOM', 'Custom', '', 2)
|
('CUSTOM', 'Custom', '', 2)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=set(), step=100,
|
fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=empty_set, step=100,
|
||||||
soft_max=60.0)
|
soft_max=60.0)
|
||||||
action_list: CollectionProperty(type=PsaExportActionListItem)
|
action_list: CollectionProperty(type=PsaExportActionListItem)
|
||||||
action_list_index: IntProperty(default=0)
|
action_list_index: IntProperty(default=0)
|
||||||
@@ -95,7 +111,7 @@ class PsaExportPropertyGroup(PropertyGroup):
|
|||||||
marker_list_index: IntProperty(default=0)
|
marker_list_index: IntProperty(default=0)
|
||||||
bone_filter_mode: EnumProperty(
|
bone_filter_mode: EnumProperty(
|
||||||
name='Bone Filter',
|
name='Bone Filter',
|
||||||
options=set(),
|
options=empty_set,
|
||||||
description='',
|
description='',
|
||||||
items=(
|
items=(
|
||||||
('ALL', 'All', 'All bones will be exported.'),
|
('ALL', 'All', 'All bones will be exported.'),
|
||||||
@@ -108,7 +124,7 @@ class PsaExportPropertyGroup(PropertyGroup):
|
|||||||
should_use_original_sequence_names: BoolProperty(
|
should_use_original_sequence_names: BoolProperty(
|
||||||
default=False,
|
default=False,
|
||||||
name='Original Names',
|
name='Original Names',
|
||||||
options=set(),
|
options=empty_set,
|
||||||
update=should_use_original_sequence_names_updated,
|
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 '
|
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',
|
'used instead of the Blender action name',
|
||||||
@@ -116,17 +132,34 @@ class PsaExportPropertyGroup(PropertyGroup):
|
|||||||
should_trim_timeline_marker_sequences: BoolProperty(
|
should_trim_timeline_marker_sequences: BoolProperty(
|
||||||
default=True,
|
default=True,
|
||||||
name='Trim Sequences',
|
name='Trim Sequences',
|
||||||
options=set(),
|
options=empty_set,
|
||||||
description='Frames without NLA track information at the boundaries of timeline markers will be excluded from '
|
description='Frames without NLA track information at the boundaries of timeline markers will be excluded from '
|
||||||
'the exported sequences '
|
'the exported sequences '
|
||||||
)
|
)
|
||||||
sequence_name_prefix: StringProperty(name='Prefix', options=set())
|
should_ignore_bone_name_restrictions: BoolProperty(
|
||||||
sequence_name_suffix: StringProperty(name='Suffix', options=set())
|
default=False,
|
||||||
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
|
name='Ignore Bone Name Restrictions',
|
||||||
sequence_use_filter_invert: BoolProperty(default=False, options=set())
|
description='Bone names restrictions will be ignored. Note that bone names without properly formatted names '
|
||||||
sequence_filter_asset: BoolProperty(default=False, name='Show assets',
|
'cannot be referenced in scripts.'
|
||||||
description='Show actions that belong to an asset library', options=set())
|
)
|
||||||
sequence_use_filter_sort_reverse: BoolProperty(default=True, options=set())
|
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
|
||||||
|
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)
|
||||||
|
sequence_filter_name: StringProperty(
|
||||||
|
default='',
|
||||||
|
name='Filter by Name',
|
||||||
|
options={'TEXTEDIT_UPDATE'},
|
||||||
|
description='Only show items matching this name (use \'*\' as wildcard)')
|
||||||
|
sequence_use_filter_invert: BoolProperty(
|
||||||
|
default=False,
|
||||||
|
name='Invert',
|
||||||
|
options=empty_set,
|
||||||
|
description='Invert filtering (show hidden items, and vice versa)')
|
||||||
|
sequence_filter_asset: BoolProperty(
|
||||||
|
default=False,
|
||||||
|
name='Show assets',
|
||||||
|
options=empty_set,
|
||||||
|
description='Show actions that belong to an asset library')
|
||||||
|
sequence_use_filter_sort_reverse: BoolProperty(default=True, options=empty_set)
|
||||||
|
|
||||||
|
|
||||||
def is_bone_filter_mode_item_available(context, identifier):
|
def is_bone_filter_mode_item_available(context, identifier):
|
||||||
@@ -153,9 +186,18 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.armature = None
|
self.armature = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
try:
|
||||||
|
cls._check_context(context)
|
||||||
|
except RuntimeError as e:
|
||||||
|
cls.poll_message_set(str(e))
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
pg = context.scene.psa_export
|
pg = getattr(context.scene, 'psa_export')
|
||||||
|
|
||||||
# FPS
|
# FPS
|
||||||
layout.prop(pg, 'fps_source', text='FPS')
|
layout.prop(pg, 'fps_source', text='FPS')
|
||||||
@@ -165,6 +207,12 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
# SOURCE
|
# SOURCE
|
||||||
layout.prop(pg, 'sequence_source', text='Source')
|
layout.prop(pg, 'sequence_source', text='Source')
|
||||||
|
|
||||||
|
if pg.sequence_source == 'TIMELINE_MARKERS':
|
||||||
|
# ANIMDATA SOURCE
|
||||||
|
layout.prop(pg, 'should_override_animation_data')
|
||||||
|
if pg.should_override_animation_data:
|
||||||
|
layout.prop(pg, 'animation_data_override', text='')
|
||||||
|
|
||||||
# SELECT ALL/NONE
|
# SELECT ALL/NONE
|
||||||
row = layout.row(align=True)
|
row = layout.row(align=True)
|
||||||
row.label(text='Select')
|
row.label(text='Select')
|
||||||
@@ -175,7 +223,7 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
if pg.sequence_source == 'ACTIONS':
|
if pg.sequence_source == 'ACTIONS':
|
||||||
rows = max(3, min(len(pg.action_list), 10))
|
rows = max(3, min(len(pg.action_list), 10))
|
||||||
|
|
||||||
layout.template_list('PSA_UL_ExportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows)
|
layout.template_list('PSA_UL_ExportSequenceList', '', pg, 'action_list', pg, 'action_list_index', rows=rows)
|
||||||
|
|
||||||
col = layout.column()
|
col = layout.column()
|
||||||
col.use_property_split = True
|
col.use_property_split = True
|
||||||
@@ -186,7 +234,7 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
|
|
||||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||||
rows = max(3, min(len(pg.marker_list), 10))
|
rows = max(3, min(len(pg.marker_list), 10))
|
||||||
layout.template_list('PSA_UL_ExportTimelineMarkerList', '', pg, 'marker_list', pg, 'marker_list_index',
|
layout.template_list('PSA_UL_ExportSequenceList', '', pg, 'marker_list', pg, 'marker_list_index',
|
||||||
rows=rows)
|
rows=rows)
|
||||||
|
|
||||||
col = layout.column()
|
col = layout.column()
|
||||||
@@ -220,15 +268,19 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index',
|
layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index',
|
||||||
rows=rows)
|
rows=rows)
|
||||||
|
|
||||||
def should_action_be_selected_by_default(self, action):
|
layout.prop(pg, 'should_ignore_bone_name_restrictions')
|
||||||
return action is not None and action.asset_data is None
|
|
||||||
|
layout.separator()
|
||||||
|
|
||||||
|
# ROOT MOTION
|
||||||
|
layout.prop(pg, 'root_motion', text='Root Motion')
|
||||||
|
|
||||||
def is_action_for_armature(self, action):
|
def is_action_for_armature(self, action):
|
||||||
if len(action.fcurves) == 0:
|
if len(action.fcurves) == 0:
|
||||||
return False
|
return False
|
||||||
bone_names = set([x.name for x in self.armature.data.bones])
|
bone_names = set([x.name for x in self.armature.data.bones])
|
||||||
for fcurve in action.fcurves:
|
for fcurve in action.fcurves:
|
||||||
match = re.match(r'pose\.bones\["(.+)"\].\w+', fcurve.data_path)
|
match = re.match(r'pose\.bones\["(.+)"].\w+', fcurve.data_path)
|
||||||
if not match:
|
if not match:
|
||||||
continue
|
continue
|
||||||
bone_name = match.group(1)
|
bone_name = match.group(1)
|
||||||
@@ -236,17 +288,21 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def invoke(self, context, event):
|
@classmethod
|
||||||
pg = context.scene.psa_export
|
def _check_context(cls, context):
|
||||||
|
|
||||||
if context.view_layer.objects.active is None:
|
if context.view_layer.objects.active is None:
|
||||||
self.report({'ERROR_INVALID_CONTEXT'}, 'An armature must be selected')
|
raise RuntimeError('An armature must be selected')
|
||||||
return {'CANCELLED'}
|
|
||||||
|
|
||||||
if context.view_layer.objects.active.type != 'ARMATURE':
|
if context.view_layer.objects.active.type != 'ARMATURE':
|
||||||
self.report({'ERROR_INVALID_CONTEXT'}, 'The selected object must be an armature.')
|
raise RuntimeError('The selected object must be an armature')
|
||||||
return {'CANCELLED'}
|
|
||||||
|
|
||||||
|
def invoke(self, context, _event):
|
||||||
|
try:
|
||||||
|
self._check_context(context)
|
||||||
|
except RuntimeError as e:
|
||||||
|
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||||
|
|
||||||
|
pg = getattr(context.scene, 'psa_export')
|
||||||
self.armature = context.view_layer.objects.active
|
self.armature = context.view_layer.objects.active
|
||||||
|
|
||||||
# Populate actions list.
|
# Populate actions list.
|
||||||
@@ -257,7 +313,7 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
item = pg.action_list.add()
|
item = pg.action_list.add()
|
||||||
item.action = action
|
item.action = action
|
||||||
item.name = action.name
|
item.name = action.name
|
||||||
item.is_selected = self.should_action_be_selected_by_default(action)
|
item.is_selected = False
|
||||||
|
|
||||||
update_action_names(context)
|
update_action_names(context)
|
||||||
|
|
||||||
@@ -266,6 +322,7 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
for marker in context.scene.timeline_markers:
|
for marker in context.scene.timeline_markers:
|
||||||
item = pg.marker_list.add()
|
item = pg.marker_list.add()
|
||||||
item.name = marker.name
|
item.name = marker.name
|
||||||
|
item.is_selected = False
|
||||||
|
|
||||||
if len(pg.action_list) == 0 and len(pg.marker_list) == 0:
|
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.
|
# If there are no actions at all, we have nothing to export, so just cancel the operation.
|
||||||
@@ -280,12 +337,14 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
return {'RUNNING_MODAL'}
|
return {'RUNNING_MODAL'}
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psa_export
|
pg = getattr(context.scene, 'psa_export')
|
||||||
|
|
||||||
actions = [x.action for x in pg.action_list if x.is_selected]
|
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]
|
marker_names = [x.name for x in pg.marker_list if x.is_selected]
|
||||||
|
|
||||||
options = PsaBuilderOptions()
|
options = PsaBuildOptions()
|
||||||
|
options.should_override_animation_data = pg.should_override_animation_data
|
||||||
|
options.animation_data_override = pg.animation_data_override
|
||||||
options.fps_source = pg.fps_source
|
options.fps_source = pg.fps_source
|
||||||
options.fps_custom = pg.fps_custom
|
options.fps_custom = pg.fps_custom
|
||||||
options.sequence_source = pg.sequence_source
|
options.sequence_source = pg.sequence_source
|
||||||
@@ -295,59 +354,47 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
|
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_use_original_sequence_names = pg.should_use_original_sequence_names
|
||||||
options.should_trim_timeline_marker_sequences = pg.should_trim_timeline_marker_sequences
|
options.should_trim_timeline_marker_sequences = pg.should_trim_timeline_marker_sequences
|
||||||
|
options.should_ignore_bone_name_restrictions = pg.should_ignore_bone_name_restrictions
|
||||||
options.sequence_name_prefix = pg.sequence_name_prefix
|
options.sequence_name_prefix = pg.sequence_name_prefix
|
||||||
options.sequence_name_suffix = pg.sequence_name_suffix
|
options.sequence_name_suffix = pg.sequence_name_suffix
|
||||||
|
options.root_motion = pg.root_motion
|
||||||
builder = PsaBuilder()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
psa = builder.build(context, options)
|
psa = build_psa(context, options)
|
||||||
|
self.report({'INFO'}, f'PSA export successful')
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||||
return {'CANCELLED'}
|
return {'CANCELLED'}
|
||||||
|
|
||||||
exporter = PsaExporter(psa)
|
export_psa(psa, self.filepath)
|
||||||
exporter.export(self.filepath)
|
|
||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
class PSA_UL_ExportTimelineMarkerList(UIList):
|
def filter_sequences(pg: PsaExportPropertyGroup, sequences) -> List[int]:
|
||||||
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)
|
|
||||||
|
|
||||||
def filter_items(self, context, data, property):
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def filter_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[int]:
|
|
||||||
bitflag_filter_item = 1 << 30
|
bitflag_filter_item = 1 << 30
|
||||||
flt_flags = [bitflag_filter_item] * len(sequences)
|
flt_flags = [bitflag_filter_item] * len(sequences)
|
||||||
|
|
||||||
if pg.sequence_filter_name is not None:
|
if pg.sequence_filter_name:
|
||||||
# Filter name is non-empty.
|
# Filter name is non-empty.
|
||||||
for i, sequence in enumerate(sequences):
|
for i, sequence in enumerate(sequences):
|
||||||
if not fnmatch.fnmatch(sequence.name, f'*{pg.sequence_filter_name}*'):
|
if not fnmatch.fnmatch(sequence.name, f'*{pg.sequence_filter_name}*'):
|
||||||
flt_flags[i] &= ~bitflag_filter_item
|
flt_flags[i] &= ~bitflag_filter_item
|
||||||
|
|
||||||
|
# Invert filter flags for all items.
|
||||||
|
if pg.sequence_use_filter_invert:
|
||||||
|
for i, sequence in enumerate(sequences):
|
||||||
|
flt_flags[i] ^= bitflag_filter_item
|
||||||
|
|
||||||
if not pg.sequence_filter_asset:
|
if not pg.sequence_filter_asset:
|
||||||
for i, sequence in enumerate(sequences):
|
for i, sequence in enumerate(sequences):
|
||||||
if hasattr(sequence, 'action') and sequence.action.asset_data is not None:
|
if hasattr(sequence, 'action') and sequence.action.asset_data is not None:
|
||||||
flt_flags[i] &= ~bitflag_filter_item
|
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
|
return flt_flags
|
||||||
|
|
||||||
|
|
||||||
def get_visible_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[
|
def get_visible_sequences(pg: PsaExportPropertyGroup, sequences) -> List[PsaExportActionListItem]:
|
||||||
PsaExportActionListItem]:
|
|
||||||
visible_sequences = []
|
visible_sequences = []
|
||||||
for i, flag in enumerate(filter_sequences(pg, sequences)):
|
for i, flag in enumerate(filter_sequences(pg, sequences)):
|
||||||
if bool(flag & (1 << 30)):
|
if bool(flag & (1 << 30)):
|
||||||
@@ -355,31 +402,33 @@ def get_visible_sequences(pg: PsaExportPropertyGroup, sequences: bpy.types.bpy_p
|
|||||||
return visible_sequences
|
return visible_sequences
|
||||||
|
|
||||||
|
|
||||||
class PSA_UL_ExportActionList(UIList):
|
class PSA_UL_ExportSequenceList(UIList):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(PSA_UL_ExportActionList, self).__init__()
|
super(PSA_UL_ExportSequenceList, self).__init__()
|
||||||
# Show the filtering options by default.
|
# Show the filtering options by default.
|
||||||
self.use_filter_show = True
|
self.use_filter_show = True
|
||||||
|
|
||||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||||
layout.prop(item, 'is_selected', icon_only=True, text=item.name)
|
layout.prop(item, 'is_selected', icon_only=True, text=item.name)
|
||||||
if item.action.asset_data is not None:
|
if hasattr(item, 'action') and item.action.asset_data is not None:
|
||||||
layout.label(text='', icon='ASSET_MANAGER')
|
layout.label(text='', icon='ASSET_MANAGER')
|
||||||
|
|
||||||
def draw_filter(self, context, layout):
|
def draw_filter(self, context, layout):
|
||||||
pg = context.scene.psa_export
|
pg = getattr(context.scene, 'psa_export')
|
||||||
row = layout.row()
|
row = layout.row()
|
||||||
subrow = row.row(align=True)
|
subrow = row.row(align=True)
|
||||||
subrow.prop(pg, 'sequence_filter_name', text="")
|
subrow.prop(pg, 'sequence_filter_name', text="")
|
||||||
subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
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')
|
# subrow.prop(pg, 'sequence_use_filter_sort_reverse', text='', icon='SORT_ASC')
|
||||||
|
|
||||||
def filter_items(self, context, data, property):
|
if pg.sequence_source == 'ACTIONS':
|
||||||
pg = context.scene.psa_export
|
subrow = row.row(align=True)
|
||||||
actions = getattr(data, property)
|
subrow.prop(pg, 'sequence_filter_asset', icon_only=True, icon='ASSET_MANAGER')
|
||||||
|
|
||||||
|
def filter_items(self, context, data, prop):
|
||||||
|
pg = getattr(context.scene, 'psa_export')
|
||||||
|
actions = getattr(data, prop)
|
||||||
flt_flags = filter_sequences(pg, actions)
|
flt_flags = filter_sequences(pg, actions)
|
||||||
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name')
|
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name')
|
||||||
return flt_flags, flt_neworder
|
return flt_flags, flt_neworder
|
||||||
@@ -402,14 +451,14 @@ class PsaExportActionsSelectAll(Operator):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
pg = context.scene.psa_export
|
pg = getattr(context.scene, 'psa_export')
|
||||||
item_list = cls.get_item_list(context)
|
item_list = cls.get_item_list(context)
|
||||||
visible_sequences = get_visible_sequences(pg, item_list)
|
visible_sequences = get_visible_sequences(pg, item_list)
|
||||||
has_unselected_sequences = any(map(lambda item: not item.is_selected, visible_sequences))
|
has_unselected_sequences = any(map(lambda item: not item.is_selected, visible_sequences))
|
||||||
return has_unselected_sequences
|
return has_unselected_sequences
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psa_export
|
pg = getattr(context.scene, 'psa_export')
|
||||||
sequences = self.get_item_list(context)
|
sequences = self.get_item_list(context)
|
||||||
for sequence in get_visible_sequences(pg, sequences):
|
for sequence in get_visible_sequences(pg, sequences):
|
||||||
sequence.is_selected = True
|
sequence.is_selected = True
|
||||||
@@ -438,7 +487,7 @@ class PsaExportActionsDeselectAll(Operator):
|
|||||||
return len(item_list) > 0 and has_selected_items
|
return len(item_list) > 0 and has_selected_items
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psa_export
|
pg = getattr(context.scene, 'psa_export')
|
||||||
item_list = self.get_item_list(context)
|
item_list = self.get_item_list(context)
|
||||||
for sequence in get_visible_sequences(pg, item_list):
|
for sequence in get_visible_sequences(pg, item_list):
|
||||||
sequence.is_selected = False
|
sequence.is_selected = False
|
||||||
@@ -453,13 +502,13 @@ class PsaExportBoneGroupsSelectAll(Operator):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
pg = context.scene.psa_export
|
pg = getattr(context.scene, 'psa_export')
|
||||||
item_list = pg.bone_group_list
|
item_list = pg.bone_group_list
|
||||||
has_unselected_items = any(map(lambda action: not action.is_selected, item_list))
|
has_unselected_items = any(map(lambda action: not action.is_selected, item_list))
|
||||||
return len(item_list) > 0 and has_unselected_items
|
return len(item_list) > 0 and has_unselected_items
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psa_export
|
pg = getattr(context.scene, 'psa_export')
|
||||||
for item in pg.bone_group_list:
|
for item in pg.bone_group_list:
|
||||||
item.is_selected = True
|
item.is_selected = True
|
||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
@@ -473,13 +522,13 @@ class PsaExportBoneGroupsDeselectAll(Operator):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
pg = context.scene.psa_export
|
pg = getattr(context.scene, 'psa_export')
|
||||||
item_list = pg.bone_group_list
|
item_list = pg.bone_group_list
|
||||||
has_selected_actions = any(map(lambda action: action.is_selected, item_list))
|
has_selected_actions = any(map(lambda action: action.is_selected, item_list))
|
||||||
return len(item_list) > 0 and has_selected_actions
|
return len(item_list) > 0 and has_selected_actions
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psa_export
|
pg = getattr(context.scene, 'psa_export')
|
||||||
for action in pg.bone_group_list:
|
for action in pg.bone_group_list:
|
||||||
action.is_selected = False
|
action.is_selected = False
|
||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
@@ -490,8 +539,7 @@ classes = (
|
|||||||
PsaExportTimelineMarkerListItem,
|
PsaExportTimelineMarkerListItem,
|
||||||
PsaExportPropertyGroup,
|
PsaExportPropertyGroup,
|
||||||
PsaExportOperator,
|
PsaExportOperator,
|
||||||
PSA_UL_ExportActionList,
|
PSA_UL_ExportSequenceList,
|
||||||
PSA_UL_ExportTimelineMarkerList,
|
|
||||||
PsaExportActionsSelectAll,
|
PsaExportActionsSelectAll,
|
||||||
PsaExportActionsDeselectAll,
|
PsaExportActionsDeselectAll,
|
||||||
PsaExportBoneGroupsSelectAll,
|
PsaExportBoneGroupsSelectAll,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import re
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
import numpy as np
|
|
||||||
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty
|
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty
|
||||||
from bpy.types import Operator, UIList, PropertyGroup, Panel
|
from bpy.types import Operator, UIList, PropertyGroup, Panel
|
||||||
from bpy_extras.io_utils import ImportHelper
|
from bpy_extras.io_utils import ImportHelper
|
||||||
@@ -16,7 +15,6 @@ from .reader import PsaReader
|
|||||||
|
|
||||||
class PsaImportOptions(object):
|
class PsaImportOptions(object):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.should_clean_keys = True
|
|
||||||
self.should_use_fake_user = False
|
self.should_use_fake_user = False
|
||||||
self.should_stash = False
|
self.should_stash = False
|
||||||
self.sequence_names = []
|
self.sequence_names = []
|
||||||
@@ -24,205 +22,187 @@ class PsaImportOptions(object):
|
|||||||
self.should_write_keyframes = True
|
self.should_write_keyframes = True
|
||||||
self.should_write_metadata = True
|
self.should_write_metadata = True
|
||||||
self.action_name_prefix = ''
|
self.action_name_prefix = ''
|
||||||
|
self.should_convert_to_samples = False
|
||||||
|
|
||||||
|
|
||||||
class PsaImporter(object):
|
class ImportBone(object):
|
||||||
def __init__(self):
|
def __init__(self, psa_bone: Psa.Bone):
|
||||||
pass
|
self.psa_bone: Psa.Bone = psa_bone
|
||||||
|
self.parent: Optional[ImportBone] = None
|
||||||
|
self.armature_bone = None
|
||||||
|
self.pose_bone = None
|
||||||
|
self.orig_loc: Vector = Vector()
|
||||||
|
self.orig_quat: Quaternion = Quaternion()
|
||||||
|
self.post_quat: Quaternion = Quaternion()
|
||||||
|
self.fcurves = []
|
||||||
|
|
||||||
def import_psa(self, psa_reader: PsaReader, armature_object, options: PsaImportOptions):
|
|
||||||
sequences = map(lambda x: psa_reader.sequences[x], options.sequence_names)
|
|
||||||
armature_data = armature_object.data
|
|
||||||
|
|
||||||
class ImportBone(object):
|
def calculate_fcurve_data(import_bone: ImportBone, key_data: []):
|
||||||
def __init__(self, psa_bone: Psa.Bone):
|
# Convert world-space transforms to local-space transforms.
|
||||||
self.psa_bone: Psa.Bone = psa_bone
|
key_rotation = Quaternion(key_data[0:4])
|
||||||
self.parent: Optional[ImportBone] = None
|
key_location = Vector(key_data[4:])
|
||||||
self.armature_bone = None
|
q = import_bone.post_quat.copy()
|
||||||
self.pose_bone = None
|
q.rotate(import_bone.orig_quat)
|
||||||
self.orig_loc: Vector = Vector()
|
quat = q
|
||||||
self.orig_quat: Quaternion = Quaternion()
|
q = import_bone.post_quat.copy()
|
||||||
self.post_quat: Quaternion = Quaternion()
|
if import_bone.parent is None:
|
||||||
self.fcurves = []
|
q.rotate(key_rotation.conjugated())
|
||||||
|
else:
|
||||||
|
q.rotate(key_rotation)
|
||||||
|
quat.rotate(q.conjugated())
|
||||||
|
loc = key_location - import_bone.orig_loc
|
||||||
|
loc.rotate(import_bone.post_quat.conjugated())
|
||||||
|
return quat.w, quat.x, quat.y, quat.z, loc.x, loc.y, loc.z
|
||||||
|
|
||||||
def calculate_fcurve_data(import_bone: ImportBone, key_data: []):
|
|
||||||
# Convert world-space transforms to local-space transforms.
|
def import_psa(psa_reader: PsaReader, armature_object, options: PsaImportOptions):
|
||||||
key_rotation = Quaternion(key_data[0:4])
|
sequences = map(lambda x: psa_reader.sequences[x], options.sequence_names)
|
||||||
key_location = Vector(key_data[4:])
|
armature_data = armature_object.data
|
||||||
q = import_bone.post_quat.copy()
|
|
||||||
q.rotate(import_bone.orig_quat)
|
# Create an index mapping from bones in the PSA to bones in the target armature.
|
||||||
quat = q
|
psa_to_armature_bone_indices = {}
|
||||||
q = import_bone.post_quat.copy()
|
armature_bone_names = [x.name for x in armature_data.bones]
|
||||||
if import_bone.parent is None:
|
psa_bone_names = []
|
||||||
q.rotate(key_rotation.conjugated())
|
for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
|
||||||
|
psa_bone_name = psa_bone.name.decode('windows-1252')
|
||||||
|
psa_bone_names.append(psa_bone_name)
|
||||||
|
try:
|
||||||
|
psa_to_armature_bone_indices[psa_bone_index] = armature_bone_names.index(psa_bone_name)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 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(list(sorted(missing_bone_names)))
|
||||||
|
del armature_bone_names
|
||||||
|
|
||||||
|
# Create intermediate bone data for import operations.
|
||||||
|
import_bones = []
|
||||||
|
import_bones_dict = dict()
|
||||||
|
|
||||||
|
for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
|
||||||
|
bone_name = psa_bone.name.decode('windows-1252')
|
||||||
|
if psa_bone_index not in psa_to_armature_bone_indices: # TODO: replace with bone_name in armature_data.bones
|
||||||
|
# PSA bone does not map to armature bone, skip it and leave an empty bone in its place.
|
||||||
|
import_bones.append(None)
|
||||||
|
continue
|
||||||
|
import_bone = ImportBone(psa_bone)
|
||||||
|
import_bone.armature_bone = armature_data.bones[bone_name]
|
||||||
|
import_bone.pose_bone = armature_object.pose.bones[bone_name]
|
||||||
|
import_bones_dict[bone_name] = import_bone
|
||||||
|
import_bones.append(import_bone)
|
||||||
|
|
||||||
|
for import_bone in filter(lambda x: x is not None, import_bones):
|
||||||
|
armature_bone = import_bone.armature_bone
|
||||||
|
if armature_bone.parent is not None and armature_bone.parent.name in psa_bone_names:
|
||||||
|
import_bone.parent = import_bones_dict[armature_bone.parent.name]
|
||||||
|
# Calculate the original location & rotation of each bone (in world-space maybe?)
|
||||||
|
if armature_bone.get('orig_quat') is not None:
|
||||||
|
# TODO: ideally we don't rely on bone auxiliary data like this, the non-aux data path is incorrect
|
||||||
|
# (animations are flipped 180 around Z)
|
||||||
|
import_bone.orig_quat = Quaternion(armature_bone['orig_quat'])
|
||||||
|
import_bone.orig_loc = Vector(armature_bone['orig_loc'])
|
||||||
|
import_bone.post_quat = Quaternion(armature_bone['post_quat'])
|
||||||
|
else:
|
||||||
|
if import_bone.parent is not None:
|
||||||
|
import_bone.orig_loc = armature_bone.matrix_local.translation - armature_bone.parent.matrix_local.translation
|
||||||
|
import_bone.orig_loc.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
|
||||||
|
import_bone.orig_quat = armature_bone.matrix_local.to_quaternion()
|
||||||
|
import_bone.orig_quat.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
|
||||||
|
import_bone.orig_quat.conjugate()
|
||||||
else:
|
else:
|
||||||
q.rotate(key_rotation)
|
import_bone.orig_loc = armature_bone.matrix_local.translation.copy()
|
||||||
quat.rotate(q.conjugated())
|
import_bone.orig_quat = armature_bone.matrix_local.to_quaternion()
|
||||||
loc = key_location - import_bone.orig_loc
|
import_bone.post_quat = import_bone.orig_quat.conjugated()
|
||||||
loc.rotate(import_bone.post_quat.conjugated())
|
|
||||||
return quat.w, quat.x, quat.y, quat.z, loc.x, loc.y, loc.z
|
|
||||||
|
|
||||||
# Create an index mapping from bones in the PSA to bones in the target armature.
|
# Create and populate the data for new sequences.
|
||||||
psa_to_armature_bone_indices = {}
|
actions = []
|
||||||
armature_bone_names = [x.name for x in armature_data.bones]
|
for sequence in sequences:
|
||||||
psa_bone_names = []
|
# Add the action.
|
||||||
for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
|
sequence_name = sequence.name.decode('windows-1252')
|
||||||
psa_bone_name = psa_bone.name.decode('windows-1252')
|
action_name = options.action_name_prefix + sequence_name
|
||||||
psa_bone_names.append(psa_bone_name)
|
|
||||||
try:
|
|
||||||
psa_to_armature_bone_indices[psa_bone_index] = armature_bone_names.index(psa_bone_name)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Report if there are missing bones in the target armature.
|
if options.should_overwrite and action_name in bpy.data.actions:
|
||||||
missing_bone_names = set(psa_bone_names).difference(set(armature_bone_names))
|
action = bpy.data.actions[action_name]
|
||||||
if len(missing_bone_names) > 0:
|
else:
|
||||||
print(
|
action = bpy.data.actions.new(name=action_name)
|
||||||
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
|
|
||||||
|
|
||||||
# Create intermediate bone data for import operations.
|
if options.should_write_keyframes:
|
||||||
import_bones = []
|
# Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2)
|
||||||
import_bones_dict = dict()
|
while len(action.fcurves) > 0:
|
||||||
|
action.fcurves.remove(action.fcurves[-1])
|
||||||
|
|
||||||
for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
|
# Create f-curves for the rotation and location of each bone.
|
||||||
bone_name = psa_bone.name.decode('windows-1252')
|
for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items():
|
||||||
if psa_bone_index not in psa_to_armature_bone_indices: # TODO: replace with bone_name in armature_data.bones
|
import_bone = import_bones[psa_bone_index]
|
||||||
# PSA bone does not map to armature bone, skip it and leave an empty bone in its place.
|
pose_bone = import_bone.pose_bone
|
||||||
import_bones.append(None)
|
rotation_data_path = pose_bone.path_from_id('rotation_quaternion')
|
||||||
continue
|
location_data_path = pose_bone.path_from_id('location')
|
||||||
import_bone = ImportBone(psa_bone)
|
import_bone.fcurves = [
|
||||||
import_bone.armature_bone = armature_data.bones[bone_name]
|
action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name), # Qw
|
||||||
import_bone.pose_bone = armature_object.pose.bones[bone_name]
|
action.fcurves.new(rotation_data_path, index=1, action_group=pose_bone.name), # Qx
|
||||||
import_bones_dict[bone_name] = import_bone
|
action.fcurves.new(rotation_data_path, index=2, action_group=pose_bone.name), # Qy
|
||||||
import_bones.append(import_bone)
|
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
|
||||||
|
]
|
||||||
|
|
||||||
for import_bone in filter(lambda x: x is not None, import_bones):
|
# Read the sequence data matrix from the PSA.
|
||||||
armature_bone = import_bone.armature_bone
|
sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name)
|
||||||
if armature_bone.parent is not None and armature_bone.parent.name in psa_bone_names:
|
|
||||||
import_bone.parent = import_bones_dict[armature_bone.parent.name]
|
|
||||||
# Calculate the original location & rotation of each bone (in world-space maybe?)
|
|
||||||
if armature_bone.get('orig_quat') is not None:
|
|
||||||
# TODO: ideally we don't rely on bone auxiliary data like this, the non-aux data path is incorrect (animations are flipped 180 around Z)
|
|
||||||
import_bone.orig_quat = Quaternion(armature_bone['orig_quat'])
|
|
||||||
import_bone.orig_loc = Vector(armature_bone['orig_loc'])
|
|
||||||
import_bone.post_quat = Quaternion(armature_bone['post_quat'])
|
|
||||||
else:
|
|
||||||
if import_bone.parent is not None:
|
|
||||||
import_bone.orig_loc = armature_bone.matrix_local.translation - armature_bone.parent.matrix_local.translation
|
|
||||||
import_bone.orig_loc.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
|
|
||||||
import_bone.orig_quat = armature_bone.matrix_local.to_quaternion()
|
|
||||||
import_bone.orig_quat.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
|
|
||||||
import_bone.orig_quat.conjugate()
|
|
||||||
else:
|
|
||||||
import_bone.orig_loc = armature_bone.matrix_local.translation.copy()
|
|
||||||
import_bone.orig_quat = armature_bone.matrix_local.to_quaternion()
|
|
||||||
import_bone.post_quat = import_bone.orig_quat.conjugated()
|
|
||||||
|
|
||||||
# Create and populate the data for new sequences.
|
# Convert the sequence's data from world-space to local-space.
|
||||||
actions = []
|
for bone_index, import_bone in enumerate(import_bones):
|
||||||
for sequence in sequences:
|
if import_bone is None:
|
||||||
# Add the action.
|
continue
|
||||||
sequence_name = sequence.name.decode('windows-1252')
|
for frame_index in range(sequence.frame_count):
|
||||||
action_name = options.action_name_prefix + sequence_name
|
# 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_overwrite and action_name in bpy.data.actions:
|
# Write the keyframes out!
|
||||||
action = bpy.data.actions[action_name]
|
for frame_index in range(sequence.frame_count):
|
||||||
else:
|
|
||||||
action = bpy.data.actions.new(name=action_name)
|
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
# 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):
|
for bone_index, import_bone in enumerate(import_bones):
|
||||||
if import_bone is None:
|
if import_bone is None:
|
||||||
continue
|
continue
|
||||||
for frame_index in range(sequence.frame_count):
|
key_data = sequence_data_matrix[frame_index, bone_index]
|
||||||
# This bone has writeable keyframes for this frame.
|
for fcurve, datum in zip(import_bone.fcurves, key_data):
|
||||||
key_data = sequence_data_matrix[frame_index, bone_index]
|
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
|
if options.should_convert_to_samples:
|
||||||
# insufficiently large change in the data from the last written frame.
|
for fcurve in action.fcurves:
|
||||||
if options.should_clean_keys:
|
fcurve.convert_to_samples(start=0, end=sequence.frame_count)
|
||||||
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!
|
# Write
|
||||||
for frame_index in range(sequence.frame_count):
|
if options.should_write_metadata:
|
||||||
for bone_index, import_bone in enumerate(import_bones):
|
action['psa_sequence_name'] = sequence_name
|
||||||
if import_bone is None:
|
action['psa_sequence_fps'] = sequence.fps
|
||||||
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
|
action.use_fake_user = options.should_use_fake_user
|
||||||
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)
|
||||||
|
|
||||||
actions.append(action)
|
# 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()
|
||||||
|
for action in actions:
|
||||||
|
nla_track = armature_object.animation_data.nla_tracks.new()
|
||||||
|
nla_track.name = action.name
|
||||||
|
nla_track.mute = True
|
||||||
|
nla_track.strips.new(name=action.name, start=0, action=action)
|
||||||
|
|
||||||
# If the user specifies, store the new animations as strips on a non-contributing NLA track.
|
|
||||||
if options.should_stash:
|
empty_set = set()
|
||||||
if armature_object.animation_data is None:
|
|
||||||
armature_object.animation_data_create()
|
|
||||||
for action in actions:
|
|
||||||
nla_track = armature_object.animation_data.nla_tracks.new()
|
|
||||||
nla_track.name = action.name
|
|
||||||
nla_track.mute = True
|
|
||||||
nla_track.strips.new(name=action.name, start=0, action=action)
|
|
||||||
|
|
||||||
|
|
||||||
class PsaImportActionListItem(PropertyGroup):
|
class PsaImportActionListItem(PropertyGroup):
|
||||||
action_name: StringProperty(options=set())
|
action_name: StringProperty(options=empty_set)
|
||||||
is_selected: BoolProperty(default=False, options=set())
|
is_selected: BoolProperty(default=False, options=empty_set)
|
||||||
|
|
||||||
|
|
||||||
def load_psa_file(context):
|
def load_psa_file(context):
|
||||||
@@ -249,7 +229,7 @@ def on_psa_file_path_updated(property, context):
|
|||||||
|
|
||||||
|
|
||||||
class PsaBonePropertyGroup(PropertyGroup):
|
class PsaBonePropertyGroup(PropertyGroup):
|
||||||
bone_name: StringProperty(options=set())
|
bone_name: StringProperty(options=empty_set)
|
||||||
|
|
||||||
|
|
||||||
class PsaDataPropertyGroup(PropertyGroup):
|
class PsaDataPropertyGroup(PropertyGroup):
|
||||||
@@ -258,37 +238,39 @@ class PsaDataPropertyGroup(PropertyGroup):
|
|||||||
|
|
||||||
|
|
||||||
class PsaImportPropertyGroup(PropertyGroup):
|
class PsaImportPropertyGroup(PropertyGroup):
|
||||||
psa_file_path: StringProperty(default='', options=set(), update=on_psa_file_path_updated, name='PSA File Path')
|
psa_file_path: StringProperty(default='', options=empty_set, update=on_psa_file_path_updated, name='PSA File Path')
|
||||||
psa_error: StringProperty(default='')
|
psa_error: StringProperty(default='')
|
||||||
psa: PointerProperty(type=PsaDataPropertyGroup)
|
psa: PointerProperty(type=PsaDataPropertyGroup)
|
||||||
sequence_list: CollectionProperty(type=PsaImportActionListItem)
|
sequence_list: CollectionProperty(type=PsaImportActionListItem)
|
||||||
sequence_list_index: IntProperty(name='', default=0)
|
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',
|
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',
|
description='Assign each imported action a fake user so that the data block is saved even it has no users',
|
||||||
options=set())
|
options=empty_set)
|
||||||
should_stash: BoolProperty(default=False, name='Stash',
|
should_stash: BoolProperty(default=False, name='Stash',
|
||||||
description='Stash each imported action as a strip on a new non-contributing NLA track',
|
description='Stash each imported action as a strip on a new non-contributing NLA track',
|
||||||
options=set())
|
options=empty_set)
|
||||||
should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=set())
|
should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=empty_set)
|
||||||
action_name_prefix: StringProperty(default='', name='Prefix', options=set())
|
action_name_prefix: StringProperty(default='', name='Prefix', options=empty_set)
|
||||||
should_overwrite: BoolProperty(default=False, name='Reuse Existing Actions', options=set(),
|
should_overwrite: BoolProperty(default=False, name='Reuse Existing Actions', options=empty_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')
|
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_keyframes: BoolProperty(default=True, name='Keyframes', options=empty_set)
|
||||||
should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(),
|
should_write_metadata: BoolProperty(default=True, name='Metadata', options=empty_set,
|
||||||
description='Additional data will be written to the custom properties of the Action (e.g., frame rate)')
|
description='Additional data will be written to the custom properties of the Action (e.g., frame rate)')
|
||||||
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
|
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
|
||||||
sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected',
|
sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected',
|
||||||
description='Only show selected sequences')
|
description='Only show selected sequences')
|
||||||
sequence_use_filter_invert: BoolProperty(default=False, options=set())
|
sequence_use_filter_invert: BoolProperty(default=False, options=empty_set)
|
||||||
sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression',
|
sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression',
|
||||||
description='Filter using regular expressions', options=set())
|
description='Filter using regular expressions', options=empty_set)
|
||||||
select_text: PointerProperty(type=bpy.types.Text)
|
select_text: PointerProperty(type=bpy.types.Text)
|
||||||
|
should_convert_to_samples: BoolProperty(
|
||||||
|
default=True,
|
||||||
|
name='Convert to Samples',
|
||||||
|
description='Convert keyframes to read-only samples. Recommended if you do not plan on editing the actions directly'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[int]:
|
def filter_sequences(pg: PsaImportPropertyGroup, sequences) -> List[int]:
|
||||||
bitflag_filter_item = 1 << 30
|
bitflag_filter_item = 1 << 30
|
||||||
flt_flags = [bitflag_filter_item] * len(sequences)
|
flt_flags = [bitflag_filter_item] * len(sequences)
|
||||||
|
|
||||||
@@ -322,8 +304,7 @@ def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_c
|
|||||||
return flt_flags
|
return flt_flags
|
||||||
|
|
||||||
|
|
||||||
def get_visible_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[
|
def get_visible_sequences(pg: PsaImportPropertyGroup, sequences) -> List[PsaImportActionListItem]:
|
||||||
PsaImportActionListItem]:
|
|
||||||
bitflag_filter_item = 1 << 30
|
bitflag_filter_item = 1 << 30
|
||||||
visible_sequences = []
|
visible_sequences = []
|
||||||
for i, flag in enumerate(filter_sequences(pg, sequences)):
|
for i, flag in enumerate(filter_sequences(pg, sequences)):
|
||||||
@@ -333,26 +314,25 @@ def get_visible_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_p
|
|||||||
|
|
||||||
|
|
||||||
class PSA_UL_SequenceList(UIList):
|
class PSA_UL_SequenceList(UIList):
|
||||||
|
def draw_item(self, context, layout, data, item, icon, active_data, active_property, index, flt_flag):
|
||||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
|
||||||
row = layout.row(align=True)
|
row = layout.row(align=True)
|
||||||
split = row.split(align=True, factor=0.75)
|
split = row.split(align=True, factor=0.75)
|
||||||
column = split.row(align=True)
|
column = split.row(align=True)
|
||||||
column.alignment = 'LEFT'
|
column.alignment = 'LEFT'
|
||||||
column.prop(item, 'is_selected', icon_only=True)
|
column.prop(item, 'is_selected', icon_only=True)
|
||||||
column.label(text=item.action_name)
|
column.label(text=getattr(item, 'action_name'))
|
||||||
|
|
||||||
def draw_filter(self, context, layout):
|
def draw_filter(self, context, layout):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
row = layout.row()
|
row = layout.row()
|
||||||
subrow = row.row(align=True)
|
sub_row = row.row(align=True)
|
||||||
subrow.prop(pg, 'sequence_filter_name', text="")
|
sub_row.prop(pg, 'sequence_filter_name', text="")
|
||||||
subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
sub_row.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
||||||
subrow.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT')
|
sub_row.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT')
|
||||||
subrow.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT')
|
sub_row.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT')
|
||||||
|
|
||||||
def filter_items(self, context, data, property):
|
def filter_items(self, context, data, property):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
sequences = getattr(data, property)
|
sequences = getattr(data, property)
|
||||||
flt_flags = filter_sequences(pg, sequences)
|
flt_flags = filter_sequences(pg, sequences)
|
||||||
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'action_name')
|
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'action_name')
|
||||||
@@ -375,7 +355,7 @@ class PsaImportSequencesFromText(Operator):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
return len(pg.sequence_list) > 0
|
return len(pg.sequence_list) > 0
|
||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
@@ -383,12 +363,15 @@ class PsaImportSequencesFromText(Operator):
|
|||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
layout.label(icon='INFO', text='Each sequence name should be on a new line.')
|
layout.label(icon='INFO', text='Each sequence name should be on a new line.')
|
||||||
layout.prop(pg, 'select_text', text='')
|
layout.prop(pg, 'select_text', text='')
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
|
if pg.select_text is None:
|
||||||
|
self.report({'ERROR_INVALID_CONTEXT'}, 'No text block selected')
|
||||||
|
return {'CANCELLED'}
|
||||||
contents = pg.select_text.as_string()
|
contents = pg.select_text.as_string()
|
||||||
count = 0
|
count = 0
|
||||||
for line in contents.split('\n'):
|
for line in contents.split('\n'):
|
||||||
@@ -403,18 +386,18 @@ class PsaImportSequencesFromText(Operator):
|
|||||||
class PsaImportSequencesSelectAll(Operator):
|
class PsaImportSequencesSelectAll(Operator):
|
||||||
bl_idname = 'psa_import.sequences_select_all'
|
bl_idname = 'psa_import.sequences_select_all'
|
||||||
bl_label = 'All'
|
bl_label = 'All'
|
||||||
bl_description = 'Select all visible sequences'
|
bl_description = 'Select all sequences'
|
||||||
bl_options = {'INTERNAL'}
|
bl_options = {'INTERNAL'}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||||
has_unselected_actions = any(map(lambda action: not action.is_selected, visible_sequences))
|
has_unselected_actions = any(map(lambda action: not action.is_selected, visible_sequences))
|
||||||
return len(visible_sequences) > 0 and has_unselected_actions
|
return len(visible_sequences) > 0 and has_unselected_actions
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||||
for sequence in visible_sequences:
|
for sequence in visible_sequences:
|
||||||
sequence.is_selected = True
|
sequence.is_selected = True
|
||||||
@@ -429,13 +412,13 @@ class PsaImportSequencesDeselectAll(Operator):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||||
has_selected_sequences = any(map(lambda sequence: sequence.is_selected, visible_sequences))
|
has_selected_sequences = any(map(lambda sequence: sequence.is_selected, visible_sequences))
|
||||||
return len(visible_sequences) > 0 and has_selected_sequences
|
return len(visible_sequences) > 0 and has_selected_sequences
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||||
for sequence in visible_sequences:
|
for sequence in visible_sequences:
|
||||||
sequence.is_selected = False
|
sequence.is_selected = False
|
||||||
@@ -451,13 +434,16 @@ class PSA_PT_ImportPanel_Advanced(Panel):
|
|||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
|
|
||||||
col = layout.column(heading="Options")
|
col = layout.column(heading='Keyframes')
|
||||||
col.use_property_split = True
|
col.use_property_split = True
|
||||||
col.use_property_decorate = False
|
col.use_property_decorate = False
|
||||||
col.prop(pg, 'should_clean_keys')
|
col.prop(pg, 'should_convert_to_samples')
|
||||||
col.separator()
|
col.separator()
|
||||||
|
col = layout.column(heading='Options')
|
||||||
|
col.use_property_split = True
|
||||||
|
col.use_property_decorate = False
|
||||||
col.prop(pg, 'should_use_fake_user')
|
col.prop(pg, 'should_use_fake_user')
|
||||||
col.prop(pg, 'should_stash')
|
col.prop(pg, 'should_stash')
|
||||||
col.prop(pg, 'should_use_action_name_prefix')
|
col.prop(pg, 'should_use_action_name_prefix')
|
||||||
@@ -476,11 +462,11 @@ class PSA_PT_ImportPanel(Panel):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
return context.object.type == 'ARMATURE'
|
return context.view_layer.objects.active.type == 'ARMATURE'
|
||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
|
|
||||||
row = layout.row(align=True)
|
row = layout.row(align=True)
|
||||||
row.operator(PsaImportSelectFile.bl_idname, text='', icon='FILEBROWSER')
|
row.operator(PsaImportSelectFile.bl_idname, text='', icon='FILEBROWSER')
|
||||||
@@ -552,7 +538,7 @@ class PsaImportSelectFile(Operator):
|
|||||||
filter_glob: bpy.props.StringProperty(default="*.psa", options={'HIDDEN'})
|
filter_glob: bpy.props.StringProperty(default="*.psa", options={'HIDDEN'})
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
context.scene.psa_import.psa_file_path = self.filepath
|
getattr(context.scene, 'psa_import').psa_file_path = self.filepath
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
@@ -568,28 +554,28 @@ class PsaImportOperator(Operator):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
active_object = context.view_layer.objects.active
|
active_object = context.view_layer.objects.active
|
||||||
if active_object is None or active_object.type != 'ARMATURE':
|
if active_object is None or active_object.type != 'ARMATURE':
|
||||||
return False
|
return False
|
||||||
return any(map(lambda x: x.is_selected, pg.sequence_list))
|
return any(map(lambda x: x.is_selected, pg.sequence_list))
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
psa_reader = PsaReader(pg.psa_file_path)
|
psa_reader = PsaReader(pg.psa_file_path)
|
||||||
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected]
|
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected]
|
||||||
|
|
||||||
options = PsaImportOptions()
|
options = PsaImportOptions()
|
||||||
options.sequence_names = sequence_names
|
options.sequence_names = sequence_names
|
||||||
options.should_clean_keys = pg.should_clean_keys
|
|
||||||
options.should_use_fake_user = pg.should_use_fake_user
|
options.should_use_fake_user = pg.should_use_fake_user
|
||||||
options.should_stash = pg.should_stash
|
options.should_stash = pg.should_stash
|
||||||
options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else ''
|
options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else ''
|
||||||
options.should_overwrite = pg.should_overwrite
|
options.should_overwrite = pg.should_overwrite
|
||||||
options.should_write_metadata = pg.should_write_metadata
|
options.should_write_metadata = pg.should_write_metadata
|
||||||
options.should_write_keyframes = pg.should_write_keyframes
|
options.should_write_keyframes = pg.should_write_keyframes
|
||||||
|
options.should_convert_to_samples = pg.should_convert_to_samples
|
||||||
|
|
||||||
PsaImporter().import_psa(psa_reader, context.view_layer.objects.active, options)
|
import_psa(psa_reader, context.view_layer.objects.active, options)
|
||||||
|
|
||||||
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
|
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
|
||||||
|
|
||||||
@@ -613,7 +599,7 @@ class PsaImportFileSelectOperator(Operator, ImportHelper):
|
|||||||
return {'RUNNING_MODAL'}
|
return {'RUNNING_MODAL'}
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psa_import
|
pg = getattr(context.scene, 'psa_import')
|
||||||
pg.psa_file_path = self.filepath
|
pg.psa_file_path = self.filepath
|
||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ from .data import *
|
|||||||
|
|
||||||
class PsaReader(object):
|
class PsaReader(object):
|
||||||
"""
|
"""
|
||||||
This class reads 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 holds onto a file handle.
|
||||||
The key data is not read into memory upon instantiation due to it's potentially very large size.
|
The keyframe data is not read into memory upon instantiation due to its potentially very large size.
|
||||||
To read the key data for a particular sequence, call `read_sequence_keys`.
|
To read the key data for a particular sequence, call :read_sequence_keys.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, path):
|
def __init__(self, path):
|
||||||
@@ -22,10 +22,15 @@ class PsaReader(object):
|
|||||||
return self.psa.bones
|
return self.psa.bones
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sequences(self) -> OrderedDict[Psa.Sequence]:
|
def sequences(self):
|
||||||
return self.psa.sequences
|
return self.psa.sequences
|
||||||
|
|
||||||
def read_sequence_data_matrix(self, sequence_name: str):
|
def read_sequence_data_matrix(self, sequence_name: str) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Reads and returns the data matrix for the given sequence.
|
||||||
|
@param sequence_name: The name of the sequence.
|
||||||
|
@return: An FxBx7 matrix where F is the number of frames, B is the number of bones.
|
||||||
|
"""
|
||||||
sequence = self.psa.sequences[sequence_name]
|
sequence = self.psa.sequences[sequence_name]
|
||||||
keys = self.read_sequence_keys(sequence_name)
|
keys = self.read_sequence_keys(sequence_name)
|
||||||
bone_count = len(self.bones)
|
bone_count = len(self.bones)
|
||||||
@@ -38,10 +43,11 @@ class PsaReader(object):
|
|||||||
return matrix
|
return matrix
|
||||||
|
|
||||||
def read_sequence_keys(self, sequence_name: str) -> List[Psa.Key]:
|
def read_sequence_keys(self, sequence_name: str) -> List[Psa.Key]:
|
||||||
""" Reads and returns the key data for a sequence.
|
"""
|
||||||
|
Reads and returns the key data for a sequence.
|
||||||
|
|
||||||
:param sequence_name: The name of the sequence.
|
@param sequence_name: The name of the sequence.
|
||||||
:return: A list of Psa.Keys.
|
@return: A list of Psa.Keys.
|
||||||
"""
|
"""
|
||||||
# Set the file reader to the beginning of the keys data
|
# Set the file reader to the beginning of the keys data
|
||||||
sequence = self.psa.sequences[sequence_name]
|
sequence = self.psa.sequences[sequence_name]
|
||||||
@@ -60,7 +66,7 @@ class PsaReader(object):
|
|||||||
return keys
|
return keys
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_types(fp, data_class: ctypes.Structure, section: Section, data):
|
def _read_types(fp, data_class, section: Section, data):
|
||||||
buffer_length = section.data_size * section.data_count
|
buffer_length = section.data_size * section.data_count
|
||||||
buffer = fp.read(buffer_length)
|
buffer = fp.read(buffer_length)
|
||||||
offset = 0
|
offset = 0
|
||||||
@@ -86,7 +92,7 @@ class PsaReader(object):
|
|||||||
# Skip keys on this pass. We will keep this file open and read from it as needed.
|
# Skip keys on this pass. We will keep this file open and read from it as needed.
|
||||||
self.keys_data_offset = fp.tell()
|
self.keys_data_offset = fp.tell()
|
||||||
fp.seek(section.data_size * section.data_count, 1)
|
fp.seek(section.data_size * section.data_count, 1)
|
||||||
elif section.name in [b'SCALEKEYS']:
|
elif section.name == b'SCALEKEYS':
|
||||||
fp.seek(section.data_size * section.data_count, 1)
|
fp.seek(section.data_size * section.data_count, 1)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f'Unrecognized section "{section.name}"')
|
raise RuntimeError(f'Unrecognized section "{section.name}"')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from collections import OrderedDict
|
import bmesh
|
||||||
|
import bpy
|
||||||
|
|
||||||
from .data import *
|
from .data import *
|
||||||
from ..helpers import *
|
from ..helpers import *
|
||||||
@@ -10,227 +11,255 @@ class PskInputObjects(object):
|
|||||||
self.armature_object = None
|
self.armature_object = None
|
||||||
|
|
||||||
|
|
||||||
class PskBuilderOptions(object):
|
class PskBuildOptions(object):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.bone_filter_mode = 'ALL'
|
self.bone_filter_mode = 'ALL'
|
||||||
self.bone_group_indices = []
|
self.bone_group_indices: List[int] = []
|
||||||
|
self.use_raw_mesh_data = True
|
||||||
|
self.material_names: List[str] = []
|
||||||
|
self.should_ignore_bone_name_restrictions = False
|
||||||
|
|
||||||
|
|
||||||
class PskBuilder(object):
|
def get_psk_input_objects(context) -> PskInputObjects:
|
||||||
def __init__(self):
|
input_objects = PskInputObjects()
|
||||||
pass
|
for selected_object in context.view_layer.objects.selected:
|
||||||
|
if selected_object.type != 'MESH':
|
||||||
|
raise RuntimeError(f'Selected object "{selected_object.name}" is not a mesh')
|
||||||
|
|
||||||
@staticmethod
|
input_objects.mesh_objects = context.view_layer.objects.selected
|
||||||
def get_input_objects(context) -> PskInputObjects:
|
|
||||||
input_objects = PskInputObjects()
|
|
||||||
for obj in context.view_layer.objects.selected:
|
|
||||||
if obj.type != 'MESH':
|
|
||||||
raise RuntimeError(f'Selected object "{obj.name}" is not a mesh')
|
|
||||||
|
|
||||||
input_objects.mesh_objects = context.view_layer.objects.selected
|
if len(input_objects.mesh_objects) == 0:
|
||||||
|
raise RuntimeError('At least one mesh must be selected')
|
||||||
|
|
||||||
if len(input_objects.mesh_objects) == 0:
|
for mesh_object in input_objects.mesh_objects:
|
||||||
raise RuntimeError('At least one mesh must be selected')
|
if len(mesh_object.data.materials) == 0:
|
||||||
|
raise RuntimeError(f'Mesh "{mesh_object.name}" must have at least one material')
|
||||||
|
|
||||||
for obj in input_objects.mesh_objects:
|
# Ensure that there are either no armature modifiers (static mesh)
|
||||||
if len(obj.data.materials) == 0:
|
# or that there is exactly one armature modifier object shared between
|
||||||
raise RuntimeError(f'Mesh "{obj.name}" must have at least one material')
|
# all selected meshes
|
||||||
|
armature_modifier_objects = set()
|
||||||
|
|
||||||
# Ensure that there are either no armature modifiers (static mesh)
|
for mesh_object in input_objects.mesh_objects:
|
||||||
# or that there is exactly one armature modifier object shared between
|
modifiers = [x for x in mesh_object.modifiers if x.type == 'ARMATURE']
|
||||||
# all selected meshes
|
if len(modifiers) == 0:
|
||||||
armature_modifier_objects = set()
|
continue
|
||||||
|
elif len(modifiers) > 1:
|
||||||
|
raise RuntimeError(f'Mesh "{mesh_object.name}" must have only one armature modifier')
|
||||||
|
armature_modifier_objects.add(modifiers[0].object)
|
||||||
|
|
||||||
for obj in input_objects.mesh_objects:
|
if len(armature_modifier_objects) > 1:
|
||||||
modifiers = [x for x in obj.modifiers if x.type == 'ARMATURE']
|
raise RuntimeError('All selected meshes must have the same armature modifier')
|
||||||
if len(modifiers) == 0:
|
elif len(armature_modifier_objects) == 1:
|
||||||
continue
|
input_objects.armature_object = list(armature_modifier_objects)[0]
|
||||||
elif len(modifiers) > 1:
|
|
||||||
raise RuntimeError(f'Mesh "{obj.name}" must have only one armature modifier')
|
|
||||||
armature_modifier_objects.add(modifiers[0].object)
|
|
||||||
|
|
||||||
if len(armature_modifier_objects) > 1:
|
return input_objects
|
||||||
raise RuntimeError('All selected meshes must have the same armature modifier')
|
|
||||||
elif len(armature_modifier_objects) == 1:
|
|
||||||
input_objects.armature_object = list(armature_modifier_objects)[0]
|
|
||||||
|
|
||||||
return input_objects
|
|
||||||
|
|
||||||
def build(self, context, options: PskBuilderOptions) -> Psk:
|
def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||||
input_objects = PskBuilder.get_input_objects(context)
|
input_objects = get_psk_input_objects(context)
|
||||||
|
|
||||||
armature_object = input_objects.armature_object
|
armature_object = input_objects.armature_object
|
||||||
|
|
||||||
psk = Psk()
|
psk = Psk()
|
||||||
bones = []
|
bones = []
|
||||||
materials = OrderedDict()
|
|
||||||
|
|
||||||
if armature_object is None:
|
if armature_object is None:
|
||||||
# If the mesh has no armature object, simply assign it a dummy bone at the root to satisfy the requirement
|
# 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.
|
# that a PSK file must have at least one bone.
|
||||||
|
psk_bone = Psk.Bone()
|
||||||
|
psk_bone.name = bytes('root', encoding='windows-1252')
|
||||||
|
psk_bone.flags = 0
|
||||||
|
psk_bone.children_count = 0
|
||||||
|
psk_bone.parent_index = 0
|
||||||
|
psk_bone.location = Vector3.zero()
|
||||||
|
psk_bone.rotation = Quaternion.identity()
|
||||||
|
psk.bones.append(psk_bone)
|
||||||
|
else:
|
||||||
|
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices)
|
||||||
|
bones = [armature_object.data.bones[bone_name] for bone_name in bone_names]
|
||||||
|
|
||||||
|
# Check that all bone names are valid.
|
||||||
|
if not options.should_ignore_bone_name_restrictions:
|
||||||
|
check_bone_names(map(lambda x: x.name, bones))
|
||||||
|
|
||||||
|
for bone in bones:
|
||||||
psk_bone = Psk.Bone()
|
psk_bone = Psk.Bone()
|
||||||
psk_bone.name = bytes('root', encoding='windows-1252')
|
psk_bone.name = bytes(bone.name, encoding='windows-1252')
|
||||||
psk_bone.flags = 0
|
psk_bone.flags = 0
|
||||||
psk_bone.children_count = 0
|
psk_bone.children_count = 0
|
||||||
psk_bone.parent_index = 0
|
|
||||||
psk_bone.location = Vector3.zero()
|
try:
|
||||||
psk_bone.rotation = Quaternion.identity()
|
parent_index = bones.index(bone.parent)
|
||||||
|
psk_bone.parent_index = parent_index
|
||||||
|
psk.bones[parent_index].children_count += 1
|
||||||
|
except ValueError:
|
||||||
|
psk_bone.parent_index = -1
|
||||||
|
|
||||||
|
if bone.parent is not None:
|
||||||
|
rotation = bone.matrix.to_quaternion().conjugated()
|
||||||
|
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:
|
||||||
|
local_matrix = armature_object.matrix_local
|
||||||
|
location = local_matrix @ bone.head
|
||||||
|
rot_matrix = bone.matrix @ local_matrix.to_3x3()
|
||||||
|
rotation = rot_matrix.to_quaternion()
|
||||||
|
|
||||||
|
psk_bone.location.x = location.x
|
||||||
|
psk_bone.location.y = location.y
|
||||||
|
psk_bone.location.z = location.z
|
||||||
|
|
||||||
|
psk_bone.rotation.w = rotation.w
|
||||||
|
psk_bone.rotation.x = rotation.x
|
||||||
|
psk_bone.rotation.y = rotation.y
|
||||||
|
psk_bone.rotation.z = rotation.z
|
||||||
|
|
||||||
psk.bones.append(psk_bone)
|
psk.bones.append(psk_bone)
|
||||||
|
|
||||||
|
# MATERIALS
|
||||||
|
material_names = options.material_names
|
||||||
|
|
||||||
|
for material_name in material_names:
|
||||||
|
psk_material = Psk.Material()
|
||||||
|
psk_material.name = bytes(material_name, encoding='windows-1252')
|
||||||
|
psk_material.texture_index = len(psk.materials)
|
||||||
|
psk.materials.append(psk_material)
|
||||||
|
|
||||||
|
for input_mesh_object in input_objects.mesh_objects:
|
||||||
|
|
||||||
|
# MATERIALS
|
||||||
|
material_indices = [material_names.index(material.name) for material in input_mesh_object.data.materials]
|
||||||
|
|
||||||
|
# MESH DATA
|
||||||
|
if options.use_raw_mesh_data:
|
||||||
|
mesh_object = input_mesh_object
|
||||||
|
mesh_data = input_mesh_object.data
|
||||||
else:
|
else:
|
||||||
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices)
|
# Create a copy of the mesh object after non-armature modifiers are applied.
|
||||||
bones = [armature_object.data.bones[bone_name] for bone_name in bone_names]
|
|
||||||
|
|
||||||
for bone in bones:
|
# Temporarily force the armature into the rest position.
|
||||||
psk_bone = Psk.Bone()
|
# We will undo this later.
|
||||||
psk_bone.name = bytes(bone.name, encoding='windows-1252')
|
old_pose_position = armature_object.data.pose_position
|
||||||
psk_bone.flags = 0
|
armature_object.data.pose_position = 'REST'
|
||||||
psk_bone.children_count = 0
|
|
||||||
|
|
||||||
|
depsgraph = context.evaluated_depsgraph_get()
|
||||||
|
bm = bmesh.new()
|
||||||
|
bm.from_object(input_mesh_object, depsgraph)
|
||||||
|
mesh_data = bpy.data.meshes.new('')
|
||||||
|
bm.to_mesh(mesh_data)
|
||||||
|
del bm
|
||||||
|
mesh_object = bpy.data.objects.new('', mesh_data)
|
||||||
|
mesh_object.matrix_world = input_mesh_object.matrix_world
|
||||||
|
|
||||||
|
# Copy the vertex groups
|
||||||
|
for vertex_group in input_mesh_object.vertex_groups:
|
||||||
|
mesh_object.vertex_groups.new(name=vertex_group.name)
|
||||||
|
|
||||||
|
# Restore the previous pose position on the armature.
|
||||||
|
armature_object.data.pose_position = old_pose_position
|
||||||
|
|
||||||
|
vertex_offset = len(psk.points)
|
||||||
|
|
||||||
|
# VERTICES
|
||||||
|
for vertex in mesh_data.vertices:
|
||||||
|
point = Vector3()
|
||||||
|
v = mesh_object.matrix_world @ vertex.co
|
||||||
|
point.x = v.x
|
||||||
|
point.y = v.y
|
||||||
|
point.z = v.z
|
||||||
|
psk.points.append(point)
|
||||||
|
|
||||||
|
uv_layer = mesh_data.uv_layers.active.data
|
||||||
|
|
||||||
|
# WEDGES
|
||||||
|
mesh_data.calc_loop_triangles()
|
||||||
|
|
||||||
|
# Build a list of non-unique wedges.
|
||||||
|
wedges = []
|
||||||
|
for loop_index, loop in enumerate(mesh_data.loops):
|
||||||
|
wedge = Psk.Wedge()
|
||||||
|
wedge.point_index = loop.vertex_index + vertex_offset
|
||||||
|
wedge.u, wedge.v = uv_layer[loop_index].uv
|
||||||
|
wedge.v = 1.0 - wedge.v
|
||||||
|
wedges.append(wedge)
|
||||||
|
|
||||||
|
# Assign material indices to the wedges.
|
||||||
|
for triangle in mesh_data.loop_triangles:
|
||||||
|
for loop_index in triangle.loops:
|
||||||
|
wedges[loop_index].material_index = material_indices[triangle.material_index]
|
||||||
|
|
||||||
|
# Populate the list of wedges with unique wedges & build a look-up table of loop indices to wedge indices
|
||||||
|
wedge_indices = {}
|
||||||
|
loop_wedge_indices = [-1] * len(mesh_data.loops)
|
||||||
|
for loop_index, wedge in enumerate(wedges):
|
||||||
|
wedge_hash = hash(wedge)
|
||||||
|
if wedge_hash in wedge_indices:
|
||||||
|
loop_wedge_indices[loop_index] = wedge_indices[wedge_hash]
|
||||||
|
else:
|
||||||
|
wedge_index = len(psk.wedges)
|
||||||
|
wedge_indices[wedge_hash] = wedge_index
|
||||||
|
psk.wedges.append(wedge)
|
||||||
|
loop_wedge_indices[loop_index] = wedge_index
|
||||||
|
|
||||||
|
# FACES
|
||||||
|
poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=True)
|
||||||
|
for f in mesh_data.loop_triangles:
|
||||||
|
face = Psk.Face()
|
||||||
|
face.material_index = material_indices[f.material_index]
|
||||||
|
face.wedge_indices[0] = loop_wedge_indices[f.loops[2]]
|
||||||
|
face.wedge_indices[1] = loop_wedge_indices[f.loops[1]]
|
||||||
|
face.wedge_indices[2] = loop_wedge_indices[f.loops[0]]
|
||||||
|
face.smoothing_groups = poly_groups[f.polygon_index]
|
||||||
|
psk.faces.append(face)
|
||||||
|
|
||||||
|
# WEIGHTS
|
||||||
|
if armature_object is not None:
|
||||||
|
# Because the vertex groups may contain entries for which there is no matching bone in the armature,
|
||||||
|
# we must filter them out and not export any weights for these vertex groups.
|
||||||
|
bone_names = [x.name for x in bones]
|
||||||
|
vertex_group_names = [x.name for x in mesh_object.vertex_groups]
|
||||||
|
vertex_group_bone_indices = dict()
|
||||||
|
for vertex_group_index, vertex_group_name in enumerate(vertex_group_names):
|
||||||
try:
|
try:
|
||||||
parent_index = bones.index(bone.parent)
|
vertex_group_bone_indices[vertex_group_index] = bone_names.index(vertex_group_name)
|
||||||
psk_bone.parent_index = parent_index
|
|
||||||
psk.bones[parent_index].children_count += 1
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
psk_bone.parent_index = 0
|
# The vertex group does not have a matching bone in the list of bones to be exported.
|
||||||
|
# Check to see if there is an associated bone for this vertex group that exists in the armature.
|
||||||
if bone.parent is not None:
|
# If there is, we can traverse the ancestors of that bone to find an alternate bone to use for
|
||||||
rotation = bone.matrix.to_quaternion()
|
# weighting the vertices belonging to this vertex group.
|
||||||
rotation.x = -rotation.x
|
if vertex_group_name in armature_object.data.bones:
|
||||||
rotation.y = -rotation.y
|
bone = armature_object.data.bones[vertex_group_name]
|
||||||
rotation.z = -rotation.z
|
while bone is not None:
|
||||||
quat_parent = bone.parent.matrix.to_quaternion().inverted()
|
try:
|
||||||
parent_head = quat_parent @ bone.parent.head
|
bone_index = bone_names.index(bone.name)
|
||||||
parent_tail = quat_parent @ bone.parent.tail
|
vertex_group_bone_indices[vertex_group_index] = bone_index
|
||||||
location = (parent_tail - parent_head) + bone.head
|
break
|
||||||
else:
|
except ValueError:
|
||||||
location = armature_object.matrix_local @ bone.head
|
bone = bone.parent
|
||||||
rot_matrix = bone.matrix @ armature_object.matrix_local.to_3x3()
|
for vertex_group_index, vertex_group in enumerate(mesh_object.vertex_groups):
|
||||||
rotation = rot_matrix.to_quaternion()
|
if vertex_group_index not in vertex_group_bone_indices:
|
||||||
|
# Vertex group has no associated bone, skip it.
|
||||||
psk_bone.location.x = location.x
|
continue
|
||||||
psk_bone.location.y = location.y
|
bone_index = vertex_group_bone_indices[vertex_group_index]
|
||||||
psk_bone.location.z = location.z
|
for vertex_index in range(len(mesh_data.vertices)):
|
||||||
|
|
||||||
psk_bone.rotation.x = rotation.x
|
|
||||||
psk_bone.rotation.y = rotation.y
|
|
||||||
psk_bone.rotation.z = rotation.z
|
|
||||||
psk_bone.rotation.w = rotation.w
|
|
||||||
|
|
||||||
psk.bones.append(psk_bone)
|
|
||||||
|
|
||||||
for object in input_objects.mesh_objects:
|
|
||||||
vertex_offset = len(psk.points)
|
|
||||||
|
|
||||||
# VERTICES
|
|
||||||
for vertex in object.data.vertices:
|
|
||||||
point = Vector3()
|
|
||||||
v = object.matrix_world @ vertex.co
|
|
||||||
point.x = v.x
|
|
||||||
point.y = v.y
|
|
||||||
point.z = v.z
|
|
||||||
psk.points.append(point)
|
|
||||||
|
|
||||||
uv_layer = object.data.uv_layers.active.data
|
|
||||||
|
|
||||||
# MATERIALS
|
|
||||||
material_indices = []
|
|
||||||
for i, m in enumerate(object.data.materials):
|
|
||||||
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)
|
|
||||||
psk.materials.append(material)
|
|
||||||
materials[m.name] = m
|
|
||||||
material_index = material.texture_index
|
|
||||||
material_indices.append(material_index)
|
|
||||||
|
|
||||||
# WEDGES
|
|
||||||
object.data.calc_loop_triangles()
|
|
||||||
|
|
||||||
# Build a list of non-unique wedges.
|
|
||||||
wedges = []
|
|
||||||
for loop_index, loop in enumerate(object.data.loops):
|
|
||||||
wedge = Psk.Wedge()
|
|
||||||
wedge.point_index = loop.vertex_index + vertex_offset
|
|
||||||
wedge.u, wedge.v = uv_layer[loop_index].uv
|
|
||||||
wedge.v = 1.0 - wedge.v
|
|
||||||
wedges.append(wedge)
|
|
||||||
|
|
||||||
# Assign material indices to the wedges.
|
|
||||||
for triangle in object.data.loop_triangles:
|
|
||||||
for loop_index in triangle.loops:
|
|
||||||
wedges[loop_index].material_index = material_indices[triangle.material_index]
|
|
||||||
|
|
||||||
# Populate the list of wedges with unique wedges & build a look-up table of loop indices to wedge indices
|
|
||||||
wedge_indices = {}
|
|
||||||
loop_wedge_indices = [-1] * len(object.data.loops)
|
|
||||||
for loop_index, wedge in enumerate(wedges):
|
|
||||||
wedge_hash = hash(wedge)
|
|
||||||
if wedge_hash in wedge_indices:
|
|
||||||
loop_wedge_indices[loop_index] = wedge_indices[wedge_hash]
|
|
||||||
else:
|
|
||||||
wedge_index = len(psk.wedges)
|
|
||||||
wedge_indices[wedge_hash] = wedge_index
|
|
||||||
psk.wedges.append(wedge)
|
|
||||||
loop_wedge_indices[loop_index] = wedge_index
|
|
||||||
|
|
||||||
# FACES
|
|
||||||
poly_groups, groups = object.data.calc_smooth_groups(use_bitflags=True)
|
|
||||||
for f in object.data.loop_triangles:
|
|
||||||
face = Psk.Face()
|
|
||||||
face.material_index = material_indices[f.material_index]
|
|
||||||
face.wedge_indices[0] = loop_wedge_indices[f.loops[2]]
|
|
||||||
face.wedge_indices[1] = loop_wedge_indices[f.loops[1]]
|
|
||||||
face.wedge_indices[2] = loop_wedge_indices[f.loops[0]]
|
|
||||||
face.smoothing_groups = poly_groups[f.polygon_index]
|
|
||||||
psk.faces.append(face)
|
|
||||||
|
|
||||||
# WEIGHTS
|
|
||||||
if armature_object is not None:
|
|
||||||
# Because the vertex groups may contain entries for which there is no matching bone in the armature,
|
|
||||||
# we must filter them out and not export any weights for these vertex groups.
|
|
||||||
bone_names = [x.name for x in bones]
|
|
||||||
vertex_group_names = [x.name for x in object.vertex_groups]
|
|
||||||
vertex_group_bone_indices = dict()
|
|
||||||
for vertex_group_index, vertex_group_name in enumerate(vertex_group_names):
|
|
||||||
try:
|
try:
|
||||||
vertex_group_bone_indices[vertex_group_index] = bone_names.index(vertex_group_name)
|
weight = vertex_group.weight(vertex_index)
|
||||||
except ValueError:
|
except RuntimeError:
|
||||||
# The vertex group does not have a matching bone in the list of bones to be exported.
|
|
||||||
# Check to see if there is an associated bone for this vertex group that exists in the armature.
|
|
||||||
# If there is, we can traverse the ancestors of that bone to find an alternate bone to use for
|
|
||||||
# weighting the vertices belonging to this vertex group.
|
|
||||||
if vertex_group_name in armature_object.data.bones:
|
|
||||||
bone = armature_object.data.bones[vertex_group_name]
|
|
||||||
while bone is not None:
|
|
||||||
try:
|
|
||||||
bone_index = bone_names.index(bone.name)
|
|
||||||
vertex_group_bone_indices[vertex_group_index] = bone_index
|
|
||||||
break
|
|
||||||
except ValueError:
|
|
||||||
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
|
continue
|
||||||
bone_index = vertex_group_bone_indices[vertex_group_index]
|
if weight == 0.0:
|
||||||
for vertex_index in range(len(object.data.vertices)):
|
continue
|
||||||
try:
|
w = Psk.Weight()
|
||||||
weight = vertex_group.weight(vertex_index)
|
w.bone_index = bone_index
|
||||||
except RuntimeError:
|
w.point_index = vertex_offset + vertex_index
|
||||||
continue
|
w.weight = weight
|
||||||
if weight == 0.0:
|
psk.weights.append(w)
|
||||||
continue
|
|
||||||
w = Psk.Weight()
|
|
||||||
w.bone_index = bone_index
|
|
||||||
w.point_index = vertex_offset + vertex_index
|
|
||||||
w.weight = weight
|
|
||||||
psk.weights.append(w)
|
|
||||||
|
|
||||||
return psk
|
if not options.use_raw_mesh_data:
|
||||||
|
bpy.data.objects.remove(mesh_object)
|
||||||
|
bpy.data.meshes.remove(mesh_data)
|
||||||
|
del mesh_data
|
||||||
|
|
||||||
|
return psk
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
from bpy.props import StringProperty, CollectionProperty, IntProperty, EnumProperty
|
from bpy.props import BoolProperty, StringProperty, CollectionProperty, IntProperty, EnumProperty
|
||||||
from bpy.types import Operator, PropertyGroup
|
from bpy.types import Operator, PropertyGroup, UIList
|
||||||
from bpy_extras.io_utils import ExportHelper
|
from bpy_extras.io_utils import ExportHelper
|
||||||
|
|
||||||
from .builder import PskBuilder, PskBuilderOptions
|
from .builder import build_psk, PskBuildOptions, get_psk_input_objects
|
||||||
from .data import *
|
from .data import *
|
||||||
from ..helpers import populate_bone_group_list
|
from ..helpers import populate_bone_group_list
|
||||||
from ..types import BoneGroupListItem
|
from ..types import BoneGroupListItem
|
||||||
@@ -15,55 +15,50 @@ MAX_BONE_COUNT = 256
|
|||||||
MAX_MATERIAL_COUNT = 256
|
MAX_MATERIAL_COUNT = 256
|
||||||
|
|
||||||
|
|
||||||
class PskExporter(object):
|
def _write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None):
|
||||||
|
section = Section()
|
||||||
|
section.name = name
|
||||||
|
if data_type is not None and data is not None:
|
||||||
|
section.data_size = sizeof(data_type)
|
||||||
|
section.data_count = len(data)
|
||||||
|
fp.write(section)
|
||||||
|
if data is not None:
|
||||||
|
for datum in data:
|
||||||
|
fp.write(datum)
|
||||||
|
|
||||||
def __init__(self, psk: Psk):
|
|
||||||
self.psk: Psk = psk
|
|
||||||
|
|
||||||
@staticmethod
|
def export_psk(psk: Psk, path: str):
|
||||||
def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None):
|
if len(psk.wedges) > MAX_WEDGE_COUNT:
|
||||||
section = Section()
|
raise RuntimeError(f'Number of wedges ({len(psk.wedges)}) exceeds limit of {MAX_WEDGE_COUNT}')
|
||||||
section.name = name
|
if len(psk.bones) > MAX_BONE_COUNT:
|
||||||
if data_type is not None and data is not None:
|
raise RuntimeError(f'Number of bones ({len(psk.bones)}) exceeds limit of {MAX_BONE_COUNT}')
|
||||||
section.data_size = sizeof(data_type)
|
if len(psk.points) > MAX_POINT_COUNT:
|
||||||
section.data_count = len(data)
|
raise RuntimeError(f'Numbers of vertices ({len(psk.points)}) exceeds limit of {MAX_POINT_COUNT}')
|
||||||
fp.write(section)
|
if len(psk.materials) > MAX_MATERIAL_COUNT:
|
||||||
if data is not None:
|
raise RuntimeError(f'Number of materials ({len(psk.materials)}) exceeds limit of {MAX_MATERIAL_COUNT}')
|
||||||
for datum in data:
|
|
||||||
fp.write(datum)
|
|
||||||
|
|
||||||
def export(self, path: str):
|
with open(path, 'wb') as fp:
|
||||||
if len(self.psk.wedges) > MAX_WEDGE_COUNT:
|
_write_section(fp, b'ACTRHEAD')
|
||||||
raise RuntimeError(f'Number of wedges ({len(self.psk.wedges)}) exceeds limit of {MAX_WEDGE_COUNT}')
|
_write_section(fp, b'PNTS0000', Vector3, psk.points)
|
||||||
if len(self.psk.bones) > MAX_BONE_COUNT:
|
|
||||||
raise RuntimeError(f'Number of bones ({len(self.psk.bones)}) exceeds limit of {MAX_BONE_COUNT}')
|
|
||||||
if len(self.psk.points) > MAX_POINT_COUNT:
|
|
||||||
raise RuntimeError(f'Numbers of vertices ({len(self.psk.points)}) exceeds limit of {MAX_POINT_COUNT}')
|
|
||||||
if len(self.psk.materials) > MAX_MATERIAL_COUNT:
|
|
||||||
raise RuntimeError(f'Number of materials ({len(self.psk.materials)}) exceeds limit of {MAX_MATERIAL_COUNT}')
|
|
||||||
|
|
||||||
with open(path, 'wb') as fp:
|
wedges = []
|
||||||
self.write_section(fp, b'ACTRHEAD')
|
for index, w in enumerate(psk.wedges):
|
||||||
self.write_section(fp, b'PNTS0000', Vector3, self.psk.points)
|
wedge = Psk.Wedge16()
|
||||||
|
wedge.material_index = w.material_index
|
||||||
|
wedge.u = w.u
|
||||||
|
wedge.v = w.v
|
||||||
|
wedge.point_index = w.point_index
|
||||||
|
wedges.append(wedge)
|
||||||
|
|
||||||
wedges = []
|
_write_section(fp, b'VTXW0000', Psk.Wedge16, wedges)
|
||||||
for index, w in enumerate(self.psk.wedges):
|
_write_section(fp, b'FACE0000', Psk.Face, psk.faces)
|
||||||
wedge = Psk.Wedge16()
|
_write_section(fp, b'MATT0000', Psk.Material, psk.materials)
|
||||||
wedge.material_index = w.material_index
|
_write_section(fp, b'REFSKELT', Psk.Bone, psk.bones)
|
||||||
wedge.u = w.u
|
_write_section(fp, b'RAWWEIGHTS', Psk.Weight, psk.weights)
|
||||||
wedge.v = w.v
|
|
||||||
wedge.point_index = w.point_index
|
|
||||||
wedges.append(wedge)
|
|
||||||
|
|
||||||
self.write_section(fp, b'VTXW0000', Psk.Wedge16, wedges)
|
|
||||||
self.write_section(fp, b'FACE0000', Psk.Face, self.psk.faces)
|
|
||||||
self.write_section(fp, b'MATT0000', Psk.Material, self.psk.materials)
|
|
||||||
self.write_section(fp, b'REFSKELT', Psk.Bone, self.psk.bones)
|
|
||||||
self.write_section(fp, b'RAWWEIGHTS', Psk.Weight, self.psk.weights)
|
|
||||||
|
|
||||||
|
|
||||||
def is_bone_filter_mode_item_available(context, identifier):
|
def is_bone_filter_mode_item_available(context, identifier):
|
||||||
input_objects = PskBuilder.get_input_objects(context)
|
input_objects = get_psk_input_objects(context)
|
||||||
armature_object = input_objects.armature_object
|
armature_object = input_objects.armature_object
|
||||||
if identifier == 'BONE_GROUPS':
|
if identifier == 'BONE_GROUPS':
|
||||||
if not armature_object or not armature_object.pose or not armature_object.pose.bone_groups:
|
if not armature_object or not armature_object.pose or not armature_object.pose.bone_groups:
|
||||||
@@ -72,6 +67,75 @@ def is_bone_filter_mode_item_available(context, identifier):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class PSK_UL_MaterialList(UIList):
|
||||||
|
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||||
|
row = layout.row()
|
||||||
|
row.label(text=str(getattr(item, 'material_name')), icon='MATERIAL')
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialListItem(PropertyGroup):
|
||||||
|
material_name: StringProperty()
|
||||||
|
index: IntProperty()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self.material_name
|
||||||
|
|
||||||
|
|
||||||
|
def populate_material_list(mesh_objects, material_list):
|
||||||
|
material_list.clear()
|
||||||
|
|
||||||
|
material_names = []
|
||||||
|
for mesh_object in mesh_objects:
|
||||||
|
for i, material in enumerate(mesh_object.data.materials):
|
||||||
|
# TODO: put this in the poll arg?
|
||||||
|
if material is None:
|
||||||
|
raise RuntimeError('Material cannot be empty (index ' + str(i) + ')')
|
||||||
|
if material.name not in material_names:
|
||||||
|
material_names.append(material.name)
|
||||||
|
|
||||||
|
for index, material_name in enumerate(material_names):
|
||||||
|
m = material_list.add()
|
||||||
|
m.material_name = material_name
|
||||||
|
m.index = index
|
||||||
|
|
||||||
|
|
||||||
|
class PskMaterialListItemMoveUp(Operator):
|
||||||
|
bl_idname = 'psk_export.material_list_item_move_up'
|
||||||
|
bl_label = 'Move Up'
|
||||||
|
bl_options = {'INTERNAL'}
|
||||||
|
bl_description = 'Move the selected material up one slot'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
pg = getattr(context.scene, 'psk_export')
|
||||||
|
return pg.material_list_index > 0
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
pg = getattr(context.scene, 'psk_export')
|
||||||
|
pg.material_list.move(pg.material_list_index, pg.material_list_index - 1)
|
||||||
|
pg.material_list_index -= 1
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class PskMaterialListItemMoveDown(Operator):
|
||||||
|
bl_idname = 'psk_export.material_list_item_move_down'
|
||||||
|
bl_label = 'Move Down'
|
||||||
|
bl_options = {'INTERNAL'}
|
||||||
|
bl_description = 'Move the selected material down one slot'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
pg = getattr(context.scene, 'psk_export')
|
||||||
|
return pg.material_list_index < len(pg.material_list) - 1
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
pg = getattr(context.scene, 'psk_export')
|
||||||
|
pg.material_list.move(pg.material_list_index, pg.material_list_index + 1)
|
||||||
|
pg.material_list_index += 1
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
class PskExportOperator(Operator, ExportHelper):
|
class PskExportOperator(Operator, ExportHelper):
|
||||||
bl_idname = 'export.psk'
|
bl_idname = 'export.psk'
|
||||||
bl_label = 'Export'
|
bl_label = 'Export'
|
||||||
@@ -88,30 +152,40 @@ class PskExportOperator(Operator, ExportHelper):
|
|||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
try:
|
try:
|
||||||
input_objects = PskBuilder.get_input_objects(context)
|
input_objects = get_psk_input_objects(context)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||||
return {'CANCELLED'}
|
return {'CANCELLED'}
|
||||||
|
|
||||||
pg = context.scene.psk_export
|
pg = getattr(context.scene, 'psk_export')
|
||||||
|
|
||||||
# Populate bone groups list.
|
# Populate bone groups list.
|
||||||
populate_bone_group_list(input_objects.armature_object, pg.bone_group_list)
|
populate_bone_group_list(input_objects.armature_object, pg.bone_group_list)
|
||||||
|
populate_material_list(input_objects.mesh_objects, pg.material_list)
|
||||||
|
|
||||||
context.window_manager.fileselect_add(self)
|
context.window_manager.fileselect_add(self)
|
||||||
|
|
||||||
return {'RUNNING_MODAL'}
|
return {'RUNNING_MODAL'}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
try:
|
||||||
|
get_psk_input_objects(context)
|
||||||
|
except RuntimeError as e:
|
||||||
|
cls.poll_message_set(str(e))
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
scene = context.scene
|
pg = getattr(context.scene, 'psk_export')
|
||||||
pg = scene.psk_export
|
|
||||||
|
layout.prop(pg, 'use_raw_mesh_data')
|
||||||
|
|
||||||
# BONES
|
# BONES
|
||||||
box = layout.box()
|
layout.label(text='Bones', icon='BONE_DATA')
|
||||||
box.label(text='Bones', icon='BONE_DATA')
|
|
||||||
bone_filter_mode_items = pg.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)
|
row = layout.row(align=True)
|
||||||
for item in bone_filter_mode_items:
|
for item in bone_filter_mode_items:
|
||||||
identifier = item.identifier
|
identifier = item.identifier
|
||||||
item_layout = row.row(align=True)
|
item_layout = row.row(align=True)
|
||||||
@@ -119,20 +193,38 @@ class PskExportOperator(Operator, ExportHelper):
|
|||||||
item_layout.enabled = is_bone_filter_mode_item_available(context, identifier)
|
item_layout.enabled = is_bone_filter_mode_item_available(context, identifier)
|
||||||
|
|
||||||
if pg.bone_filter_mode == 'BONE_GROUPS':
|
if pg.bone_filter_mode == 'BONE_GROUPS':
|
||||||
row = box.row()
|
row = layout.row()
|
||||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
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)
|
row.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows)
|
||||||
|
|
||||||
|
layout.separator()
|
||||||
|
|
||||||
|
# MATERIALS
|
||||||
|
layout.label(text='Materials', icon='MATERIAL')
|
||||||
|
row = layout.row()
|
||||||
|
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||||
|
row.template_list('PSK_UL_MaterialList', '', pg, 'material_list', pg, 'material_list_index', rows=rows)
|
||||||
|
col = row.column(align=True)
|
||||||
|
col.operator(PskMaterialListItemMoveUp.bl_idname, text='', icon='TRIA_UP')
|
||||||
|
col.operator(PskMaterialListItemMoveDown.bl_idname, text='', icon='TRIA_DOWN')
|
||||||
|
|
||||||
|
layout.separator()
|
||||||
|
|
||||||
|
layout.prop(pg, 'should_ignore_bone_name_restrictions')
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psk_export
|
pg = context.scene.psk_export
|
||||||
builder = PskBuilder()
|
options = PskBuildOptions()
|
||||||
options = PskBuilderOptions()
|
|
||||||
options.bone_filter_mode = pg.bone_filter_mode
|
options.bone_filter_mode = pg.bone_filter_mode
|
||||||
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
|
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
|
||||||
|
options.use_raw_mesh_data = pg.use_raw_mesh_data
|
||||||
|
options.material_names = [m.material_name for m in pg.material_list]
|
||||||
|
options.should_ignore_bone_name_restrictions = pg.should_ignore_bone_name_restrictions
|
||||||
|
|
||||||
try:
|
try:
|
||||||
psk = builder.build(context, options)
|
psk = build_psk(context, options)
|
||||||
exporter = PskExporter(psk)
|
export_psk(psk, self.filepath)
|
||||||
exporter.export(self.filepath)
|
self.report({'INFO'}, f'PSK export successful')
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||||
return {'CANCELLED'}
|
return {'CANCELLED'}
|
||||||
@@ -152,9 +244,22 @@ class PskExportPropertyGroup(PropertyGroup):
|
|||||||
)
|
)
|
||||||
bone_group_list: CollectionProperty(type=BoneGroupListItem)
|
bone_group_list: CollectionProperty(type=BoneGroupListItem)
|
||||||
bone_group_list_index: IntProperty(default=0)
|
bone_group_list_index: IntProperty(default=0)
|
||||||
|
use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh')
|
||||||
|
material_list: CollectionProperty(type=MaterialListItem)
|
||||||
|
material_list_index: IntProperty(default=0)
|
||||||
|
should_ignore_bone_name_restrictions: BoolProperty(
|
||||||
|
default=False,
|
||||||
|
name='Ignore Bone Name Restrictions',
|
||||||
|
description='Bone names restrictions will be ignored. Note that bone names without properly formatted names '
|
||||||
|
'cannot be referenced in scripts.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
classes = (
|
classes = (
|
||||||
|
MaterialListItem,
|
||||||
|
PSK_UL_MaterialList,
|
||||||
|
PskMaterialListItemMoveUp,
|
||||||
|
PskMaterialListItemMoveDown,
|
||||||
PskExportOperator,
|
PskExportOperator,
|
||||||
PskExportPropertyGroup
|
PskExportPropertyGroup,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,36 +1,55 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from math import inf
|
from math import inf
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
|
||||||
import bmesh
|
import bmesh
|
||||||
import bpy
|
import bpy
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from bpy.props import BoolProperty, EnumProperty, FloatProperty, StringProperty
|
from bpy.props import BoolProperty, EnumProperty, FloatProperty, StringProperty
|
||||||
from bpy.types import Operator, PropertyGroup
|
from bpy.types import Operator, PropertyGroup, VertexGroup
|
||||||
from bpy_extras.io_utils import ImportHelper
|
from bpy_extras.io_utils import ImportHelper
|
||||||
from mathutils import Quaternion, Vector, Matrix
|
from mathutils import Quaternion, Vector, Matrix
|
||||||
|
|
||||||
from .data import Psk
|
from .data import Psk
|
||||||
from .reader import PskReader
|
from .reader import read_psk
|
||||||
from ..helpers import rgb_to_srgb
|
from ..helpers import rgb_to_srgb
|
||||||
|
|
||||||
|
|
||||||
class PskImportOptions(object):
|
class PskImportOptions(object):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.name = ''
|
self.name = ''
|
||||||
|
self.should_import_mesh = True
|
||||||
self.should_import_vertex_colors = True
|
self.should_import_vertex_colors = True
|
||||||
self.vertex_color_space = 'sRGB'
|
self.vertex_color_space = 'sRGB'
|
||||||
self.should_import_vertex_normals = True
|
self.should_import_vertex_normals = True
|
||||||
self.should_import_extra_uvs = True
|
self.should_import_extra_uvs = True
|
||||||
|
self.should_import_skeleton = True
|
||||||
self.bone_length = 1.0
|
self.bone_length = 1.0
|
||||||
|
|
||||||
|
|
||||||
class PskImporter(object):
|
class ImportBone(object):
|
||||||
def __init__(self):
|
"""
|
||||||
pass
|
Intermediate bone type for the purpose of construction.
|
||||||
|
"""
|
||||||
|
def __init__(self, index: int, psk_bone: Psk.Bone):
|
||||||
|
self.index: int = index
|
||||||
|
self.psk_bone: Psk.Bone = psk_bone
|
||||||
|
self.parent: Optional[ImportBone] = None
|
||||||
|
self.local_rotation: Quaternion = Quaternion()
|
||||||
|
self.local_translation: Vector = Vector()
|
||||||
|
self.world_rotation_matrix: Matrix = Matrix()
|
||||||
|
self.world_matrix: Matrix = Matrix()
|
||||||
|
self.vertex_group = None
|
||||||
|
self.orig_quat: Quaternion = Quaternion()
|
||||||
|
self.orig_loc: Vector = Vector()
|
||||||
|
self.post_quat: Quaternion = Quaternion()
|
||||||
|
|
||||||
def import_psk(self, psk: Psk, context, options: PskImportOptions):
|
|
||||||
|
def import_psk(psk: Psk, context, options: PskImportOptions):
|
||||||
|
armature_object = None
|
||||||
|
|
||||||
|
if options.should_import_skeleton:
|
||||||
# ARMATURE
|
# ARMATURE
|
||||||
armature_data = bpy.data.armatures.new(options.name)
|
armature_data = bpy.data.armatures.new(options.name)
|
||||||
armature_object = bpy.data.objects.new(options.name, armature_data)
|
armature_object = bpy.data.objects.new(options.name, armature_data)
|
||||||
@@ -48,21 +67,6 @@ class PskImporter(object):
|
|||||||
|
|
||||||
bpy.ops.object.mode_set(mode='EDIT')
|
bpy.ops.object.mode_set(mode='EDIT')
|
||||||
|
|
||||||
# Intermediate bone type for the purpose of construction.
|
|
||||||
class ImportBone(object):
|
|
||||||
def __init__(self, index: int, psk_bone: Psk.Bone):
|
|
||||||
self.index: int = index
|
|
||||||
self.psk_bone: Psk.Bone = psk_bone
|
|
||||||
self.parent: Optional[ImportBone] = None
|
|
||||||
self.local_rotation: Quaternion = Quaternion()
|
|
||||||
self.local_translation: Vector = Vector()
|
|
||||||
self.world_rotation_matrix: Matrix = Matrix()
|
|
||||||
self.world_matrix: Matrix = Matrix()
|
|
||||||
self.vertex_group = None
|
|
||||||
self.orig_quat: Quaternion = Quaternion()
|
|
||||||
self.orig_loc: Vector = Vector()
|
|
||||||
self.post_quat: Quaternion = Quaternion()
|
|
||||||
|
|
||||||
import_bones = []
|
import_bones = []
|
||||||
|
|
||||||
for bone_index, psk_bone in enumerate(psk.bones):
|
for bone_index, psk_bone in enumerate(psk.bones):
|
||||||
@@ -109,7 +113,8 @@ class PskImporter(object):
|
|||||||
edit_bone['orig_loc'] = import_bone.local_translation
|
edit_bone['orig_loc'] = import_bone.local_translation
|
||||||
edit_bone['post_quat'] = import_bone.local_rotation.conjugated()
|
edit_bone['post_quat'] = import_bone.local_rotation.conjugated()
|
||||||
|
|
||||||
# MESH
|
# MESH
|
||||||
|
if options.should_import_mesh:
|
||||||
mesh_data = bpy.data.meshes.new(options.name)
|
mesh_data = bpy.data.meshes.new(options.name)
|
||||||
mesh_object = bpy.data.objects.new(options.name, mesh_data)
|
mesh_object = bpy.data.objects.new(options.name, mesh_data)
|
||||||
|
|
||||||
@@ -211,36 +216,40 @@ class PskImporter(object):
|
|||||||
|
|
||||||
# Get a list of all bones that have weights associated with them.
|
# 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))
|
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))):
|
vertex_groups: List[Optional[VertexGroup]] = [None] * len(psk.bones)
|
||||||
import_bone.vertex_group = mesh_object.vertex_groups.new(
|
for bone_index, psk_bone in map(lambda x: (x, psk.bones[x]), vertex_group_bone_indices):
|
||||||
name=import_bone.psk_bone.name.decode('windows-1252'))
|
vertex_groups[bone_index] = mesh_object.vertex_groups.new(name=psk_bone.name.decode('windows-1252'))
|
||||||
|
|
||||||
for weight in psk.weights:
|
for weight in psk.weights:
|
||||||
import_bones[weight.bone_index].vertex_group.add((weight.point_index,), weight.weight, 'ADD')
|
vertex_groups[weight.bone_index].add((weight.point_index,), weight.weight, 'ADD')
|
||||||
|
|
||||||
# Add armature modifier to our mesh object.
|
|
||||||
armature_modifier = mesh_object.modifiers.new(name='Armature', type='ARMATURE')
|
|
||||||
armature_modifier.object = armature_object
|
|
||||||
mesh_object.parent = armature_object
|
|
||||||
|
|
||||||
context.scene.collection.objects.link(mesh_object)
|
context.scene.collection.objects.link(mesh_object)
|
||||||
|
|
||||||
try:
|
# Add armature modifier to our mesh object.
|
||||||
bpy.ops.object.mode_set(mode='OBJECT')
|
if options.should_import_skeleton:
|
||||||
except:
|
armature_modifier = mesh_object.modifiers.new(name='Armature', type='ARMATURE')
|
||||||
pass
|
armature_modifier.object = armature_object
|
||||||
|
mesh_object.parent = armature_object
|
||||||
|
|
||||||
|
try:
|
||||||
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
empty_set = set()
|
||||||
|
|
||||||
|
|
||||||
class PskImportPropertyGroup(PropertyGroup):
|
class PskImportPropertyGroup(PropertyGroup):
|
||||||
should_import_vertex_colors: BoolProperty(
|
should_import_vertex_colors: BoolProperty(
|
||||||
default=True,
|
default=True,
|
||||||
options=set(),
|
options=empty_set,
|
||||||
name='Vertex Colors',
|
name='Vertex Colors',
|
||||||
description='Import vertex colors from PSKX files, if available'
|
description='Import vertex colors from PSKX files, if available'
|
||||||
)
|
)
|
||||||
vertex_color_space: EnumProperty(
|
vertex_color_space: EnumProperty(
|
||||||
name='Vertex Color Space',
|
name='Vertex Color Space',
|
||||||
options=set(),
|
options=empty_set,
|
||||||
description='The source vertex color space',
|
description='The source vertex color space',
|
||||||
default='SRGBA',
|
default='SRGBA',
|
||||||
items=(
|
items=(
|
||||||
@@ -251,29 +260,41 @@ class PskImportPropertyGroup(PropertyGroup):
|
|||||||
should_import_vertex_normals: BoolProperty(
|
should_import_vertex_normals: BoolProperty(
|
||||||
default=True,
|
default=True,
|
||||||
name='Vertex Normals',
|
name='Vertex Normals',
|
||||||
options=set(),
|
options=empty_set,
|
||||||
description='Import vertex normals from PSKX files, if available'
|
description='Import vertex normals from PSKX files, if available'
|
||||||
)
|
)
|
||||||
should_import_extra_uvs: BoolProperty(
|
should_import_extra_uvs: BoolProperty(
|
||||||
default=True,
|
default=True,
|
||||||
name='Extra UVs',
|
name='Extra UVs',
|
||||||
options=set(),
|
options=empty_set,
|
||||||
description='Import extra UV maps from PSKX files, if available'
|
description='Import extra UV maps from PSKX files, if available'
|
||||||
)
|
)
|
||||||
|
should_import_mesh: BoolProperty(
|
||||||
|
default=True,
|
||||||
|
name='Import Mesh',
|
||||||
|
options=empty_set,
|
||||||
|
description='Import mesh'
|
||||||
|
)
|
||||||
|
should_import_skeleton: BoolProperty(
|
||||||
|
default=True,
|
||||||
|
name='Import Skeleton',
|
||||||
|
options=empty_set,
|
||||||
|
description='Import skeleton'
|
||||||
|
)
|
||||||
bone_length: FloatProperty(
|
bone_length: FloatProperty(
|
||||||
default=1.0,
|
default=1.0,
|
||||||
min=sys.float_info.epsilon,
|
min=sys.float_info.epsilon,
|
||||||
step=100,
|
step=100,
|
||||||
soft_min=1.0,
|
soft_min=1.0,
|
||||||
name='Bone Length',
|
name='Bone Length',
|
||||||
options=set(),
|
options=empty_set,
|
||||||
description='Length of the bones'
|
description='Length of the bones'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PskImportOperator(Operator, ImportHelper):
|
class PskImportOperator(Operator, ImportHelper):
|
||||||
bl_idname = 'import.psk'
|
bl_idname = 'import.psk'
|
||||||
bl_label = 'Export'
|
bl_label = 'Import'
|
||||||
bl_options = {'INTERNAL', 'UNDO'}
|
bl_options = {'INTERNAL', 'UNDO'}
|
||||||
__doc__ = 'Load a PSK file'
|
__doc__ = 'Load a PSK file'
|
||||||
filename_ext = '.psk'
|
filename_ext = '.psk'
|
||||||
@@ -285,30 +306,43 @@ class PskImportOperator(Operator, ImportHelper):
|
|||||||
default='')
|
default='')
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
pg = context.scene.psk_import
|
pg = getattr(context.scene, 'psk_import')
|
||||||
reader = PskReader()
|
|
||||||
psk = reader.read(self.filepath)
|
psk = read_psk(self.filepath)
|
||||||
|
|
||||||
options = PskImportOptions()
|
options = PskImportOptions()
|
||||||
options.name = os.path.splitext(os.path.basename(self.filepath))[0]
|
options.name = os.path.splitext(os.path.basename(self.filepath))[0]
|
||||||
|
options.should_import_mesh = pg.should_import_mesh
|
||||||
options.should_import_extra_uvs = pg.should_import_extra_uvs
|
options.should_import_extra_uvs = pg.should_import_extra_uvs
|
||||||
options.should_import_vertex_colors = pg.should_import_vertex_colors
|
options.should_import_vertex_colors = pg.should_import_vertex_colors
|
||||||
options.should_import_vertex_normals = pg.should_import_vertex_normals
|
options.should_import_vertex_normals = pg.should_import_vertex_normals
|
||||||
options.vertex_color_space = pg.vertex_color_space
|
options.vertex_color_space = pg.vertex_color_space
|
||||||
|
options.should_import_skeleton = pg.should_import_skeleton
|
||||||
options.bone_length = pg.bone_length
|
options.bone_length = pg.bone_length
|
||||||
PskImporter().import_psk(psk, context, options)
|
|
||||||
|
import_psk(psk, context, options)
|
||||||
|
|
||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
pg = context.scene.psk_import
|
pg = getattr(context.scene, 'psk_import')
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
layout.use_property_split = True
|
layout.prop(pg, 'should_import_mesh')
|
||||||
layout.use_property_decorate = False
|
row = layout.column()
|
||||||
layout.prop(pg, 'should_import_vertex_normals')
|
row.use_property_split = True
|
||||||
layout.prop(pg, 'should_import_extra_uvs')
|
row.use_property_decorate = False
|
||||||
layout.prop(pg, 'should_import_vertex_colors')
|
if pg.should_import_mesh:
|
||||||
if pg.should_import_vertex_colors:
|
row.prop(pg, 'should_import_vertex_normals')
|
||||||
layout.prop(pg, 'vertex_color_space')
|
row.prop(pg, 'should_import_extra_uvs')
|
||||||
layout.prop(pg, 'bone_length')
|
row.prop(pg, 'should_import_vertex_colors')
|
||||||
|
if pg.should_import_vertex_colors:
|
||||||
|
row.prop(pg, 'vertex_color_space')
|
||||||
|
layout.prop(pg, 'should_import_skeleton')
|
||||||
|
row = layout.column()
|
||||||
|
row.use_property_split = True
|
||||||
|
row.use_property_decorate = False
|
||||||
|
if pg.should_import_skeleton:
|
||||||
|
row.prop(pg, 'bone_length')
|
||||||
|
|
||||||
|
|
||||||
classes = (
|
classes = (
|
||||||
|
|||||||
@@ -3,53 +3,48 @@ import ctypes
|
|||||||
from .data import *
|
from .data import *
|
||||||
|
|
||||||
|
|
||||||
class PskReader(object):
|
def _read_types(fp, data_class, 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 __init__(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
def read_psk(path) -> Psk:
|
||||||
def read_types(fp, data_class: ctypes.Structure, section: Section, data):
|
psk = Psk()
|
||||||
buffer_length = section.data_size * section.data_count
|
with open(path, 'rb') as fp:
|
||||||
buffer = fp.read(buffer_length)
|
while fp.read(1):
|
||||||
offset = 0
|
fp.seek(-1, 1)
|
||||||
for _ in range(section.data_count):
|
section = Section.from_buffer_copy(fp.read(ctypes.sizeof(Section)))
|
||||||
data.append(data_class.from_buffer_copy(buffer, offset))
|
if section.name == b'ACTRHEAD':
|
||||||
offset += section.data_size
|
pass
|
||||||
|
elif section.name == b'PNTS0000':
|
||||||
def read(self, path) -> Psk:
|
_read_types(fp, Vector3, section, psk.points)
|
||||||
psk = Psk()
|
elif section.name == b'VTXW0000':
|
||||||
with open(path, 'rb') as fp:
|
if section.data_size == ctypes.sizeof(Psk.Wedge16):
|
||||||
while fp.read(1):
|
_read_types(fp, Psk.Wedge16, section, psk.wedges)
|
||||||
fp.seek(-1, 1)
|
elif section.data_size == ctypes.sizeof(Psk.Wedge32):
|
||||||
section = Section.from_buffer_copy(fp.read(ctypes.sizeof(Section)))
|
_read_types(fp, Psk.Wedge32, section, psk.wedges)
|
||||||
if section.name == b'ACTRHEAD':
|
|
||||||
pass
|
|
||||||
elif section.name == b'PNTS0000':
|
|
||||||
PskReader.read_types(fp, Vector3, section, psk.points)
|
|
||||||
elif section.name == b'VTXW0000':
|
|
||||||
if section.data_size == ctypes.sizeof(Psk.Wedge16):
|
|
||||||
PskReader.read_types(fp, Psk.Wedge16, section, psk.wedges)
|
|
||||||
elif section.data_size == ctypes.sizeof(Psk.Wedge32):
|
|
||||||
PskReader.read_types(fp, Psk.Wedge32, section, psk.wedges)
|
|
||||||
else:
|
|
||||||
raise RuntimeError('Unrecognized wedge format')
|
|
||||||
elif section.name == b'FACE0000':
|
|
||||||
PskReader.read_types(fp, Psk.Face, section, psk.faces)
|
|
||||||
elif section.name == b'MATT0000':
|
|
||||||
PskReader.read_types(fp, Psk.Material, section, psk.materials)
|
|
||||||
elif section.name == b'REFSKELT':
|
|
||||||
PskReader.read_types(fp, Psk.Bone, section, psk.bones)
|
|
||||||
elif section.name == b'RAWWEIGHTS':
|
|
||||||
PskReader.read_types(fp, Psk.Weight, section, psk.weights)
|
|
||||||
elif section.name == b'FACE3200':
|
|
||||||
PskReader.read_types(fp, Psk.Face32, section, psk.faces)
|
|
||||||
elif section.name == b'VERTEXCOLOR':
|
|
||||||
PskReader.read_types(fp, Color, section, psk.vertex_colors)
|
|
||||||
elif section.name.startswith(b'EXTRAUVS'):
|
|
||||||
PskReader.read_types(fp, Vector2, section, psk.extra_uvs)
|
|
||||||
elif section.name == b'VTXNORMS':
|
|
||||||
PskReader.read_types(fp, Vector3, section, psk.vertex_normals)
|
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f'Unrecognized section "{section.name} at position {15:fp.tell()}"')
|
raise RuntimeError('Unrecognized wedge format')
|
||||||
return psk
|
elif section.name == b'FACE0000':
|
||||||
|
_read_types(fp, Psk.Face, section, psk.faces)
|
||||||
|
elif section.name == b'MATT0000':
|
||||||
|
_read_types(fp, Psk.Material, section, psk.materials)
|
||||||
|
elif section.name == b'REFSKELT':
|
||||||
|
_read_types(fp, Psk.Bone, section, psk.bones)
|
||||||
|
elif section.name == b'RAWWEIGHTS':
|
||||||
|
_read_types(fp, Psk.Weight, section, psk.weights)
|
||||||
|
elif section.name == b'FACE3200':
|
||||||
|
_read_types(fp, Psk.Face32, section, psk.faces)
|
||||||
|
elif section.name == b'VERTEXCOLOR':
|
||||||
|
_read_types(fp, Color, section, psk.vertex_colors)
|
||||||
|
elif section.name.startswith(b'EXTRAUVS'):
|
||||||
|
_read_types(fp, Vector2, section, psk.extra_uvs)
|
||||||
|
elif section.name == b'VTXNORMS':
|
||||||
|
_read_types(fp, Vector3, section, psk.vertex_normals)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f'Unrecognized section "{section.name} at position {15:fp.tell()}"')
|
||||||
|
return psk
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
from bpy.props import StringProperty, IntProperty, BoolProperty
|
from bpy.props import StringProperty, IntProperty, BoolProperty
|
||||||
from bpy.types import PropertyGroup, UIList
|
from bpy.types import PropertyGroup, UIList, UILayout, Context, AnyType
|
||||||
|
|
||||||
|
|
||||||
class PSX_UL_BoneGroupList(UIList):
|
class PSX_UL_BoneGroupList(UIList):
|
||||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
|
||||||
|
def draw_item(self, context: Context, layout: UILayout, data: AnyType, item: AnyType, icon: int,
|
||||||
|
active_data: AnyType, active_property: str, index: int = 0, flt_flag: int = 0):
|
||||||
row = layout.row()
|
row = layout.row()
|
||||||
row.prop(item, 'is_selected', text=item.name)
|
row.prop(item, 'is_selected', text=getattr(item, 'name'))
|
||||||
row.label(text=str(item.count), icon='BONE_DATA')
|
row.label(text=str(getattr(item, 'count')), icon='BONE_DATA')
|
||||||
|
|
||||||
|
|
||||||
class BoneGroupListItem(PropertyGroup):
|
class BoneGroupListItem(PropertyGroup):
|
||||||
@@ -15,10 +17,6 @@ class BoneGroupListItem(PropertyGroup):
|
|||||||
count: IntProperty()
|
count: IntProperty()
|
||||||
is_selected: BoolProperty(default=False)
|
is_selected: BoolProperty(default=False)
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
classes = (
|
classes = (
|
||||||
BoneGroupListItem,
|
BoneGroupListItem,
|
||||||
|
|||||||
Reference in New Issue
Block a user