Compare commits

...

14 Commits
4.1.0 ... 4.2.1

Author SHA1 Message Date
Colin Basnett
c99725b686 Bumped the minimum Blender version to 2.90. 2023-04-03 02:26:53 -07:00
Colin Basnett
947c86eb8f Fix for issue #32.
Unrecognized sections are now simply ignored.
2023-04-03 01:52:52 -07:00
Colin Basnett
f40db53cb9 Fixed a bug where it was possible to export a PSK with no bones 2023-02-18 00:28:31 -08:00
Colin Basnett
ab998885bb Update README.md 2022-12-06 10:13:30 -08:00
Colin Basnett
f821bec0ff Update README.md 2022-12-06 10:13:13 -08:00
Colin Basnett
43b0fe82dd Update README.md 2022-12-06 10:11:55 -08:00
Colin Basnett
17e9e83826 Incremented version to 4.2.0 2022-11-25 12:44:50 -08:00
Colin Basnett
44afce3e64 Improved PSA import speed dramatically 2022-11-24 16:38:06 -08:00
Colin Basnett
449331cd00 Fixed a bug where certain material errors would not display correctly 2022-11-22 12:57:44 -08:00
Colin Basnett
8ada80e243 Typing and naming improvements 2022-11-22 12:57:06 -08:00
Colin Basnett
38ed183897 Fixed a bug where the root bone orientation would be exported incorrectly 2022-11-22 12:55:56 -08:00
Colin Basnett
9ae573422b Fixed a bug where actions would not be considered to be "for" an armature if its only F-Curve data was for custom properties on a bone 2022-11-16 12:34:09 -08:00
Colin Basnett
86473584b8 Fixing warnings and typing 2022-11-12 16:26:40 -08:00
Colin Basnett
aa8725c3d0 Added a "Bone Mapping" option.
This allows imported PSA actions to map to armature bones with names
that differ only by case.

In addition, import warnings are now written to the info log so they
have better visibility to the user.
2022-11-12 16:25:19 -08:00
10 changed files with 176 additions and 75 deletions

View File

@@ -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.

View File

@@ -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, 1, 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": "",

View File

@@ -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,7 +94,7 @@ 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'

View File

@@ -112,20 +112,20 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
psa = Psa() psa = Psa()
armature = active_object armature_object = active_object
armature_data = typing.cast(Armature, armature) armature_data = typing.cast(Armature, armature_object.data)
bones: List[Bone] = list(iter(armature_data.bones)) 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.
@@ -159,9 +159,12 @@ def build_psa(context: bpy.types.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
@@ -260,7 +263,7 @@ def build_psa(context: bpy.types.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

View File

@@ -280,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)

View File

@@ -1,11 +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
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty import numpy
from bpy.types import Operator, UIList, PropertyGroup, Panel from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty
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
@@ -23,6 +26,7 @@ class PsaImportOptions(object):
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.should_convert_to_samples = False
self.bone_mapping_mode = 'CASE_INSENSITIVE'
class ImportBone(object): class ImportBone(object):
@@ -34,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:])
@@ -55,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):
@@ -164,20 +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)
# Write the keyframes out! # Write the keyframes out.
for frame_index in range(sequence.frame_count): fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float)
fcurve_data[0::2] = range(sequence.frame_count)
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
key_data = sequence_data_matrix[frame_index, bone_index] for fcurve_index, fcurve in enumerate(import_bone.fcurves):
for fcurve, datum in zip(import_bone.fcurves, key_data): fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index]
fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'}) fcurve.keyframe_points.add(sequence.frame_count)
fcurve.keyframe_points.foreach_set('co', fcurve_data)
if options.should_convert_to_samples: if options.should_convert_to_samples:
# Bake the curve to samples.
for fcurve in action.fcurves: for fcurve in action.fcurves:
fcurve.convert_to_samples(start=0, end=sequence.frame_count) fcurve.convert_to_samples(start=0, end=sequence.frame_count)
# 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
@@ -196,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()
@@ -224,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)
@@ -244,7 +282,8 @@ class PsaImportPropertyGroup(PropertyGroup):
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_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',
@@ -252,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')
@@ -264,9 +305,20 @@ class PsaImportPropertyGroup(PropertyGroup):
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( should_convert_to_samples: BoolProperty(
default=True, default=False,
name='Convert to Samples', name='Convert to Samples',
description='Convert keyframes to read-only samples. Recommended if you do not plan on editing the actions directly' 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),
)
) )
@@ -331,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
@@ -436,11 +488,18 @@ 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()
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'bone_mapping_mode')
if pg.should_write_keyframes:
col = layout.column(heading='Keyframes') 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_convert_to_samples') col.prop(pg, 'should_convert_to_samples')
col.separator() col.separator()
col = layout.column(heading='Options') col = layout.column(heading='Options')
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
@@ -574,9 +633,15 @@ class PsaImportOperator(Operator):
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.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'}

View File

@@ -1,5 +1,8 @@
import typing
import bmesh import bmesh
import bpy import bpy
from bpy.types import Armature
from .data import * from .data import *
from ..helpers import * from ..helpers import *
@@ -59,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 = []
@@ -77,7 +80,8 @@ 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: if not options.should_ignore_bone_name_restrictions:
@@ -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
@@ -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)

View File

@@ -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)

View File

@@ -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'}

View File

@@ -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