Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c99725b686 | ||
|
|
947c86eb8f | ||
|
|
f40db53cb9 | ||
|
|
ab998885bb | ||
|
|
f821bec0ff | ||
|
|
43b0fe82dd | ||
|
|
17e9e83826 | ||
|
|
44afce3e64 | ||
|
|
449331cd00 | ||
|
|
8ada80e243 | ||
|
|
38ed183897 | ||
|
|
9ae573422b | ||
|
|
86473584b8 | ||
|
|
aa8725c3d0 | ||
|
|
980042fc7f | ||
|
|
02082b9594 | ||
|
|
4181a15d0e | ||
|
|
b6ef3dda44 | ||
|
|
f7290e6808 | ||
|
|
65d3104ea9 | ||
|
|
1a48128cb9 | ||
|
|
88c22c9e80 |
@@ -1,13 +1,13 @@
|
|||||||
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.
|
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 versions of the Unreal Engine.
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
* Full PSK/PSA import and export capabilities
|
* Full PSK/PSA import and export capabilities
|
||||||
* Non-standard PSKX file format with vertex normals, extra UV channels and vertex colors is supported for import only
|
* Non-standard PSKX file format with vertex normals, extra UV channels and vertex colors is supported for import only
|
||||||
* 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
|
|
||||||
* 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
|
||||||
* 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)
|
* 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 direct 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
|
||||||
|
* Manual re-ordering of material slots when exporting multiple mesh objects.
|
||||||
|
|
||||||
# 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,8 +1,8 @@
|
|||||||
bl_info = {
|
bl_info = {
|
||||||
"name": "PSK/PSA Importer/Exporter",
|
"name": "PSK/PSA Importer/Exporter",
|
||||||
"author": "Colin Basnett, Yurii Ti",
|
"author": "Colin Basnett, Yurii Ti",
|
||||||
"version": (4, 0, 0),
|
"version": (4, 2, 1),
|
||||||
"blender": (2, 80, 0),
|
"blender": (2, 90, 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)",
|
||||||
"warning": "",
|
"warning": "",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def get_nla_strips_in_timeframe(animation_data, frame_min, frame_max) -> List[Nl
|
|||||||
return strips
|
return strips
|
||||||
|
|
||||||
|
|
||||||
def populate_bone_group_list(armature_object: Object, bone_group_list: bpy.types.Collection) -> None:
|
def populate_bone_group_list(armature_object: Object, bone_group_list: bpy.props.CollectionProperty) -> None:
|
||||||
"""
|
"""
|
||||||
Updates the bone group collection.
|
Updates the bone group collection.
|
||||||
|
|
||||||
@@ -94,11 +94,11 @@ def get_psa_sequence_name(action, should_use_original_sequence_name):
|
|||||||
|
|
||||||
|
|
||||||
def check_bone_names(bone_names: Iterable[str]):
|
def check_bone_names(bone_names: Iterable[str]):
|
||||||
pattern = re.compile(r'^[a-zA-Z0-9_ ]+$')
|
pattern = re.compile(r'^[a-zA-Z\d_\- ]+$')
|
||||||
invalid_bone_names = [x for x in bone_names if pattern.match(x) is None]
|
invalid_bone_names = [x for x in bone_names if pattern.match(x) is None]
|
||||||
if len(invalid_bone_names) > 0:
|
if len(invalid_bone_names) > 0:
|
||||||
raise RuntimeError(f'The following bone names are invalid: {invalid_bone_names}.\n'
|
raise RuntimeError(f'The following bone names are invalid: {invalid_bone_names}.\n'
|
||||||
f'Bone names must only contain letters, numbers, spaces, and underscores.')
|
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]:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Dict
|
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 *
|
||||||
@@ -19,6 +19,7 @@ class PsaBuildOptions(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
|
self.root_motion = False
|
||||||
@@ -87,7 +88,7 @@ def get_timeline_marker_sequence_frame_ranges(animation_data, context, options:
|
|||||||
return sequence_frame_ranges
|
return sequence_frame_ranges
|
||||||
|
|
||||||
|
|
||||||
def build_psa(context, options: PsaBuildOptions) -> Psa:
|
def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||||
active_object = context.view_layer.objects.active
|
active_object = context.view_layer.objects.active
|
||||||
|
|
||||||
if active_object.type != 'ARMATURE':
|
if active_object.type != 'ARMATURE':
|
||||||
@@ -111,19 +112,20 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
|
|||||||
|
|
||||||
psa = Psa()
|
psa = Psa()
|
||||||
|
|
||||||
armature = active_object
|
armature_object = active_object
|
||||||
bones = list(armature.data.bones)
|
armature_data = typing.cast(Armature, armature_object.data)
|
||||||
|
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.
|
# 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
|
# As a result, we need to reconstruct the list of pose bones in the same order as the
|
||||||
# armature bones.
|
# armature bones.
|
||||||
bone_names = [x.name for x in bones]
|
bone_names = [x.name for x in bones]
|
||||||
pose_bones = [(bone_names.index(bone.name), bone) for bone in armature.pose.bones]
|
pose_bones = [(bone_names.index(bone.name), bone) for bone in armature_object.pose.bones]
|
||||||
pose_bones.sort(key=lambda x: x[0])
|
pose_bones.sort(key=lambda x: x[0])
|
||||||
pose_bones = [x[1] for x in pose_bones]
|
pose_bones = [x[1] for x in pose_bones]
|
||||||
|
|
||||||
# Get a list of all the bone indices and instigator bones for the bone filter settings.
|
# Get a list of all the bone indices and instigator bones for the bone filter settings.
|
||||||
export_bone_names = get_export_bone_names(armature, options.bone_filter_mode, options.bone_group_indices)
|
export_bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices)
|
||||||
bone_indices = [bone_names.index(x) for x in export_bone_names]
|
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.
|
# Make the bone lists contain only the bones that are going to be exported.
|
||||||
@@ -135,6 +137,7 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
|
|||||||
raise RuntimeError('No bones available for export')
|
raise RuntimeError('No bones available for export')
|
||||||
|
|
||||||
# Check that all bone names are valid.
|
# Check that all bone names are valid.
|
||||||
|
if not options.should_ignore_bone_name_restrictions:
|
||||||
check_bone_names(map(lambda bone: bone.name, bones))
|
check_bone_names(map(lambda bone: bone.name, bones))
|
||||||
|
|
||||||
# Build list of PSA bones.
|
# Build list of PSA bones.
|
||||||
@@ -156,9 +159,12 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
|
|||||||
parent_tail = inverse_parent_rotation @ bone.parent.tail
|
parent_tail = inverse_parent_rotation @ bone.parent.tail
|
||||||
location = (parent_tail - parent_head) + bone.head
|
location = (parent_tail - parent_head) + bone.head
|
||||||
else:
|
else:
|
||||||
location = armature.matrix_local @ bone.head
|
armature_local_matrix = armature_object.matrix_local
|
||||||
rot_matrix = bone.matrix @ armature.matrix_local.to_3x3()
|
location = armature_local_matrix @ bone.head
|
||||||
rotation = rot_matrix.to_quaternion()
|
bone_rotation = bone.matrix.to_quaternion().conjugated()
|
||||||
|
local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated()
|
||||||
|
rotation = bone_rotation @ local_rotation
|
||||||
|
rotation.conjugate()
|
||||||
|
|
||||||
psa_bone.location.x = location.x
|
psa_bone.location.x = location.x
|
||||||
psa_bone.location.y = location.y
|
psa_bone.location.y = location.y
|
||||||
@@ -208,6 +214,7 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
|
|||||||
export_sequence.nla_state.action = None
|
export_sequence.nla_state.action = None
|
||||||
export_sequence.nla_state.frame_min = frame_min
|
export_sequence.nla_state.frame_min = frame_min
|
||||||
export_sequence.nla_state.frame_max = frame_max
|
export_sequence.nla_state.frame_max = frame_max
|
||||||
|
|
||||||
nla_strips_actions = set(
|
nla_strips_actions = set(
|
||||||
map(lambda x: x.action, get_nla_strips_in_timeframe(animation_data, frame_min, frame_max)))
|
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_sequence.fps = get_sequence_fps(context, options, nla_strips_actions)
|
||||||
@@ -220,6 +227,10 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
|
|||||||
export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}'
|
export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}'
|
||||||
export_sequence.name = export_sequence.name.strip()
|
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.
|
# Now build the PSA sequences.
|
||||||
# We actually alter the timeline frame and simply record the resultant pose bone matrices.
|
# We actually alter the timeline frame and simply record the resultant pose bone matrices.
|
||||||
frame_start_index = 0
|
frame_start_index = 0
|
||||||
@@ -252,7 +263,7 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
|
|||||||
else:
|
else:
|
||||||
if options.root_motion:
|
if options.root_motion:
|
||||||
# Export root motion
|
# Export root motion
|
||||||
pose_bone_matrix = armature.matrix_world @ pose_bone.matrix
|
pose_bone_matrix = armature_object.matrix_world @ pose_bone.matrix
|
||||||
else:
|
else:
|
||||||
pose_bone_matrix = pose_bone.matrix
|
pose_bone_matrix = pose_bone.matrix
|
||||||
|
|
||||||
@@ -280,4 +291,8 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
|
|||||||
|
|
||||||
psa.sequences[export_sequence.name] = psa_sequence
|
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
|
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] = []
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ class PsaExportPropertyGroup(PropertyGroup):
|
|||||||
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 '
|
||||||
)
|
)
|
||||||
|
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.'
|
||||||
|
)
|
||||||
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
|
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
|
||||||
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)
|
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)
|
||||||
sequence_filter_name: StringProperty(
|
sequence_filter_name: StringProperty(
|
||||||
@@ -262,6 +268,8 @@ 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)
|
||||||
|
|
||||||
|
layout.prop(pg, 'should_ignore_bone_name_restrictions')
|
||||||
|
|
||||||
layout.separator()
|
layout.separator()
|
||||||
|
|
||||||
# ROOT MOTION
|
# ROOT MOTION
|
||||||
@@ -272,7 +280,7 @@ class PsaExportOperator(Operator, ExportHelper):
|
|||||||
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\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path)
|
||||||
if not match:
|
if not match:
|
||||||
continue
|
continue
|
||||||
bone_name = match.group(1)
|
bone_name = match.group(1)
|
||||||
@@ -346,6 +354,7 @@ 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
|
options.root_motion = pg.root_motion
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import fnmatch
|
import fnmatch
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import typing
|
||||||
|
from collections import Counter
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
import numpy as np
|
import numpy
|
||||||
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty
|
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty
|
||||||
from bpy.types import Operator, UIList, PropertyGroup, Panel
|
from bpy.types import Operator, UIList, PropertyGroup, Panel, FCurve
|
||||||
from bpy_extras.io_utils import ImportHelper
|
from bpy_extras.io_utils import ImportHelper
|
||||||
from mathutils import Vector, Quaternion
|
from mathutils import Vector, Quaternion
|
||||||
|
|
||||||
@@ -16,7 +18,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,6 +25,8 @@ 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
|
||||||
|
self.bone_mapping_mode = 'CASE_INSENSITIVE'
|
||||||
|
|
||||||
|
|
||||||
class ImportBone(object):
|
class ImportBone(object):
|
||||||
@@ -35,10 +38,10 @@ class ImportBone(object):
|
|||||||
self.orig_loc: Vector = Vector()
|
self.orig_loc: Vector = Vector()
|
||||||
self.orig_quat: Quaternion = Quaternion()
|
self.orig_quat: Quaternion = Quaternion()
|
||||||
self.post_quat: Quaternion = Quaternion()
|
self.post_quat: Quaternion = Quaternion()
|
||||||
self.fcurves = []
|
self.fcurves: List[FCurve] = []
|
||||||
|
|
||||||
|
|
||||||
def calculate_fcurve_data(import_bone: ImportBone, key_data: []):
|
def calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[float]):
|
||||||
# Convert world-space transforms to local-space transforms.
|
# Convert world-space transforms to local-space transforms.
|
||||||
key_rotation = Quaternion(key_data[0:4])
|
key_rotation = Quaternion(key_data[0:4])
|
||||||
key_location = Vector(key_data[4:])
|
key_location = Vector(key_data[4:])
|
||||||
@@ -56,44 +59,73 @@ def calculate_fcurve_data(import_bone: ImportBone, key_data: []):
|
|||||||
return quat.w, quat.x, quat.y, quat.z, loc.x, loc.y, loc.z
|
return quat.w, quat.x, quat.y, quat.z, loc.x, loc.y, loc.z
|
||||||
|
|
||||||
|
|
||||||
def import_psa(psa_reader: PsaReader, armature_object, options: PsaImportOptions):
|
class PsaImportResult:
|
||||||
|
def __init__(self):
|
||||||
|
self.warnings: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def import_psa(psa_reader: PsaReader, armature_object: bpy.types.Object, options: PsaImportOptions) -> PsaImportResult:
|
||||||
|
result = PsaImportResult()
|
||||||
sequences = map(lambda x: psa_reader.sequences[x], options.sequence_names)
|
sequences = map(lambda x: psa_reader.sequences[x], options.sequence_names)
|
||||||
armature_data = armature_object.data
|
armature_data = typing.cast(bpy.types.Armature, armature_object.data)
|
||||||
|
|
||||||
# Create an index mapping from bones in the PSA to bones in the target armature.
|
# Create an index mapping from bones in the PSA to bones in the target armature.
|
||||||
psa_to_armature_bone_indices = {}
|
psa_to_armature_bone_indices = {}
|
||||||
armature_bone_names = [x.name for x in armature_data.bones]
|
armature_bone_names = [x.name for x in armature_data.bones]
|
||||||
psa_bone_names = []
|
psa_bone_names = []
|
||||||
for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
|
for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
|
||||||
psa_bone_name = psa_bone.name.decode('windows-1252')
|
psa_bone_name: str = psa_bone.name.decode('windows-1252')
|
||||||
psa_bone_names.append(psa_bone_name)
|
|
||||||
try:
|
try:
|
||||||
psa_to_armature_bone_indices[psa_bone_index] = armature_bone_names.index(psa_bone_name)
|
psa_to_armature_bone_indices[psa_bone_index] = armature_bone_names.index(psa_bone_name)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
# PSA bone could not be mapped directly to an armature bone by name.
|
||||||
|
# Attempt to create a bone mapping by ignoring the case of the names.
|
||||||
|
if options.bone_mapping_mode == 'CASE_INSENSITIVE':
|
||||||
|
for armature_bone_index, armature_bone_name in enumerate(armature_bone_names):
|
||||||
|
if armature_bone_name.upper() == psa_bone_name.upper():
|
||||||
|
psa_to_armature_bone_indices[psa_bone_index] = armature_bone_index
|
||||||
|
psa_bone_name = armature_bone_name
|
||||||
|
break
|
||||||
|
psa_bone_names.append(psa_bone_name)
|
||||||
|
|
||||||
|
# Remove ambiguous bone mappings (where multiple PSA bones correspond to the same armature bone).
|
||||||
|
armature_bone_index_counts = Counter(psa_to_armature_bone_indices.values())
|
||||||
|
for armature_bone_index, count in armature_bone_index_counts.items():
|
||||||
|
if count > 1:
|
||||||
|
psa_bone_indices = []
|
||||||
|
for psa_bone_index, mapped_bone_index in psa_to_armature_bone_indices:
|
||||||
|
if mapped_bone_index == armature_bone_index:
|
||||||
|
psa_bone_indices.append(psa_bone_index)
|
||||||
|
ambiguous_psa_bone_names = list(sorted([psa_bone_names[x] for x in psa_bone_indices]))
|
||||||
|
result.warnings.append(
|
||||||
|
f'Ambiguous mapping for bone {armature_bone_names[armature_bone_index]}!\n'
|
||||||
|
f'The following PSA bones all map to the same armature bone: {ambiguous_psa_bone_names}\n'
|
||||||
|
f'These bones will be ignored.'
|
||||||
|
)
|
||||||
|
|
||||||
# Report if there are missing bones in the target armature.
|
# Report if there are missing bones in the target armature.
|
||||||
missing_bone_names = set(psa_bone_names).difference(set(armature_bone_names))
|
missing_bone_names = set(psa_bone_names).difference(set(armature_bone_names))
|
||||||
if len(missing_bone_names) > 0:
|
if len(missing_bone_names) > 0:
|
||||||
print(
|
result.warnings.append(
|
||||||
f'The armature object \'{armature_object.name}\' is missing the following bones that exist in the PSA:')
|
f'The armature \'{armature_object.name}\' is missing {len(missing_bone_names)} bones that exist in '
|
||||||
print(list(sorted(missing_bone_names)))
|
'the PSA:\n' +
|
||||||
|
str(list(sorted(missing_bone_names)))
|
||||||
|
)
|
||||||
del armature_bone_names
|
del armature_bone_names
|
||||||
|
|
||||||
# Create intermediate bone data for import operations.
|
# Create intermediate bone data for import operations.
|
||||||
import_bones = []
|
import_bones = []
|
||||||
import_bones_dict = dict()
|
import_bones_dict = dict()
|
||||||
|
|
||||||
for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
|
for (psa_bone_index, psa_bone), psa_bone_name in zip(enumerate(psa_reader.bones), psa_bone_names):
|
||||||
bone_name = psa_bone.name.decode('windows-1252')
|
if psa_bone_index not in psa_to_armature_bone_indices:
|
||||||
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.
|
# PSA bone does not map to armature bone, skip it and leave an empty bone in its place.
|
||||||
import_bones.append(None)
|
import_bones.append(None)
|
||||||
continue
|
continue
|
||||||
import_bone = ImportBone(psa_bone)
|
import_bone = ImportBone(psa_bone)
|
||||||
import_bone.armature_bone = armature_data.bones[bone_name]
|
import_bone.armature_bone = armature_data.bones[psa_bone_name]
|
||||||
import_bone.pose_bone = armature_object.pose.bones[bone_name]
|
import_bone.pose_bone = armature_object.pose.bones[psa_bone_name]
|
||||||
import_bones_dict[bone_name] = import_bone
|
import_bones_dict[psa_bone_name] = import_bone
|
||||||
import_bones.append(import_bone)
|
import_bones.append(import_bone)
|
||||||
|
|
||||||
for import_bone in filter(lambda x: x is not None, import_bones):
|
for import_bone in filter(lambda x: x is not None, import_bones):
|
||||||
@@ -154,7 +186,6 @@ def import_psa(psa_reader: PsaReader, armature_object, options: PsaImportOptions
|
|||||||
|
|
||||||
# Read the sequence data matrix from the PSA.
|
# Read the sequence data matrix from the PSA.
|
||||||
sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name)
|
sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name)
|
||||||
keyframe_write_matrix = np.ones(sequence_data_matrix.shape, dtype=np.int8)
|
|
||||||
|
|
||||||
# Convert the sequence's data from world-space to local-space.
|
# Convert the sequence's data from world-space to local-space.
|
||||||
for bone_index, import_bone in enumerate(import_bones):
|
for bone_index, import_bone in enumerate(import_bones):
|
||||||
@@ -166,41 +197,23 @@ def import_psa(psa_reader: PsaReader, armature_object, options: PsaImportOptions
|
|||||||
# Calculate the local-space key data for the bone.
|
# Calculate the local-space key data for the bone.
|
||||||
sequence_data_matrix[frame_index, bone_index] = calculate_fcurve_data(import_bone, key_data)
|
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
|
# Write the keyframes out.
|
||||||
# insufficiently large change in the data from the last written frame.
|
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float)
|
||||||
if options.should_clean_keys:
|
fcurve_data[0::2] = range(sequence.frame_count)
|
||||||
threshold = 0.001
|
|
||||||
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 fcurve_index in range(len(import_bone.fcurves)):
|
for fcurve_index, fcurve in enumerate(import_bone.fcurves):
|
||||||
# Get all the keyframe data for the bone's f-curve data from the sequence data matrix.
|
fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index]
|
||||||
fcurve_frame_data = sequence_data_matrix[:, bone_index, fcurve_index]
|
fcurve.keyframe_points.add(sequence.frame_count)
|
||||||
last_written_datum = 0
|
fcurve.keyframe_points.foreach_set('co', fcurve_data)
|
||||||
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!
|
if options.should_convert_to_samples:
|
||||||
for frame_index in range(sequence.frame_count):
|
# Bake the curve to samples.
|
||||||
for bone_index, import_bone in enumerate(import_bones):
|
for fcurve in action.fcurves:
|
||||||
if import_bone is None:
|
fcurve.convert_to_samples(start=0, end=sequence.frame_count)
|
||||||
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
|
# Write meta-data.
|
||||||
if options.should_write_metadata:
|
if options.should_write_metadata:
|
||||||
action['psa_sequence_name'] = sequence_name
|
action['psa_sequence_name'] = sequence_name
|
||||||
action['psa_sequence_fps'] = sequence.fps
|
action['psa_sequence_fps'] = sequence.fps
|
||||||
@@ -219,6 +232,8 @@ def import_psa(psa_reader: PsaReader, armature_object, options: PsaImportOptions
|
|||||||
nla_track.mute = True
|
nla_track.mute = True
|
||||||
nla_track.strips.new(name=action.name, start=0, action=action)
|
nla_track.strips.new(name=action.name, start=0, action=action)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
empty_set = set()
|
empty_set = set()
|
||||||
|
|
||||||
@@ -247,7 +262,7 @@ def load_psa_file(context):
|
|||||||
pg.psa_error = str(e)
|
pg.psa_error = str(e)
|
||||||
|
|
||||||
|
|
||||||
def on_psa_file_path_updated(property, context):
|
def on_psa_file_path_updated(property_, context):
|
||||||
load_psa_file(context)
|
load_psa_file(context)
|
||||||
|
|
||||||
|
|
||||||
@@ -266,11 +281,9 @@ class PsaImportPropertyGroup(PropertyGroup):
|
|||||||
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=empty_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=empty_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',
|
||||||
@@ -278,10 +291,12 @@ class PsaImportPropertyGroup(PropertyGroup):
|
|||||||
should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=empty_set)
|
should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=empty_set)
|
||||||
action_name_prefix: StringProperty(default='', name='Prefix', options=empty_set)
|
action_name_prefix: StringProperty(default='', name='Prefix', options=empty_set)
|
||||||
should_overwrite: BoolProperty(default=False, name='Reuse Existing Actions', options=empty_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=empty_set)
|
should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=empty_set)
|
||||||
should_write_metadata: BoolProperty(default=True, name='Metadata', options=empty_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=empty_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')
|
||||||
@@ -289,6 +304,22 @@ class PsaImportPropertyGroup(PropertyGroup):
|
|||||||
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=empty_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=False,
|
||||||
|
name='Convert to Samples',
|
||||||
|
description='Convert keyframes to read-only samples. '
|
||||||
|
'Recommended if you do not plan on editing the actions directly'
|
||||||
|
)
|
||||||
|
bone_mapping_mode: EnumProperty(
|
||||||
|
name='Bone Mapping',
|
||||||
|
options=empty_set,
|
||||||
|
description='The method by which bones from the incoming PSA file are mapped to the armature',
|
||||||
|
items=(
|
||||||
|
('EXACT', 'Exact', 'Bone names must match exactly.', 'EXACT', 0),
|
||||||
|
('CASE_INSENSITIVE', 'Case Insensitive', 'Bones names must match, ignoring case (e.g., the bone PSA bone '
|
||||||
|
'\'root\' can be mapped to the armature bone \'Root\')', 'CASE_INSENSITIVE', 1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def filter_sequences(pg: PsaImportPropertyGroup, sequences) -> List[int]:
|
def filter_sequences(pg: PsaImportPropertyGroup, sequences) -> List[int]:
|
||||||
@@ -352,9 +383,9 @@ class PSA_UL_SequenceList(UIList):
|
|||||||
sub_row.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT')
|
sub_row.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT')
|
||||||
sub_row.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 = getattr(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')
|
||||||
return flt_flags, flt_neworder
|
return flt_flags, flt_neworder
|
||||||
@@ -457,11 +488,21 @@ class PSA_PT_ImportPanel_Advanced(Panel):
|
|||||||
layout = self.layout
|
layout = self.layout
|
||||||
pg = getattr(context.scene, 'psa_import')
|
pg = getattr(context.scene, 'psa_import')
|
||||||
|
|
||||||
col = layout.column(heading="Options")
|
col = layout.column()
|
||||||
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, 'bone_mapping_mode')
|
||||||
|
|
||||||
|
if pg.should_write_keyframes:
|
||||||
|
col = layout.column(heading='Keyframes')
|
||||||
|
col.use_property_split = True
|
||||||
|
col.use_property_decorate = False
|
||||||
|
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')
|
||||||
@@ -585,16 +626,22 @@ class PsaImportOperator(Operator):
|
|||||||
|
|
||||||
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
|
||||||
|
options.bone_mapping_mode = pg.bone_mapping_mode
|
||||||
|
|
||||||
import_psa(psa_reader, context.view_layer.objects.active, options)
|
result = import_psa(psa_reader, context.view_layer.objects.active, options)
|
||||||
|
|
||||||
|
if len(result.warnings) > 0:
|
||||||
|
message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n'
|
||||||
|
message += '\n'.join(result.warnings)
|
||||||
|
self.report({'WARNING'}, message)
|
||||||
|
else:
|
||||||
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
|
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
|
||||||
|
|
||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ class PsaReader(object):
|
|||||||
def sequences(self):
|
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)
|
||||||
@@ -41,8 +46,8 @@ class PsaReader(object):
|
|||||||
"""
|
"""
|
||||||
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]
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from collections import OrderedDict
|
import typing
|
||||||
from typing import Dict, List
|
|
||||||
|
import bmesh
|
||||||
|
import bpy
|
||||||
|
from bpy.types import Armature
|
||||||
|
|
||||||
from .data import *
|
from .data import *
|
||||||
from ..helpers import *
|
from ..helpers import *
|
||||||
import bmesh
|
|
||||||
import bpy
|
|
||||||
|
|
||||||
|
|
||||||
class PskInputObjects(object):
|
class PskInputObjects(object):
|
||||||
@@ -19,6 +20,7 @@ class PskBuildOptions(object):
|
|||||||
self.bone_group_indices: List[int] = []
|
self.bone_group_indices: List[int] = []
|
||||||
self.use_raw_mesh_data = True
|
self.use_raw_mesh_data = True
|
||||||
self.material_names: List[str] = []
|
self.material_names: List[str] = []
|
||||||
|
self.should_ignore_bone_name_restrictions = False
|
||||||
|
|
||||||
|
|
||||||
def get_psk_input_objects(context) -> PskInputObjects:
|
def get_psk_input_objects(context) -> PskInputObjects:
|
||||||
@@ -60,7 +62,7 @@ def get_psk_input_objects(context) -> PskInputObjects:
|
|||||||
def build_psk(context, options: PskBuildOptions) -> Psk:
|
def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||||
input_objects = get_psk_input_objects(context)
|
input_objects = get_psk_input_objects(context)
|
||||||
|
|
||||||
armature_object = input_objects.armature_object
|
armature_object: bpy.types.Object = input_objects.armature_object
|
||||||
|
|
||||||
psk = Psk()
|
psk = Psk()
|
||||||
bones = []
|
bones = []
|
||||||
@@ -78,9 +80,11 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
|||||||
psk.bones.append(psk_bone)
|
psk.bones.append(psk_bone)
|
||||||
else:
|
else:
|
||||||
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices)
|
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]
|
armature_data = typing.cast(Armature, armature_object.data)
|
||||||
|
bones = [armature_data.bones[bone_name] for bone_name in bone_names]
|
||||||
|
|
||||||
# Check that all bone names are valid.
|
# Check that all bone names are valid.
|
||||||
|
if not options.should_ignore_bone_name_restrictions:
|
||||||
check_bone_names(map(lambda x: x.name, bones))
|
check_bone_names(map(lambda x: x.name, bones))
|
||||||
|
|
||||||
for bone in bones:
|
for bone in bones:
|
||||||
@@ -98,15 +102,17 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
|||||||
|
|
||||||
if bone.parent is not None:
|
if bone.parent is not None:
|
||||||
rotation = bone.matrix.to_quaternion().conjugated()
|
rotation = bone.matrix.to_quaternion().conjugated()
|
||||||
quat_parent = bone.parent.matrix.to_quaternion().inverted()
|
inverse_parent_rotation = bone.parent.matrix.to_quaternion().inverted()
|
||||||
parent_head = quat_parent @ bone.parent.head
|
parent_head = inverse_parent_rotation @ bone.parent.head
|
||||||
parent_tail = quat_parent @ bone.parent.tail
|
parent_tail = inverse_parent_rotation @ bone.parent.tail
|
||||||
location = (parent_tail - parent_head) + bone.head
|
location = (parent_tail - parent_head) + bone.head
|
||||||
else:
|
else:
|
||||||
local_matrix = armature_object.matrix_local
|
armature_local_matrix = armature_object.matrix_local
|
||||||
location = local_matrix @ bone.head
|
location = armature_local_matrix @ bone.head
|
||||||
rot_matrix = bone.matrix @ local_matrix.to_3x3()
|
bone_rotation = bone.matrix.to_quaternion().conjugated()
|
||||||
rotation = rot_matrix.to_quaternion()
|
local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated()
|
||||||
|
rotation = bone_rotation @ local_rotation
|
||||||
|
rotation.conjugate()
|
||||||
|
|
||||||
psk_bone.location.x = location.x
|
psk_bone.location.x = location.x
|
||||||
psk_bone.location.y = location.y
|
psk_bone.location.y = location.y
|
||||||
@@ -133,16 +139,17 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
|||||||
# MATERIALS
|
# MATERIALS
|
||||||
material_indices = [material_names.index(material.name) for material in input_mesh_object.data.materials]
|
material_indices = [material_names.index(material.name) for material in input_mesh_object.data.materials]
|
||||||
|
|
||||||
|
# MESH DATA
|
||||||
if options.use_raw_mesh_data:
|
if options.use_raw_mesh_data:
|
||||||
mesh_object = input_mesh_object
|
mesh_object = input_mesh_object
|
||||||
mesh_data = input_mesh_object.data
|
mesh_data = input_mesh_object.data
|
||||||
else:
|
else:
|
||||||
# Create a copy of the mesh object after non-armature modifiers are applied.
|
# Create a copy of the mesh object after non-armature modifiers are applied.
|
||||||
|
|
||||||
# Temporarily deactivate any armature modifiers on the input mesh object.
|
# Temporarily force the armature into the rest position.
|
||||||
active_armature_modifiers = [x for x in filter(lambda x: x.type == 'ARMATURE' and x.is_active, input_mesh_object.modifiers)]
|
# We will undo this later.
|
||||||
for modifier in active_armature_modifiers:
|
old_pose_position = armature_object.data.pose_position
|
||||||
modifier.show_viewport = False
|
armature_object.data.pose_position = 'REST'
|
||||||
|
|
||||||
depsgraph = context.evaluated_depsgraph_get()
|
depsgraph = context.evaluated_depsgraph_get()
|
||||||
bm = bmesh.new()
|
bm = bmesh.new()
|
||||||
@@ -157,9 +164,8 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
|||||||
for vertex_group in input_mesh_object.vertex_groups:
|
for vertex_group in input_mesh_object.vertex_groups:
|
||||||
mesh_object.vertex_groups.new(name=vertex_group.name)
|
mesh_object.vertex_groups.new(name=vertex_group.name)
|
||||||
|
|
||||||
# Reactivate previously active armature modifiers
|
# Restore the previous pose position on the armature.
|
||||||
for modifier in active_armature_modifiers:
|
armature_object.data.pose_position = old_pose_position
|
||||||
modifier.show_viewport = True
|
|
||||||
|
|
||||||
vertex_offset = len(psk.points)
|
vertex_offset = len(psk.points)
|
||||||
|
|
||||||
@@ -217,6 +223,7 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
|||||||
|
|
||||||
# WEIGHTS
|
# WEIGHTS
|
||||||
if armature_object is not None:
|
if armature_object is not None:
|
||||||
|
armature_data = typing.cast(Armature, armature_object.data)
|
||||||
# Because the vertex groups may contain entries for which there is no matching bone in the armature,
|
# 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.
|
# we must filter them out and not export any weights for these vertex groups.
|
||||||
bone_names = [x.name for x in bones]
|
bone_names = [x.name for x in bones]
|
||||||
@@ -230,8 +237,8 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
|||||||
# Check to see if there is an associated bone for this vertex group that exists in the armature.
|
# 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
|
# 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.
|
# weighting the vertices belonging to this vertex group.
|
||||||
if vertex_group_name in armature_object.data.bones:
|
if vertex_group_name in armature_data.bones:
|
||||||
bone = armature_object.data.bones[vertex_group_name]
|
bone = armature_data.bones[vertex_group_name]
|
||||||
while bone is not None:
|
while bone is not None:
|
||||||
try:
|
try:
|
||||||
bone_index = bone_names.index(bone.name)
|
bone_index = bone_names.index(bone.name)
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ def _write_section(fp, name: bytes, data_type: Type[Structure] = None, data: lis
|
|||||||
def export_psk(psk: Psk, path: str):
|
def export_psk(psk: Psk, path: str):
|
||||||
if len(psk.wedges) > MAX_WEDGE_COUNT:
|
if len(psk.wedges) > MAX_WEDGE_COUNT:
|
||||||
raise RuntimeError(f'Number of wedges ({len(psk.wedges)}) exceeds limit of {MAX_WEDGE_COUNT}')
|
raise RuntimeError(f'Number of wedges ({len(psk.wedges)}) exceeds limit of {MAX_WEDGE_COUNT}')
|
||||||
if len(psk.bones) > MAX_BONE_COUNT:
|
|
||||||
raise RuntimeError(f'Number of bones ({len(psk.bones)}) exceeds limit of {MAX_BONE_COUNT}')
|
|
||||||
if len(psk.points) > MAX_POINT_COUNT:
|
if len(psk.points) > MAX_POINT_COUNT:
|
||||||
raise RuntimeError(f'Numbers of vertices ({len(psk.points)}) exceeds limit of {MAX_POINT_COUNT}')
|
raise RuntimeError(f'Numbers of vertices ({len(psk.points)}) exceeds limit of {MAX_POINT_COUNT}')
|
||||||
if len(psk.materials) > MAX_MATERIAL_COUNT:
|
if len(psk.materials) > MAX_MATERIAL_COUNT:
|
||||||
raise RuntimeError(f'Number of materials ({len(psk.materials)}) exceeds limit of {MAX_MATERIAL_COUNT}')
|
raise RuntimeError(f'Number of materials ({len(psk.materials)}) exceeds limit of {MAX_MATERIAL_COUNT}')
|
||||||
|
if len(psk.bones) > MAX_BONE_COUNT:
|
||||||
|
raise RuntimeError(f'Number of bones ({len(psk.bones)}) exceeds limit of {MAX_BONE_COUNT}')
|
||||||
|
elif len(psk.bones) == 0:
|
||||||
|
raise RuntimeError(f'At least one bone must be marked for export')
|
||||||
|
|
||||||
with open(path, 'wb') as fp:
|
with open(path, 'wb') as fp:
|
||||||
_write_section(fp, b'ACTRHEAD')
|
_write_section(fp, b'ACTRHEAD')
|
||||||
@@ -161,7 +163,12 @@ class PskExportOperator(Operator, ExportHelper):
|
|||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
|
try:
|
||||||
populate_material_list(input_objects.mesh_objects, pg.material_list)
|
populate_material_list(input_objects.mesh_objects, pg.material_list)
|
||||||
|
except RuntimeError as e:
|
||||||
|
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
context.window_manager.fileselect_add(self)
|
context.window_manager.fileselect_add(self)
|
||||||
|
|
||||||
@@ -208,6 +215,10 @@ class PskExportOperator(Operator, ExportHelper):
|
|||||||
col.operator(PskMaterialListItemMoveUp.bl_idname, text='', icon='TRIA_UP')
|
col.operator(PskMaterialListItemMoveUp.bl_idname, text='', icon='TRIA_UP')
|
||||||
col.operator(PskMaterialListItemMoveDown.bl_idname, text='', icon='TRIA_DOWN')
|
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
|
||||||
options = PskBuildOptions()
|
options = PskBuildOptions()
|
||||||
@@ -215,6 +226,7 @@ class PskExportOperator(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.use_raw_mesh_data = pg.use_raw_mesh_data
|
options.use_raw_mesh_data = pg.use_raw_mesh_data
|
||||||
options.material_names = [m.material_name for m in pg.material_list]
|
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 = build_psk(context, options)
|
psk = build_psk(context, options)
|
||||||
@@ -242,6 +254,12 @@ class PskExportPropertyGroup(PropertyGroup):
|
|||||||
use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh')
|
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: CollectionProperty(type=MaterialListItem)
|
||||||
material_list_index: IntProperty(default=0)
|
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 = (
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ class ImportBone(object):
|
|||||||
self.post_quat: Quaternion = Quaternion()
|
self.post_quat: Quaternion = Quaternion()
|
||||||
|
|
||||||
|
|
||||||
def import_psk(psk: Psk, context, options: PskImportOptions):
|
class PskImportResult:
|
||||||
|
def __init__(self):
|
||||||
|
self.warnings: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||||
|
result = PskImportResult()
|
||||||
armature_object = None
|
armature_object = None
|
||||||
|
|
||||||
if options.should_import_skeleton:
|
if options.should_import_skeleton:
|
||||||
@@ -142,7 +148,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions):
|
|||||||
degenerate_face_indices.add(face_index)
|
degenerate_face_indices.add(face_index)
|
||||||
|
|
||||||
if len(degenerate_face_indices) > 0:
|
if len(degenerate_face_indices) > 0:
|
||||||
print(f'WARNING: Discarded {len(degenerate_face_indices)} degenerate face(s).')
|
result.warnings.append(f'Discarded {len(degenerate_face_indices)} degenerate face(s).')
|
||||||
|
|
||||||
bm.to_mesh(mesh_data)
|
bm.to_mesh(mesh_data)
|
||||||
|
|
||||||
@@ -200,7 +206,8 @@ def import_psk(psk: Psk, context, options: PskImportOptions):
|
|||||||
vertex_color_data.data[loop_index].color = 1.0, 1.0, 1.0, 1.0
|
vertex_color_data.data[loop_index].color = 1.0, 1.0, 1.0, 1.0
|
||||||
|
|
||||||
if len(ambiguous_vertex_color_point_indices) > 0:
|
if len(ambiguous_vertex_color_point_indices) > 0:
|
||||||
print(f'WARNING: {len(ambiguous_vertex_color_point_indices)} vertex(es) with ambiguous vertex colors.')
|
result.warnings.append(
|
||||||
|
f'{len(ambiguous_vertex_color_point_indices)} vertex(es) with ambiguous vertex colors.')
|
||||||
|
|
||||||
# VERTEX NORMALS
|
# VERTEX NORMALS
|
||||||
if psk.has_vertex_normals and options.should_import_vertex_normals:
|
if psk.has_vertex_normals and options.should_import_vertex_normals:
|
||||||
@@ -236,6 +243,8 @@ def import_psk(psk: Psk, context, options: PskImportOptions):
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
empty_set = set()
|
empty_set = set()
|
||||||
|
|
||||||
@@ -320,7 +329,14 @@ class PskImportOperator(Operator, ImportHelper):
|
|||||||
options.should_import_skeleton = pg.should_import_skeleton
|
options.should_import_skeleton = pg.should_import_skeleton
|
||||||
options.bone_length = pg.bone_length
|
options.bone_length = pg.bone_length
|
||||||
|
|
||||||
import_psk(psk, context, options)
|
result = import_psk(psk, context, options)
|
||||||
|
|
||||||
|
if len(result.warnings):
|
||||||
|
message = f'PSK imported with {len(result.warnings)} warning(s)\n'
|
||||||
|
message += '\n'.join(result.warnings)
|
||||||
|
self.report({'WARNING'}, message)
|
||||||
|
else:
|
||||||
|
self.report({'INFO'}, f'PSK imported')
|
||||||
|
|
||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import ctypes
|
import ctypes
|
||||||
|
import os
|
||||||
|
|
||||||
from .data import *
|
from .data import *
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ def _read_types(fp, data_class, section: Section, data):
|
|||||||
offset += section.data_size
|
offset += section.data_size
|
||||||
|
|
||||||
|
|
||||||
def read_psk(path) -> Psk:
|
def read_psk(path: str) -> Psk:
|
||||||
psk = Psk()
|
psk = Psk()
|
||||||
with open(path, 'rb') as fp:
|
with open(path, 'rb') as fp:
|
||||||
while fp.read(1):
|
while fp.read(1):
|
||||||
@@ -46,5 +47,7 @@ def read_psk(path) -> Psk:
|
|||||||
elif section.name == b'VTXNORMS':
|
elif section.name == b'VTXNORMS':
|
||||||
_read_types(fp, Vector3, section, psk.vertex_normals)
|
_read_types(fp, Vector3, section, psk.vertex_normals)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f'Unrecognized section "{section.name} at position {15:fp.tell()}"')
|
# Section is not handled, skip it.
|
||||||
|
fp.seek(section.data_size * section.data_count, os.SEEK_CUR)
|
||||||
|
print(f'Unrecognized section "{section.name} at position {fp.tell():15}"')
|
||||||
return psk
|
return psk
|
||||||
|
|||||||
Reference in New Issue
Block a user