Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9798415f75 | ||
|
|
24b7bff3d8 | ||
|
|
73b967949d | ||
|
|
e750be474a | ||
|
|
972067860a | ||
|
|
95d72c23aa | ||
|
|
afe598f671 | ||
|
|
782c210f04 | ||
|
|
25bf8f2087 | ||
|
|
160a6b22f6 | ||
|
|
84f168a9c0 | ||
|
|
0428c2bd72 | ||
|
|
75033056fa | ||
|
|
1f81a6b86b |
@@ -3,7 +3,7 @@
|
||||
|
||||
[](https://ko-fi.com/L4L3853VR)
|
||||
|
||||
This Blender add-on allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many version of the Unreal Engine.
|
||||
This Blender add-on allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many versions of the Unreal Engine.
|
||||
|
||||
> **NOTE**: This addon requires Blender 3.4+. If this is not available to you, install version [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0), as it has a minimum Blender version of 2.9, but is no longer receiving new features.
|
||||
|
||||
|
||||
41
build.py
Normal file
41
build.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fnmatch import fnmatch
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
|
||||
ignore_patterns = [
|
||||
'*/__pycache__/*',
|
||||
'*/.git/*',
|
||||
'*/.github/*',
|
||||
'*/.idea/*',
|
||||
'*/venv/*',
|
||||
'*/.gitignore',
|
||||
'*/.gitattributes',
|
||||
'*/build/*',
|
||||
'*/build.py',
|
||||
]
|
||||
|
||||
|
||||
def zipdir(path, zip_file: ZipFile):
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if file != zip_file.filename and not any(fnmatch(os.path.join(root, file), pattern) for pattern in ignore_patterns):
|
||||
zip_file.write(os.path.join(root, file))
|
||||
|
||||
# Get the branch name.
|
||||
branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).decode('utf-8').strip()
|
||||
|
||||
# Get the most recent tag.
|
||||
tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0']).decode('utf-8').strip()
|
||||
|
||||
# Create a zip file of the current directory.
|
||||
|
||||
zip_path = f'./build/io_scene_psk_psa-{branch}-{tag}.zip'
|
||||
|
||||
# Check that the directory exists, if it doesn't, create it.
|
||||
if not os.path.exists('./build'):
|
||||
os.makedirs('./build')
|
||||
|
||||
zipf = ZipFile(zip_path, 'w', ZIP_DEFLATED)
|
||||
zipdir('.', zipf)
|
||||
zipf.close()
|
||||
@@ -1,7 +1,7 @@
|
||||
bl_info = {
|
||||
"name": "PSK/PSA Importer/Exporter",
|
||||
"author": "Colin Basnett, Yurii Ti",
|
||||
"version": (5, 0, 0),
|
||||
"version": (5, 0, 1),
|
||||
"blender": (3, 4, 0),
|
||||
"description": "PSK/PSA Import/Export (.psk/.psa)",
|
||||
"warning": "",
|
||||
@@ -16,56 +16,84 @@ if 'bpy' in locals():
|
||||
importlib.reload(psx_data)
|
||||
importlib.reload(psx_helpers)
|
||||
importlib.reload(psx_types)
|
||||
|
||||
importlib.reload(psk_data)
|
||||
importlib.reload(psk_builder)
|
||||
importlib.reload(psk_exporter)
|
||||
importlib.reload(psk_importer)
|
||||
importlib.reload(psk_reader)
|
||||
importlib.reload(psk_writer)
|
||||
importlib.reload(psk_builder)
|
||||
importlib.reload(psk_importer)
|
||||
importlib.reload(psk_export_properties)
|
||||
importlib.reload(psk_export_operators)
|
||||
importlib.reload(psk_export_ui)
|
||||
importlib.reload(psk_import_operators)
|
||||
|
||||
importlib.reload(psa_data)
|
||||
importlib.reload(psa_builder)
|
||||
importlib.reload(psa_exporter)
|
||||
importlib.reload(psa_reader)
|
||||
importlib.reload(psa_importer)
|
||||
importlib.reload(psa_writer)
|
||||
importlib.reload(psa_builder)
|
||||
importlib.reload(psa_export_properties)
|
||||
importlib.reload(psa_export_operators)
|
||||
importlib.reload(psa_export_ui)
|
||||
importlib.reload(psa_import_properties)
|
||||
importlib.reload(psa_import_operators)
|
||||
importlib.reload(psa_import_ui)
|
||||
else:
|
||||
# if i remove this line, it can be enabled just fine
|
||||
from . import data as psx_data
|
||||
from . import helpers as psx_helpers
|
||||
from . import types as psx_types
|
||||
from .psk import data as psk_data
|
||||
from .psk import builder as psk_builder
|
||||
from .psk import exporter as psk_exporter
|
||||
from .psk import reader as psk_reader
|
||||
from .psk import writer as psk_writer
|
||||
from .psk import builder as psk_builder
|
||||
from .psk import importer as psk_importer
|
||||
from .psk.export import properties as psk_export_properties
|
||||
from .psk.export import operators as psk_export_operators
|
||||
from .psk.export import ui as psk_export_ui
|
||||
from .psk.import_ import operators as psk_import_operators
|
||||
|
||||
from .psa import data as psa_data
|
||||
from .psa import builder as psa_builder
|
||||
from .psa import exporter as psa_exporter
|
||||
from .psa import reader as psa_reader
|
||||
from .psa import writer as psa_writer
|
||||
from .psa import builder as psa_builder
|
||||
from .psa import importer as psa_importer
|
||||
from .psa.export import properties as psa_export_properties
|
||||
from .psa.export import operators as psa_export_operators
|
||||
from .psa.export import ui as psa_export_ui
|
||||
from .psa.import_ import properties as psa_import_properties
|
||||
from .psa.import_ import operators as psa_import_operators
|
||||
from .psa.import_ import ui as psa_import_ui
|
||||
|
||||
import bpy
|
||||
from bpy.props import PointerProperty
|
||||
|
||||
classes = psx_types.classes +\
|
||||
psk_importer.classes +\
|
||||
psk_exporter.classes +\
|
||||
psa_exporter.classes +\
|
||||
psa_importer.classes
|
||||
psk_import_operators.classes +\
|
||||
psk_export_properties.classes +\
|
||||
psk_export_operators.classes +\
|
||||
psk_export_ui.classes + \
|
||||
psa_export_properties.classes +\
|
||||
psa_export_operators.classes +\
|
||||
psa_export_ui.classes + \
|
||||
psa_import_properties.classes +\
|
||||
psa_import_operators.classes +\
|
||||
psa_import_ui.classes
|
||||
|
||||
|
||||
def psk_export_menu_func(self, context):
|
||||
self.layout.operator(psk_exporter.PskExportOperator.bl_idname, text='Unreal PSK (.psk)')
|
||||
self.layout.operator(psk_export_operators.PSK_OT_export.bl_idname, text='Unreal PSK (.psk)')
|
||||
|
||||
|
||||
def psk_import_menu_func(self, context):
|
||||
self.layout.operator(psk_importer.PskImportOperator.bl_idname, text='Unreal PSK (.psk/.pskx)')
|
||||
self.layout.operator(psk_import_operators.PSK_OT_import.bl_idname, text='Unreal PSK (.psk/.pskx)')
|
||||
|
||||
|
||||
def psa_export_menu_func(self, context):
|
||||
self.layout.operator(psa_exporter.PsaExportOperator.bl_idname, text='Unreal PSA (.psa)')
|
||||
self.layout.operator(psa_export_operators.PSA_OT_export.bl_idname, text='Unreal PSA (.psa)')
|
||||
|
||||
|
||||
def psa_import_menu_func(self, context):
|
||||
self.layout.operator(psa_importer.PsaImportOperator.bl_idname, text='Unreal PSA (.psa)')
|
||||
self.layout.operator(psa_import_operators.PSA_OT_import.bl_idname, text='Unreal PSA (.psa)')
|
||||
|
||||
|
||||
def register():
|
||||
@@ -75,10 +103,10 @@ def register():
|
||||
bpy.types.TOPBAR_MT_file_import.append(psk_import_menu_func)
|
||||
bpy.types.TOPBAR_MT_file_export.append(psa_export_menu_func)
|
||||
bpy.types.TOPBAR_MT_file_import.append(psa_import_menu_func)
|
||||
bpy.types.Scene.psa_import = PointerProperty(type=psa_importer.PsaImportPropertyGroup)
|
||||
bpy.types.Scene.psa_export = PointerProperty(type=psa_exporter.PsaExportPropertyGroup)
|
||||
bpy.types.Scene.psk_export = PointerProperty(type=psk_exporter.PskExportPropertyGroup)
|
||||
bpy.types.Action.psa_export = PointerProperty(type=psx_types.PSX_PG_ActionExportPropertyGroup)
|
||||
bpy.types.Scene.psa_import = PointerProperty(type=psa_import_properties.PSA_PG_import)
|
||||
bpy.types.Scene.psa_export = PointerProperty(type=psa_export_properties.PSA_PG_export)
|
||||
bpy.types.Scene.psk_export = PointerProperty(type=psk_export_properties.PSK_PG_export)
|
||||
bpy.types.Action.psa_export = PointerProperty(type=psx_types.PSX_PG_action_export)
|
||||
|
||||
|
||||
def unregister():
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class UReference:
|
||||
type_name: str
|
||||
package_name: str
|
||||
group_name: Optional[str]
|
||||
object_name: str
|
||||
|
||||
def __init__(self, type_name: str, package_name: str, object_name: str, group_name: Optional[str] = None):
|
||||
self.type_name = type_name
|
||||
self.package_name = package_name
|
||||
self.object_name = object_name
|
||||
self.group_name = group_name
|
||||
|
||||
@staticmethod
|
||||
def from_string(string: str) -> Optional['UReference']:
|
||||
if string == 'None':
|
||||
return None
|
||||
pattern = r'(\w+)\'([\w\.\d\-\_]+)\''
|
||||
match = re.match(pattern, string)
|
||||
if match is None:
|
||||
print(f'BAD REFERENCE STRING: {string}')
|
||||
return None
|
||||
type_name = match.group(1)
|
||||
object_name = match.group(2)
|
||||
pattern = r'([\w\d\-\_]+)'
|
||||
values = re.findall(pattern, object_name)
|
||||
package_name = values[0]
|
||||
object_name = values[-1]
|
||||
return UReference(type_name, package_name, object_name, group_name=None)
|
||||
|
||||
def __repr__(self):
|
||||
s = f'{self.type_name}\'{self.package_name}'
|
||||
if self.group_name:
|
||||
s += f'.{self.group_name}'
|
||||
s += f'.{self.object_name}'
|
||||
return s
|
||||
@@ -93,7 +93,8 @@ def check_bone_names(bone_names: Iterable[str]):
|
||||
invalid_bone_names = [x for x in bone_names if pattern.match(x) is None]
|
||||
if len(invalid_bone_names) > 0:
|
||||
raise RuntimeError(f'The following bone names are invalid: {invalid_bone_names}.\n'
|
||||
f'Bone names must only contain letters, numbers, spaces, hyphens and underscores.')
|
||||
f'Bone names must only contain letters, numbers, spaces, hyphens and underscores.\n'
|
||||
f'You can bypass this by disabling "Enforce Bone Name Restrictions" in the export settings.')
|
||||
|
||||
|
||||
def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_group_indices: List[int]) -> List[str]:
|
||||
|
||||
@@ -6,7 +6,7 @@ from .data import *
|
||||
from ..helpers import *
|
||||
|
||||
|
||||
class PsaExportSequence:
|
||||
class PsaBuildSequence:
|
||||
class NlaState:
|
||||
def __init__(self):
|
||||
self.action: Optional[Action] = None
|
||||
@@ -15,7 +15,7 @@ class PsaExportSequence:
|
||||
|
||||
def __init__(self):
|
||||
self.name: str = ''
|
||||
self.nla_state: PsaExportSequence.NlaState = PsaExportSequence.NlaState()
|
||||
self.nla_state: PsaBuildSequence.NlaState = PsaBuildSequence.NlaState()
|
||||
self.compression_ratio: float = 1.0
|
||||
self.key_quota: int = 0
|
||||
self.fps: float = 30.0
|
||||
@@ -24,16 +24,16 @@ class PsaExportSequence:
|
||||
class PsaBuildOptions:
|
||||
def __init__(self):
|
||||
self.animation_data: Optional[AnimData] = None
|
||||
self.sequences: List[PsaExportSequence] = []
|
||||
self.sequences: List[PsaBuildSequence] = []
|
||||
self.bone_filter_mode: str = 'ALL'
|
||||
self.bone_group_indices: List[int] = []
|
||||
self.should_ignore_bone_name_restrictions: bool = False
|
||||
self.should_enforce_bone_name_restrictions: bool = False
|
||||
self.sequence_name_prefix: str = ''
|
||||
self.sequence_name_suffix: str = ''
|
||||
self.root_motion: bool = False
|
||||
|
||||
|
||||
def get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions):
|
||||
def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions):
|
||||
if pose_bone.parent is not None:
|
||||
pose_bone_matrix = pose_bone.matrix
|
||||
pose_bone_parent_matrix = pose_bone.parent.matrix
|
||||
@@ -85,7 +85,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
raise RuntimeError('No bones available for export')
|
||||
|
||||
# Check that all bone names are valid.
|
||||
if not options.should_ignore_bone_name_restrictions:
|
||||
if options.should_enforce_bone_name_restrictions:
|
||||
check_bone_names(map(lambda bone: bone.name, bones))
|
||||
|
||||
# Build list of PSA bones.
|
||||
@@ -98,7 +98,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
psa_bone.parent_index = parent_index
|
||||
psa.bones[parent_index].children_count += 1
|
||||
except ValueError:
|
||||
psa_bone.parent_index = -1
|
||||
psa_bone.parent_index = 0
|
||||
|
||||
if bone.parent is not None:
|
||||
rotation = bone.matrix.to_quaternion().conjugated()
|
||||
@@ -138,7 +138,9 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
# We actually alter the timeline frame and simply record the resultant pose bone matrices.
|
||||
frame_start_index = 0
|
||||
|
||||
for export_sequence in options.sequences:
|
||||
context.window_manager.progress_begin(0, len(options.sequences))
|
||||
|
||||
for export_sequence_index, export_sequence in enumerate(options.sequences):
|
||||
# Link the action to the animation data and update view layer.
|
||||
options.animation_data.action = export_sequence.nla_state.action
|
||||
context.view_layer.update()
|
||||
@@ -169,6 +171,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
psa_sequence.fps = frame_count / sequence_duration
|
||||
psa_sequence.bone_count = len(pose_bones)
|
||||
psa_sequence.track_time = frame_count
|
||||
psa_sequence.key_reduction = 1.0
|
||||
|
||||
frame = float(frame_start)
|
||||
|
||||
@@ -176,7 +179,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
context.scene.frame_set(frame=int(frame), subframe=frame % 1.0)
|
||||
|
||||
for pose_bone in pose_bones:
|
||||
location, rotation = get_pose_bone_location_and_rotation(pose_bone, armature_object, options)
|
||||
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, armature_object, options)
|
||||
|
||||
key = Psa.Key()
|
||||
key.location.x = location.x
|
||||
@@ -195,8 +198,12 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
|
||||
|
||||
psa.sequences[export_sequence.name] = psa_sequence
|
||||
|
||||
context.window_manager.progress_update(export_sequence_index)
|
||||
|
||||
# Restore the previous action & frame.
|
||||
options.animation_data.action = saved_action
|
||||
context.scene.frame_set(saved_frame_current)
|
||||
|
||||
context.window_manager.progress_end()
|
||||
|
||||
return psa
|
||||
|
||||
0
io_scene_psk_psa/psa/export/__init__.py
Normal file
0
io_scene_psk_psa/psa/export/__init__.py
Normal file
@@ -1,220 +1,17 @@
|
||||
import fnmatch
|
||||
import sys
|
||||
from typing import Type, Dict
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import List, Iterable, Dict, Tuple
|
||||
|
||||
import bpy
|
||||
from bpy.props import BoolProperty, CollectionProperty, EnumProperty, FloatProperty, IntProperty, PointerProperty, \
|
||||
StringProperty
|
||||
from bpy.types import Action, Operator, PropertyGroup, UIList, Context, Armature, TimelineMarker
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Context, Armature, Action, Object, AnimData, TimelineMarker
|
||||
from bpy_extras.io_utils import ExportHelper
|
||||
from bpy_types import Operator
|
||||
|
||||
from .builder import PsaBuildOptions, PsaExportSequence, build_psa
|
||||
from .data import *
|
||||
from ..helpers import *
|
||||
from ..types import BoneGroupListItem
|
||||
|
||||
|
||||
def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None):
|
||||
section = Section()
|
||||
section.name = name
|
||||
if data_type is not None and data is not None:
|
||||
section.data_size = sizeof(data_type)
|
||||
section.data_count = len(data)
|
||||
fp.write(section)
|
||||
if data is not None:
|
||||
for datum in data:
|
||||
fp.write(datum)
|
||||
|
||||
|
||||
def export_psa(psa: Psa, path: str):
|
||||
with open(path, 'wb') as fp:
|
||||
write_section(fp, b'ANIMHEAD')
|
||||
write_section(fp, b'BONENAMES', Psa.Bone, psa.bones)
|
||||
write_section(fp, b'ANIMINFO', Psa.Sequence, list(psa.sequences.values()))
|
||||
write_section(fp, b'ANIMKEYS', Psa.Key, psa.keys)
|
||||
|
||||
|
||||
class PsaExportActionListItem(PropertyGroup):
|
||||
action: PointerProperty(type=Action)
|
||||
name: StringProperty()
|
||||
is_selected: BoolProperty(default=False)
|
||||
frame_start: IntProperty(options={'HIDDEN'})
|
||||
frame_end: IntProperty(options={'HIDDEN'})
|
||||
is_pose_marker: BoolProperty(options={'HIDDEN'})
|
||||
|
||||
|
||||
class PsaExportTimelineMarkerListItem(PropertyGroup):
|
||||
marker_index: IntProperty()
|
||||
name: StringProperty()
|
||||
is_selected: BoolProperty(default=True)
|
||||
frame_start: IntProperty(options={'HIDDEN'})
|
||||
frame_end: IntProperty(options={'HIDDEN'})
|
||||
|
||||
|
||||
def psa_export_property_group_animation_data_override_poll(_context, obj):
|
||||
return obj.animation_data is not None
|
||||
|
||||
|
||||
empty_set = set()
|
||||
|
||||
|
||||
class PsaExportPropertyGroup(PropertyGroup):
|
||||
root_motion: BoolProperty(
|
||||
name='Root Motion',
|
||||
options=empty_set,
|
||||
default=False,
|
||||
description='When enabled, the root bone will be transformed as it appears in the scene.\n\n'
|
||||
'You might want to disable this if you are exporting an animation for an armature that is '
|
||||
'attached to another object, such as a weapon or a shield',
|
||||
)
|
||||
should_override_animation_data: BoolProperty(
|
||||
name='Override Animation Data',
|
||||
options=empty_set,
|
||||
default=False,
|
||||
description='Use the animation data from a different object instead of the selected object'
|
||||
)
|
||||
animation_data_override: PointerProperty(
|
||||
type=bpy.types.Object,
|
||||
poll=psa_export_property_group_animation_data_override_poll
|
||||
)
|
||||
sequence_source: EnumProperty(
|
||||
name='Source',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
|
||||
('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers', 'MARKER_HLT',
|
||||
1),
|
||||
)
|
||||
)
|
||||
fps_source: EnumProperty(
|
||||
name='FPS Source',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('SCENE', 'Scene', '', 'SCENE_DATA', 0),
|
||||
('ACTION_METADATA', 'Action Metadata',
|
||||
'The frame rate will be determined by action\'s "psa_sequence_fps" custom property, if it exists. If the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used. If no metadata is available, the scene\'s frame rate will be used.',
|
||||
'PROPERTIES', 1),
|
||||
('CUSTOM', 'Custom', '', 2)
|
||||
)
|
||||
)
|
||||
fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=empty_set, step=100,
|
||||
soft_max=60.0)
|
||||
action_list: CollectionProperty(type=PsaExportActionListItem)
|
||||
action_list_index: IntProperty(default=0)
|
||||
marker_list: CollectionProperty(type=PsaExportTimelineMarkerListItem)
|
||||
marker_list_index: IntProperty(default=0)
|
||||
bone_filter_mode: EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('ALL', 'All', 'All bones will be exported.'),
|
||||
('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will '
|
||||
'be exported.'),
|
||||
)
|
||||
)
|
||||
bone_group_list: CollectionProperty(type=BoneGroupListItem)
|
||||
bone_group_list_index: IntProperty(default=0, name='', description='')
|
||||
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_suffix: StringProperty(name='Suffix', options=empty_set)
|
||||
sequence_filter_name: StringProperty(
|
||||
default='',
|
||||
name='Filter by Name',
|
||||
options={'TEXTEDIT_UPDATE'},
|
||||
description='Only show items matching this name (use \'*\' as wildcard)')
|
||||
sequence_use_filter_invert: BoolProperty(
|
||||
default=False,
|
||||
name='Invert',
|
||||
options=empty_set,
|
||||
description='Invert filtering (show hidden items, and vice versa)')
|
||||
sequence_filter_asset: BoolProperty(
|
||||
default=False,
|
||||
name='Show assets',
|
||||
options=empty_set,
|
||||
description='Show actions that belong to an asset library')
|
||||
sequence_filter_pose_marker: BoolProperty(
|
||||
default=False,
|
||||
name='Show pose markers',
|
||||
options=empty_set)
|
||||
sequence_use_filter_sort_reverse: BoolProperty(default=True, options=empty_set)
|
||||
|
||||
|
||||
def is_bone_filter_mode_item_available(context, identifier):
|
||||
if identifier == 'BONE_GROUPS':
|
||||
obj = context.active_object
|
||||
if not obj.pose or not obj.pose.bone_groups:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context: Context, marker_names: List[str]) -> Dict:
|
||||
# Timeline markers need to be sorted so that we can determine the sequence start and end positions.
|
||||
sequence_frame_ranges = dict()
|
||||
sorted_timeline_markers = list(sorted(context.scene.timeline_markers, key=lambda x: x.frame))
|
||||
sorted_timeline_marker_names = list(map(lambda x: x.name, sorted_timeline_markers))
|
||||
|
||||
for marker_name in marker_names:
|
||||
marker = context.scene.timeline_markers[marker_name]
|
||||
frame_start = marker.frame
|
||||
# Determine the final frame of the sequence based on the next marker.
|
||||
# If no subsequent marker exists, use the maximum frame_end from all NLA strips.
|
||||
marker_index = sorted_timeline_marker_names.index(marker_name)
|
||||
next_marker_index = marker_index + 1
|
||||
frame_end = 0
|
||||
if next_marker_index < len(sorted_timeline_markers):
|
||||
# There is a next marker. Use that next marker's frame position as the last frame of this sequence.
|
||||
frame_end = sorted_timeline_markers[next_marker_index].frame
|
||||
nla_strips = get_nla_strips_in_timeframe(animation_data, marker.frame, frame_end)
|
||||
if len(nla_strips) > 0:
|
||||
frame_end = min(frame_end, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips)))
|
||||
frame_start = max(frame_start, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips)))
|
||||
else:
|
||||
# No strips in between this marker and the next, just export this as a one-frame animation.
|
||||
frame_end = frame_start
|
||||
else:
|
||||
# There is no next marker.
|
||||
# Find the final frame of all the NLA strips and use that as the last frame of this sequence.
|
||||
for nla_track in animation_data.nla_tracks:
|
||||
if nla_track.mute:
|
||||
continue
|
||||
for strip in nla_track.strips:
|
||||
frame_end = max(frame_end, strip.frame_end)
|
||||
|
||||
if frame_start > frame_end:
|
||||
continue
|
||||
|
||||
sequence_frame_ranges[marker_name] = int(frame_start), int(frame_end)
|
||||
|
||||
return sequence_frame_ranges
|
||||
|
||||
|
||||
def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float:
|
||||
if fps_source == 'SCENE':
|
||||
return context.scene.render.fps
|
||||
elif fps_source == 'CUSTOM':
|
||||
return fps_custom
|
||||
elif fps_source == 'ACTION_METADATA':
|
||||
# Get the minimum value of action metadata FPS values.
|
||||
fps_list = []
|
||||
for action in filter(lambda x: 'psa_sequence_fps' in x, actions):
|
||||
fps = action['psa_sequence_fps']
|
||||
if type(fps) == int or type(fps) == float:
|
||||
fps_list.append(fps)
|
||||
if len(fps_list) > 0:
|
||||
return min(fps_list)
|
||||
else:
|
||||
# No valid action metadata to use, fallback to scene FPS
|
||||
return context.scene.render.fps
|
||||
else:
|
||||
raise RuntimeError(f'Invalid FPS source "{fps_source}"')
|
||||
from io_scene_psk_psa.helpers import populate_bone_group_list, get_nla_strips_in_timeframe
|
||||
from io_scene_psk_psa.psa.builder import build_psa, PsaBuildSequence, PsaBuildOptions
|
||||
from io_scene_psk_psa.psa.export.properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences
|
||||
from io_scene_psk_psa.psa.writer import write_psa
|
||||
|
||||
|
||||
def is_action_for_armature(armature: Armature, action: Action):
|
||||
@@ -231,57 +28,6 @@ def is_action_for_armature(armature: Armature, action: Action):
|
||||
return False
|
||||
|
||||
|
||||
def get_animation_data_object(context: Context) -> Object:
|
||||
pg: PsaExportPropertyGroup = getattr(context.scene, 'psa_export')
|
||||
|
||||
active_object = context.view_layer.objects.active
|
||||
|
||||
if active_object.type != 'ARMATURE':
|
||||
raise RuntimeError('Selected object must be an Armature')
|
||||
|
||||
if pg.should_override_animation_data:
|
||||
animation_data_object = pg.animation_data_override
|
||||
else:
|
||||
animation_data_object = active_object
|
||||
|
||||
return animation_data_object
|
||||
|
||||
|
||||
def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]:
|
||||
frame_start = int(action.frame_range[0])
|
||||
frame_end = int(action.frame_range[1])
|
||||
reversed_pattern = r'(.+)/(.+)'
|
||||
reversed_match = re.match(reversed_pattern, action.name)
|
||||
if reversed_match:
|
||||
forward_name = reversed_match.group(1)
|
||||
backwards_name = reversed_match.group(2)
|
||||
return [
|
||||
(forward_name, frame_start, frame_end),
|
||||
(backwards_name, frame_end, frame_start)
|
||||
]
|
||||
else:
|
||||
return [(action.name, frame_start, frame_end)]
|
||||
|
||||
|
||||
def get_sequences_from_action_pose_marker(action: Action, pose_markers: List[TimelineMarker], pose_marker: TimelineMarker, pose_marker_index: int) -> List[Tuple[str, int, int]]:
|
||||
frame_start = pose_marker.frame
|
||||
if pose_marker_index + 1 < len(pose_markers):
|
||||
frame_end = pose_markers[pose_marker_index + 1].frame
|
||||
else:
|
||||
frame_end = int(action.frame_range[1])
|
||||
reversed_pattern = r'(.+)/(.+)'
|
||||
reversed_match = re.match(reversed_pattern, pose_marker.name)
|
||||
if reversed_match:
|
||||
forward_name = reversed_match.group(1)
|
||||
backwards_name = reversed_match.group(2)
|
||||
return [
|
||||
(forward_name, frame_start, frame_end),
|
||||
(backwards_name, frame_end, frame_start)
|
||||
]
|
||||
else:
|
||||
return [(pose_marker.name, frame_start, frame_end)]
|
||||
|
||||
|
||||
def update_actions_and_timeline_markers(context: Context, armature: Armature):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
|
||||
@@ -342,7 +88,136 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature):
|
||||
item.frame_end = frame_end
|
||||
|
||||
|
||||
class PsaExportOperator(Operator, ExportHelper):
|
||||
def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float:
|
||||
if fps_source == 'SCENE':
|
||||
return context.scene.render.fps
|
||||
elif fps_source == 'CUSTOM':
|
||||
return fps_custom
|
||||
elif fps_source == 'ACTION_METADATA':
|
||||
# Get the minimum value of action metadata FPS values.
|
||||
fps_list = []
|
||||
for action in filter(lambda x: 'psa_sequence_fps' in x, actions):
|
||||
fps = action['psa_sequence_fps']
|
||||
if type(fps) == int or type(fps) == float:
|
||||
fps_list.append(fps)
|
||||
if len(fps_list) > 0:
|
||||
return min(fps_list)
|
||||
else:
|
||||
# No valid action metadata to use, fallback to scene FPS
|
||||
return context.scene.render.fps
|
||||
else:
|
||||
raise RuntimeError(f'Invalid FPS source "{fps_source}"')
|
||||
|
||||
|
||||
def get_animation_data_object(context: Context) -> Object:
|
||||
pg: PSA_PG_export = getattr(context.scene, 'psa_export')
|
||||
|
||||
active_object = context.view_layer.objects.active
|
||||
|
||||
if active_object.type != 'ARMATURE':
|
||||
raise RuntimeError('Selected object must be an Armature')
|
||||
|
||||
if pg.should_override_animation_data:
|
||||
animation_data_object = pg.animation_data_override
|
||||
else:
|
||||
animation_data_object = active_object
|
||||
|
||||
return animation_data_object
|
||||
|
||||
|
||||
def is_bone_filter_mode_item_available(context, identifier):
|
||||
if identifier == 'BONE_GROUPS':
|
||||
obj = context.active_object
|
||||
if not obj.pose or not obj.pose.bone_groups:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context: Context, marker_names: List[str]) -> Dict:
|
||||
# Timeline markers need to be sorted so that we can determine the sequence start and end positions.
|
||||
sequence_frame_ranges = dict()
|
||||
sorted_timeline_markers = list(sorted(context.scene.timeline_markers, key=lambda x: x.frame))
|
||||
sorted_timeline_marker_names = list(map(lambda x: x.name, sorted_timeline_markers))
|
||||
|
||||
for marker_name in marker_names:
|
||||
marker = context.scene.timeline_markers[marker_name]
|
||||
frame_start = marker.frame
|
||||
# Determine the final frame of the sequence based on the next marker.
|
||||
# If no subsequent marker exists, use the maximum frame_end from all NLA strips.
|
||||
marker_index = sorted_timeline_marker_names.index(marker_name)
|
||||
next_marker_index = marker_index + 1
|
||||
frame_end = 0
|
||||
if next_marker_index < len(sorted_timeline_markers):
|
||||
# There is a next marker. Use that next marker's frame position as the last frame of this sequence.
|
||||
frame_end = sorted_timeline_markers[next_marker_index].frame
|
||||
nla_strips = get_nla_strips_in_timeframe(animation_data, marker.frame, frame_end)
|
||||
if len(nla_strips) > 0:
|
||||
frame_end = min(frame_end, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips)))
|
||||
frame_start = max(frame_start, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips)))
|
||||
else:
|
||||
# No strips in between this marker and the next, just export this as a one-frame animation.
|
||||
frame_end = frame_start
|
||||
else:
|
||||
# There is no next marker.
|
||||
# Find the final frame of all the NLA strips and use that as the last frame of this sequence.
|
||||
for nla_track in animation_data.nla_tracks:
|
||||
if nla_track.mute:
|
||||
continue
|
||||
for strip in nla_track.strips:
|
||||
frame_end = max(frame_end, strip.frame_end)
|
||||
|
||||
if frame_start > frame_end:
|
||||
continue
|
||||
|
||||
sequence_frame_ranges[marker_name] = int(frame_start), int(frame_end)
|
||||
|
||||
return sequence_frame_ranges
|
||||
|
||||
|
||||
def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]:
|
||||
frame_start = int(action.frame_range[0])
|
||||
frame_end = int(action.frame_range[1])
|
||||
reversed_pattern = r'(.+)/(.+)'
|
||||
reversed_match = re.match(reversed_pattern, action.name)
|
||||
if reversed_match:
|
||||
forward_name = reversed_match.group(1)
|
||||
backwards_name = reversed_match.group(2)
|
||||
return [
|
||||
(forward_name, frame_start, frame_end),
|
||||
(backwards_name, frame_end, frame_start)
|
||||
]
|
||||
else:
|
||||
return [(action.name, frame_start, frame_end)]
|
||||
|
||||
|
||||
def get_sequences_from_action_pose_marker(action: Action, pose_markers: List[TimelineMarker], pose_marker: TimelineMarker, pose_marker_index: int) -> List[Tuple[str, int, int]]:
|
||||
frame_start = pose_marker.frame
|
||||
if pose_marker_index + 1 < len(pose_markers):
|
||||
frame_end = pose_markers[pose_marker_index + 1].frame
|
||||
else:
|
||||
frame_end = int(action.frame_range[1])
|
||||
reversed_pattern = r'(.+)/(.+)'
|
||||
reversed_match = re.match(reversed_pattern, pose_marker.name)
|
||||
if reversed_match:
|
||||
forward_name = reversed_match.group(1)
|
||||
backwards_name = reversed_match.group(2)
|
||||
return [
|
||||
(forward_name, frame_start, frame_end),
|
||||
(backwards_name, frame_end, frame_start)
|
||||
]
|
||||
else:
|
||||
return [(pose_marker.name, frame_start, frame_end)]
|
||||
|
||||
|
||||
def get_visible_sequences(pg: PSA_PG_export, sequences) -> List[PSA_PG_export_action_list_item]:
|
||||
visible_sequences = []
|
||||
for i, flag in enumerate(filter_sequences(pg, sequences)):
|
||||
if bool(flag & (1 << 30)):
|
||||
visible_sequences.append(sequences[i])
|
||||
return visible_sequences
|
||||
|
||||
|
||||
class PSA_OT_export(Operator, ExportHelper):
|
||||
bl_idname = 'psa_export.operator'
|
||||
bl_label = 'Export'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
@@ -388,14 +263,14 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
# SELECT ALL/NONE
|
||||
row = layout.row(align=True)
|
||||
row.label(text='Select')
|
||||
row.operator(PsaExportActionsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row.operator(PsaExportActionsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
|
||||
# ACTIONS
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
rows = max(3, min(len(pg.action_list), 10))
|
||||
|
||||
layout.template_list('PSA_UL_ExportSequenceList', '', pg, 'action_list', pg, 'action_list_index', rows=rows)
|
||||
layout.template_list('PSA_UL_export_sequences', '', pg, 'action_list', pg, 'action_list_index', rows=rows)
|
||||
|
||||
col = layout.column()
|
||||
col.use_property_split = True
|
||||
@@ -405,7 +280,7 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
rows = max(3, min(len(pg.marker_list), 10))
|
||||
layout.template_list('PSA_UL_ExportSequenceList', '', pg, 'marker_list', pg, 'marker_list_index',
|
||||
layout.template_list('PSA_UL_export_sequences', '', pg, 'marker_list', pg, 'marker_list_index',
|
||||
rows=rows)
|
||||
|
||||
col = layout.column()
|
||||
@@ -432,13 +307,13 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
if pg.bone_filter_mode == 'BONE_GROUPS':
|
||||
row = layout.row(align=True)
|
||||
row.label(text='Select')
|
||||
row.operator(PsaExportBoneGroupsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row.operator(PsaExportBoneGroupsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
row.operator(PSA_OT_export_bone_groups_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row.operator(PSA_OT_export_bone_groups_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||
layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index',
|
||||
layout.template_list('PSX_UL_bone_group_list', '', pg, 'bone_group_list', pg, 'bone_group_list_index',
|
||||
rows=rows)
|
||||
|
||||
layout.prop(pg, 'should_ignore_bone_name_restrictions')
|
||||
layout.prop(pg, 'should_enforce_bone_name_restrictions')
|
||||
|
||||
layout.separator()
|
||||
|
||||
@@ -459,10 +334,15 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
except RuntimeError as e:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
|
||||
pg: PsaExportPropertyGroup = getattr(context.scene, 'psa_export')
|
||||
pg: PSA_PG_export = getattr(context.scene, 'psa_export')
|
||||
|
||||
self.armature_object = context.view_layer.objects.active
|
||||
|
||||
if self.armature_object.animation_data is None:
|
||||
# This is required otherwise the action list will be empty if the armature has never had its animation
|
||||
# data created before (i.e. if no action was ever assigned to it).
|
||||
self.armature_object.animation_data_create()
|
||||
|
||||
update_actions_and_timeline_markers(context, self.armature_object.data)
|
||||
|
||||
# Populate bone groups list.
|
||||
@@ -488,13 +368,13 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
if animation_data is None:
|
||||
raise RuntimeError(f'No animation data for object \'{animation_data_object.name}\'')
|
||||
|
||||
export_sequences: List[PsaExportSequence] = []
|
||||
export_sequences: List[PsaBuildSequence] = []
|
||||
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
for action in filter(lambda x: x.is_selected, pg.action_list):
|
||||
if len(action.action.fcurves) == 0:
|
||||
continue
|
||||
export_sequence = PsaExportSequence()
|
||||
export_sequence = PsaBuildSequence()
|
||||
export_sequence.nla_state.action = action.action
|
||||
export_sequence.name = action.name
|
||||
export_sequence.nla_state.frame_start = action.frame_start
|
||||
@@ -505,7 +385,7 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
export_sequences.append(export_sequence)
|
||||
elif pg.sequence_source == 'TIMELINE_MARKERS':
|
||||
for marker in pg.marker_list:
|
||||
export_sequence = PsaExportSequence()
|
||||
export_sequence = PsaBuildSequence()
|
||||
export_sequence.name = marker.name
|
||||
export_sequence.nla_state.action = None
|
||||
export_sequence.nla_state.frame_start = marker.frame_start
|
||||
@@ -522,7 +402,7 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
options.sequences = export_sequences
|
||||
options.bone_filter_mode = pg.bone_filter_mode
|
||||
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
|
||||
options.should_ignore_bone_name_restrictions = pg.should_ignore_bone_name_restrictions
|
||||
options.should_ignore_bone_name_restrictions = pg.should_enforce_bone_name_restrictions
|
||||
options.sequence_name_prefix = pg.sequence_name_prefix
|
||||
options.sequence_name_suffix = pg.sequence_name_suffix
|
||||
options.root_motion = pg.root_motion
|
||||
@@ -534,91 +414,12 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
export_psa(psa, self.filepath)
|
||||
write_psa(psa, self.filepath)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def filter_sequences(pg: PsaExportPropertyGroup, sequences) -> List[int]:
|
||||
bitflag_filter_item = 1 << 30
|
||||
flt_flags = [bitflag_filter_item] * len(sequences)
|
||||
|
||||
if pg.sequence_filter_name:
|
||||
# Filter name is non-empty.
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not fnmatch.fnmatch(sequence.name, f'*{pg.sequence_filter_name}*'):
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
# Invert filter flags for all items.
|
||||
if pg.sequence_use_filter_invert:
|
||||
for i, sequence in enumerate(sequences):
|
||||
flt_flags[i] ^= bitflag_filter_item
|
||||
|
||||
if not pg.sequence_filter_asset:
|
||||
for i, sequence in enumerate(sequences):
|
||||
if hasattr(sequence, 'action') and sequence.action.asset_data is not None:
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if not pg.sequence_filter_pose_marker:
|
||||
for i, sequence in enumerate(sequences):
|
||||
if hasattr(sequence, 'is_pose_marker') and sequence.is_pose_marker:
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
return flt_flags
|
||||
|
||||
|
||||
def get_visible_sequences(pg: PsaExportPropertyGroup, sequences) -> List[PsaExportActionListItem]:
|
||||
visible_sequences = []
|
||||
for i, flag in enumerate(filter_sequences(pg, sequences)):
|
||||
if bool(flag & (1 << 30)):
|
||||
visible_sequences.append(sequences[i])
|
||||
return visible_sequences
|
||||
|
||||
|
||||
class PSA_UL_ExportSequenceList(UIList):
|
||||
|
||||
def __init__(self):
|
||||
super(PSA_UL_ExportSequenceList, self).__init__()
|
||||
# Show the filtering options by default.
|
||||
self.use_filter_show = True
|
||||
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
item = typing.cast(PsaExportActionListItem, item)
|
||||
is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker
|
||||
layout.prop(item, 'is_selected', icon_only=True, text=item.name)
|
||||
if hasattr(item, 'action') and item.action.asset_data is not None:
|
||||
layout.label(text='', icon='ASSET_MANAGER')
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.alignment = 'RIGHT'
|
||||
if item.frame_end < item.frame_start:
|
||||
row.label(text='', icon='FRAME_PREV')
|
||||
if is_pose_marker:
|
||||
row.label(text=item.action.name, icon='PMARKER')
|
||||
|
||||
def draw_filter(self, context, layout):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
row = layout.row()
|
||||
subrow = row.row(align=True)
|
||||
subrow.prop(pg, 'sequence_filter_name', text="")
|
||||
subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
||||
# subrow.prop(pg, 'sequence_use_filter_sort_reverse', text='', icon='SORT_ASC')
|
||||
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
subrow = row.row(align=True)
|
||||
subrow.prop(pg, 'sequence_filter_asset', icon_only=True, icon='ASSET_MANAGER')
|
||||
subrow.prop(pg, 'sequence_filter_pose_marker', icon_only=True, icon='PMARKER')
|
||||
|
||||
def filter_items(self, context, data, prop):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
actions = getattr(data, prop)
|
||||
flt_flags = filter_sequences(pg, actions)
|
||||
# flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name')
|
||||
flt_neworder = list(range(len(actions)))
|
||||
return flt_flags, flt_neworder
|
||||
|
||||
|
||||
class PsaExportActionsSelectAll(Operator):
|
||||
class PSA_OT_export_actions_select_all(Operator):
|
||||
bl_idname = 'psa_export.sequences_select_all'
|
||||
bl_label = 'Select All'
|
||||
bl_description = 'Select all visible sequences'
|
||||
@@ -649,7 +450,7 @@ class PsaExportActionsSelectAll(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaExportActionsDeselectAll(Operator):
|
||||
class PSA_OT_export_actions_deselect_all(Operator):
|
||||
bl_idname = 'psa_export.sequences_deselect_all'
|
||||
bl_label = 'Deselect All'
|
||||
bl_description = 'Deselect all visible sequences'
|
||||
@@ -678,7 +479,7 @@ class PsaExportActionsDeselectAll(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaExportBoneGroupsSelectAll(Operator):
|
||||
class PSA_OT_export_bone_groups_select_all(Operator):
|
||||
bl_idname = 'psa_export.bone_groups_select_all'
|
||||
bl_label = 'Select All'
|
||||
bl_description = 'Select all bone groups'
|
||||
@@ -698,7 +499,7 @@ class PsaExportBoneGroupsSelectAll(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaExportBoneGroupsDeselectAll(Operator):
|
||||
class PSA_OT_export_bone_groups_deselect_all(Operator):
|
||||
bl_idname = 'psa_export.bone_groups_deselect_all'
|
||||
bl_label = 'Deselect All'
|
||||
bl_description = 'Deselect all bone groups'
|
||||
@@ -719,13 +520,9 @@ class PsaExportBoneGroupsDeselectAll(Operator):
|
||||
|
||||
|
||||
classes = (
|
||||
PsaExportActionListItem,
|
||||
PsaExportTimelineMarkerListItem,
|
||||
PsaExportPropertyGroup,
|
||||
PsaExportOperator,
|
||||
PSA_UL_ExportSequenceList,
|
||||
PsaExportActionsSelectAll,
|
||||
PsaExportActionsDeselectAll,
|
||||
PsaExportBoneGroupsSelectAll,
|
||||
PsaExportBoneGroupsDeselectAll,
|
||||
PSA_OT_export,
|
||||
PSA_OT_export_actions_select_all,
|
||||
PSA_OT_export_actions_deselect_all,
|
||||
PSA_OT_export_bone_groups_select_all,
|
||||
PSA_OT_export_bone_groups_deselect_all,
|
||||
)
|
||||
168
io_scene_psk_psa/psa/export/properties.py
Normal file
168
io_scene_psk_psa/psa/export/properties.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import sys
|
||||
from fnmatch import fnmatch
|
||||
from typing import List
|
||||
|
||||
from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \
|
||||
StringProperty
|
||||
from bpy.types import PropertyGroup, Object, Action
|
||||
|
||||
from ...types import PSX_PG_bone_group_list_item
|
||||
|
||||
|
||||
def psa_export_property_group_animation_data_override_poll(_context, obj):
|
||||
return obj.animation_data is not None
|
||||
|
||||
|
||||
empty_set = set()
|
||||
|
||||
|
||||
class PSA_PG_export_action_list_item(PropertyGroup):
|
||||
action: PointerProperty(type=Action)
|
||||
name: StringProperty()
|
||||
is_selected: BoolProperty(default=False)
|
||||
frame_start: IntProperty(options={'HIDDEN'})
|
||||
frame_end: IntProperty(options={'HIDDEN'})
|
||||
is_pose_marker: BoolProperty(options={'HIDDEN'})
|
||||
|
||||
|
||||
class PSA_PG_export_timeline_markers(PropertyGroup):
|
||||
marker_index: IntProperty()
|
||||
name: StringProperty()
|
||||
is_selected: BoolProperty(default=True)
|
||||
frame_start: IntProperty(options={'HIDDEN'})
|
||||
frame_end: IntProperty(options={'HIDDEN'})
|
||||
|
||||
|
||||
class PSA_PG_export(PropertyGroup):
|
||||
root_motion: BoolProperty(
|
||||
name='Root Motion',
|
||||
options=empty_set,
|
||||
default=False,
|
||||
description='When enabled, the root bone will be transformed as it appears in the scene.\n\n'
|
||||
'You might want to disable this if you are exporting an animation for an armature that is '
|
||||
'attached to another object, such as a weapon or a shield',
|
||||
)
|
||||
should_override_animation_data: BoolProperty(
|
||||
name='Override Animation Data',
|
||||
options=empty_set,
|
||||
default=False,
|
||||
description='Use the animation data from a different object instead of the selected object'
|
||||
)
|
||||
animation_data_override: PointerProperty(
|
||||
type=Object,
|
||||
poll=psa_export_property_group_animation_data_override_poll
|
||||
)
|
||||
sequence_source: EnumProperty(
|
||||
name='Source',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
|
||||
('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers', 'MARKER_HLT',
|
||||
1),
|
||||
)
|
||||
)
|
||||
fps_source: EnumProperty(
|
||||
name='FPS Source',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('SCENE', 'Scene', '', 'SCENE_DATA', 0),
|
||||
('ACTION_METADATA', 'Action Metadata',
|
||||
'The frame rate will be determined by action\'s "psa_sequence_fps" custom property, if it exists. If the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used. If no metadata is available, the scene\'s frame rate will be used.',
|
||||
'PROPERTIES', 1),
|
||||
('CUSTOM', 'Custom', '', 2)
|
||||
)
|
||||
)
|
||||
fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=empty_set, step=100,
|
||||
soft_max=60.0)
|
||||
action_list: CollectionProperty(type=PSA_PG_export_action_list_item)
|
||||
action_list_index: IntProperty(default=0)
|
||||
marker_list: CollectionProperty(type=PSA_PG_export_timeline_markers)
|
||||
marker_list_index: IntProperty(default=0)
|
||||
bone_filter_mode: EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=empty_set,
|
||||
description='',
|
||||
items=(
|
||||
('ALL', 'All', 'All bones will be exported.'),
|
||||
('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will '
|
||||
'be exported.'),
|
||||
)
|
||||
)
|
||||
bone_group_list: CollectionProperty(type=PSX_PG_bone_group_list_item)
|
||||
bone_group_list_index: IntProperty(default=0, name='', description='')
|
||||
should_enforce_bone_name_restrictions: BoolProperty(
|
||||
default=False,
|
||||
name='Enforce Bone Name Restrictions',
|
||||
description='Bone names restrictions will be enforced. Note that bone names without properly formatted names '
|
||||
'cannot be referenced in scripts'
|
||||
)
|
||||
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
|
||||
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)
|
||||
sequence_filter_name: StringProperty(
|
||||
default='',
|
||||
name='Filter by Name',
|
||||
options={'TEXTEDIT_UPDATE'},
|
||||
description='Only show items matching this name (use \'*\' as wildcard)')
|
||||
sequence_use_filter_invert: BoolProperty(
|
||||
default=False,
|
||||
name='Invert',
|
||||
options=empty_set,
|
||||
description='Invert filtering (show hidden items, and vice versa)')
|
||||
sequence_filter_asset: BoolProperty(
|
||||
default=False,
|
||||
name='Show assets',
|
||||
options=empty_set,
|
||||
description='Show actions that belong to an asset library')
|
||||
sequence_filter_pose_marker: BoolProperty(
|
||||
default=True,
|
||||
name='Show pose markers',
|
||||
options=empty_set)
|
||||
sequence_use_filter_sort_reverse: BoolProperty(default=True, options=empty_set)
|
||||
sequence_filter_reversed: BoolProperty(
|
||||
default=True,
|
||||
options=empty_set,
|
||||
name='Show Reversed',
|
||||
description='Show reversed sequences'
|
||||
)
|
||||
|
||||
|
||||
def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:
|
||||
bitflag_filter_item = 1 << 30
|
||||
flt_flags = [bitflag_filter_item] * len(sequences)
|
||||
|
||||
if pg.sequence_filter_name:
|
||||
# Filter name is non-empty.
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not fnmatch(sequence.name, f'*{pg.sequence_filter_name}*'):
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
# Invert filter flags for all items.
|
||||
if pg.sequence_use_filter_invert:
|
||||
for i, sequence in enumerate(sequences):
|
||||
flt_flags[i] ^= bitflag_filter_item
|
||||
|
||||
if not pg.sequence_filter_asset:
|
||||
for i, sequence in enumerate(sequences):
|
||||
if hasattr(sequence, 'action') and sequence.action.asset_data is not None:
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if not pg.sequence_filter_pose_marker:
|
||||
for i, sequence in enumerate(sequences):
|
||||
if hasattr(sequence, 'is_pose_marker') and sequence.is_pose_marker:
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if not pg.sequence_filter_reversed:
|
||||
for i, sequence in enumerate(sequences):
|
||||
if sequence.frame_start > sequence.frame_end:
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
return flt_flags
|
||||
|
||||
|
||||
classes = (
|
||||
PSA_PG_export_action_list_item,
|
||||
PSA_PG_export_timeline_markers,
|
||||
PSA_PG_export,
|
||||
)
|
||||
54
io_scene_psk_psa/psa/export/ui.py
Normal file
54
io_scene_psk_psa/psa/export/ui.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from typing import cast
|
||||
|
||||
from bpy.types import UIList
|
||||
|
||||
from .properties import PSA_PG_export_action_list_item, filter_sequences
|
||||
|
||||
|
||||
class PSA_UL_export_sequences(UIList):
|
||||
|
||||
def __init__(self):
|
||||
super(PSA_UL_export_sequences, self).__init__()
|
||||
# Show the filtering options by default.
|
||||
self.use_filter_show = True
|
||||
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
item = cast(PSA_PG_export_action_list_item, item)
|
||||
is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker
|
||||
layout.prop(item, 'is_selected', icon_only=True, text=item.name)
|
||||
if hasattr(item, 'action') and item.action.asset_data is not None:
|
||||
layout.label(text='', icon='ASSET_MANAGER')
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.alignment = 'RIGHT'
|
||||
if item.frame_end < item.frame_start:
|
||||
row.label(text='', icon='FRAME_PREV')
|
||||
if is_pose_marker:
|
||||
row.label(text=item.action.name, icon='PMARKER')
|
||||
|
||||
def draw_filter(self, context, layout):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
row = layout.row()
|
||||
subrow = row.row(align=True)
|
||||
subrow.prop(pg, 'sequence_filter_name', text="")
|
||||
subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
||||
# subrow.prop(pg, 'sequence_use_filter_sort_reverse', text='', icon='SORT_ASC')
|
||||
|
||||
if pg.sequence_source == 'ACTIONS':
|
||||
subrow = row.row(align=True)
|
||||
subrow.prop(pg, 'sequence_filter_asset', icon_only=True, icon='ASSET_MANAGER')
|
||||
subrow.prop(pg, 'sequence_filter_pose_marker', icon_only=True, icon='PMARKER')
|
||||
subrow.prop(pg, 'sequence_filter_reversed', text="", icon='FRAME_PREV')
|
||||
|
||||
def filter_items(self, context, data, prop):
|
||||
pg = getattr(context.scene, 'psa_export')
|
||||
actions = getattr(data, prop)
|
||||
flt_flags = filter_sequences(pg, actions)
|
||||
# flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name')
|
||||
flt_neworder = list(range(len(actions)))
|
||||
return flt_flags, flt_neworder
|
||||
|
||||
|
||||
classes = (
|
||||
PSA_UL_export_sequences,
|
||||
)
|
||||
0
io_scene_psk_psa/psa/import_/__init__.py
Normal file
0
io_scene_psk_psa/psa/import_/__init__.py
Normal file
255
io_scene_psk_psa/psa/import_/operators.py
Normal file
255
io_scene_psk_psa/psa/import_/operators.py
Normal file
@@ -0,0 +1,255 @@
|
||||
import os
|
||||
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Operator, Event, Context
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
|
||||
from .properties import get_visible_sequences
|
||||
from ..importer import import_psa, PsaImportOptions
|
||||
from ..reader import PsaReader
|
||||
|
||||
|
||||
class PSA_OT_import_sequences_from_text(Operator):
|
||||
bl_idname = 'psa_import.sequences_select_from_text'
|
||||
bl_label = 'Select By Text List'
|
||||
bl_description = 'Select sequences by name from text list'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
return len(pg.sequence_list) > 0
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self, width=256)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
layout.label(icon='INFO', text='Each sequence name should be on a new line.')
|
||||
layout.prop(pg, 'select_text', text='')
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
if pg.select_text is None:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'No text block selected')
|
||||
return {'CANCELLED'}
|
||||
contents = pg.select_text.as_string()
|
||||
count = 0
|
||||
for line in contents.split('\n'):
|
||||
for sequence in pg.sequence_list:
|
||||
if sequence.action_name == line:
|
||||
sequence.is_selected = True
|
||||
count += 1
|
||||
self.report({'INFO'}, f'Selected {count} sequence(s)')
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSA_OT_import_sequences_select_all(Operator):
|
||||
bl_idname = 'psa_import.sequences_select_all'
|
||||
bl_label = 'All'
|
||||
bl_description = 'Select all sequences'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
has_unselected_actions = any(map(lambda action: not action.is_selected, visible_sequences))
|
||||
return len(visible_sequences) > 0 and has_unselected_actions
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
for sequence in visible_sequences:
|
||||
sequence.is_selected = True
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSA_OT_import_sequences_deselect_all(Operator):
|
||||
bl_idname = 'psa_import.sequences_deselect_all'
|
||||
bl_label = 'None'
|
||||
bl_description = 'Deselect all visible sequences'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
has_selected_sequences = any(map(lambda sequence: sequence.is_selected, visible_sequences))
|
||||
return len(visible_sequences) > 0 and has_selected_sequences
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
for sequence in visible_sequences:
|
||||
sequence.is_selected = False
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PSA_OT_import_select_file(Operator):
|
||||
bl_idname = 'psa_import.select_file'
|
||||
bl_label = 'Select'
|
||||
bl_options = {'INTERNAL'}
|
||||
bl_description = 'Select a PSA file from which to import animations'
|
||||
filepath: StringProperty(subtype='FILE_PATH')
|
||||
filter_glob: StringProperty(default="*.psa", options={'HIDDEN'})
|
||||
|
||||
def execute(self, context):
|
||||
getattr(context.scene, 'psa_import').psa_file_path = self.filepath
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
def load_psa_file(context, filepath: str):
|
||||
pg = context.scene.psa_import
|
||||
pg.sequence_list.clear()
|
||||
pg.psa.bones.clear()
|
||||
pg.psa_error = ''
|
||||
try:
|
||||
# Read the file and populate the action list.
|
||||
p = os.path.abspath(filepath)
|
||||
psa_reader = PsaReader(p)
|
||||
for sequence in psa_reader.sequences.values():
|
||||
item = pg.sequence_list.add()
|
||||
item.action_name = sequence.name.decode('windows-1252')
|
||||
for psa_bone in psa_reader.bones:
|
||||
item = pg.psa.bones.add()
|
||||
item.bone_name = psa_bone.name.decode('windows-1252')
|
||||
except Exception as e:
|
||||
pg.psa_error = str(e)
|
||||
|
||||
|
||||
|
||||
def on_psa_file_path_updated(cls, context):
|
||||
load_psa_file(context, cls.filepath)
|
||||
|
||||
|
||||
class PSA_OT_import(Operator, ImportHelper):
|
||||
bl_idname = 'psa_import.import'
|
||||
bl_label = 'Import'
|
||||
bl_description = 'Import the selected animations into the scene as actions'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
|
||||
filename_ext = '.psa'
|
||||
filter_glob: StringProperty(default='*.psa', options={'HIDDEN'})
|
||||
filepath: StringProperty(
|
||||
name='File Path',
|
||||
description='File path used for importing the PSA file',
|
||||
maxlen=1024,
|
||||
default='',
|
||||
update=on_psa_file_path_updated)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
active_object = context.view_layer.objects.active
|
||||
if active_object is None or active_object.type != 'ARMATURE':
|
||||
cls.poll_message_set('The active object must be an armature')
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
psa_reader = PsaReader(self.filepath)
|
||||
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected]
|
||||
|
||||
options = PsaImportOptions()
|
||||
options.sequence_names = sequence_names
|
||||
options.should_use_fake_user = pg.should_use_fake_user
|
||||
options.should_stash = pg.should_stash
|
||||
options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else ''
|
||||
options.should_overwrite = pg.should_overwrite
|
||||
options.should_write_metadata = pg.should_write_metadata
|
||||
options.should_write_keyframes = pg.should_write_keyframes
|
||||
options.should_convert_to_samples = pg.should_convert_to_samples
|
||||
options.bone_mapping_mode = pg.bone_mapping_mode
|
||||
|
||||
result = import_psa(context, 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)')
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context: Context, event: Event):
|
||||
# Attempt to load the PSA file for the pre-selected file.
|
||||
load_psa_file(context, self.filepath)
|
||||
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def draw(self, context: Context):
|
||||
layout = self.layout
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
|
||||
if pg.psa_error:
|
||||
row = layout.row()
|
||||
row.label(text='Select a PSA file', icon='ERROR')
|
||||
else:
|
||||
box = layout.box()
|
||||
|
||||
box.label(text=f'Sequences ({len(pg.sequence_list)})', icon='ARMATURE_DATA')
|
||||
|
||||
# Select buttons.
|
||||
rows = max(3, min(len(pg.sequence_list), 10))
|
||||
|
||||
row = box.row()
|
||||
col = row.column()
|
||||
|
||||
row2 = col.row(align=True)
|
||||
row2.label(text='Select')
|
||||
row2.operator(PSA_OT_import_sequences_from_text.bl_idname, text='', icon='TEXT')
|
||||
row2.operator(PSA_OT_import_sequences_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row2.operator(PSA_OT_import_sequences_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
|
||||
col = col.row()
|
||||
col.template_list('PSA_UL_import_sequences', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows)
|
||||
|
||||
col = layout.column(heading='')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_overwrite')
|
||||
|
||||
col = layout.column(heading='Write')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_write_keyframes')
|
||||
col.prop(pg, 'should_write_metadata')
|
||||
|
||||
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.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_convert_to_samples')
|
||||
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_stash')
|
||||
col.prop(pg, 'should_use_action_name_prefix')
|
||||
|
||||
if pg.should_use_action_name_prefix:
|
||||
col.prop(pg, 'action_name_prefix')
|
||||
|
||||
|
||||
classes = (
|
||||
PSA_OT_import_sequences_select_all,
|
||||
PSA_OT_import_sequences_deselect_all,
|
||||
PSA_OT_import_sequences_from_text,
|
||||
PSA_OT_import,
|
||||
PSA_OT_import_select_file,
|
||||
)
|
||||
119
io_scene_psk_psa/psa/import_/properties.py
Normal file
119
io_scene_psk_psa/psa/import_/properties.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import re
|
||||
from fnmatch import fnmatch
|
||||
from typing import List
|
||||
|
||||
from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty, PointerProperty, EnumProperty
|
||||
from bpy.types import PropertyGroup, Text
|
||||
|
||||
empty_set = set()
|
||||
|
||||
|
||||
class PSA_PG_import_action_list_item(PropertyGroup):
|
||||
action_name: StringProperty(options=empty_set)
|
||||
is_selected: BoolProperty(default=False, options=empty_set)
|
||||
|
||||
|
||||
class PSA_PG_bone(PropertyGroup):
|
||||
bone_name: StringProperty(options=empty_set)
|
||||
|
||||
|
||||
class PSA_PG_data(PropertyGroup):
|
||||
bones: CollectionProperty(type=PSA_PG_bone)
|
||||
sequence_count: IntProperty(default=0)
|
||||
|
||||
|
||||
class PSA_PG_import(PropertyGroup):
|
||||
psa_error: StringProperty(default='')
|
||||
psa: PointerProperty(type=PSA_PG_data)
|
||||
sequence_list: CollectionProperty(type=PSA_PG_import_action_list_item)
|
||||
sequence_list_index: IntProperty(name='', default=0)
|
||||
should_use_fake_user: BoolProperty(default=True, name='Fake User',
|
||||
description='Assign each imported action a fake user so that the data block is '
|
||||
'saved even it has no users',
|
||||
options=empty_set)
|
||||
should_stash: BoolProperty(default=False, name='Stash',
|
||||
description='Stash each imported action as a strip on a new non-contributing NLA track',
|
||||
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)
|
||||
should_overwrite: BoolProperty(default=False, name='Overwrite', 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')
|
||||
should_write_keyframes: BoolProperty(default=True, name='Keyframes', 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)')
|
||||
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
|
||||
sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected',
|
||||
description='Only show selected sequences')
|
||||
sequence_use_filter_invert: BoolProperty(default=False, options=empty_set)
|
||||
sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression',
|
||||
description='Filter using regular expressions', options=empty_set)
|
||||
select_text: PointerProperty(type=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: PSA_PG_import, sequences) -> List[int]:
|
||||
bitflag_filter_item = 1 << 30
|
||||
flt_flags = [bitflag_filter_item] * len(sequences)
|
||||
|
||||
if pg.sequence_filter_name is not None:
|
||||
# Filter name is non-empty.
|
||||
if pg.sequence_use_filter_regex:
|
||||
# Use regular expression. If regex pattern doesn't compile, just ignore it.
|
||||
try:
|
||||
regex = re.compile(pg.sequence_filter_name)
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not regex.match(sequence.action_name):
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
except re.error:
|
||||
pass
|
||||
else:
|
||||
# User regular text matching.
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not fnmatch(sequence.action_name, f'*{pg.sequence_filter_name}*'):
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if pg.sequence_filter_is_selected:
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not sequence.is_selected:
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if pg.sequence_use_filter_invert:
|
||||
# Invert filter flags for all items.
|
||||
for i, sequence in enumerate(sequences):
|
||||
flt_flags[i] ^= bitflag_filter_item
|
||||
|
||||
return flt_flags
|
||||
|
||||
|
||||
def get_visible_sequences(pg: PSA_PG_import, sequences) -> List[PSA_PG_import_action_list_item]:
|
||||
bitflag_filter_item = 1 << 30
|
||||
visible_sequences = []
|
||||
for i, flag in enumerate(filter_sequences(pg, sequences)):
|
||||
if bool(flag & bitflag_filter_item):
|
||||
visible_sequences.append(sequences[i])
|
||||
return visible_sequences
|
||||
|
||||
|
||||
classes = (
|
||||
PSA_PG_import_action_list_item,
|
||||
PSA_PG_bone,
|
||||
PSA_PG_data,
|
||||
PSA_PG_import,
|
||||
)
|
||||
45
io_scene_psk_psa/psa/import_/ui.py
Normal file
45
io_scene_psk_psa/psa/import_/ui.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import bpy
|
||||
from bpy.types import UIList
|
||||
|
||||
from .properties import filter_sequences
|
||||
|
||||
|
||||
class PSA_UL_sequences(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_property, index, flt_flag):
|
||||
row = layout.row(align=True)
|
||||
split = row.split(align=True, factor=0.75)
|
||||
column = split.row(align=True)
|
||||
column.alignment = 'LEFT'
|
||||
column.prop(item, 'is_selected', icon_only=True)
|
||||
column.label(text=getattr(item, 'action_name'))
|
||||
|
||||
def draw_filter(self, context, layout):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
row = layout.row()
|
||||
sub_row = row.row(align=True)
|
||||
sub_row.prop(pg, 'sequence_filter_name', text="")
|
||||
sub_row.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
||||
sub_row.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT')
|
||||
sub_row.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT')
|
||||
|
||||
def filter_items(self, context, data, property_):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
sequences = getattr(data, property_)
|
||||
flt_flags = filter_sequences(pg, sequences)
|
||||
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'action_name')
|
||||
return flt_flags, flt_neworder
|
||||
|
||||
|
||||
class PSA_UL_import_sequences(PSA_UL_sequences, UIList):
|
||||
pass
|
||||
|
||||
|
||||
class PSA_UL_import_actions(PSA_UL_sequences, UIList):
|
||||
pass
|
||||
|
||||
|
||||
classes = (
|
||||
PSA_UL_sequences,
|
||||
PSA_UL_import_sequences,
|
||||
PSA_UL_import_actions,
|
||||
)
|
||||
@@ -1,15 +1,9 @@
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import typing
|
||||
from collections import Counter
|
||||
from typing import List, Optional
|
||||
|
||||
import bpy
|
||||
import numpy
|
||||
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty
|
||||
from bpy.types import Operator, UIList, PropertyGroup, FCurve
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
from bpy.types import FCurve, Object, Context
|
||||
from mathutils import Vector, Quaternion
|
||||
|
||||
from .data import Psa
|
||||
@@ -41,7 +35,7 @@ class ImportBone(object):
|
||||
self.fcurves: List[FCurve] = []
|
||||
|
||||
|
||||
def calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[float]):
|
||||
def _calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[float]):
|
||||
# Convert world-space transforms to local-space transforms.
|
||||
key_rotation = Quaternion(key_data[0:4])
|
||||
key_location = Vector(key_data[4:])
|
||||
@@ -64,44 +58,57 @@ class PsaImportResult:
|
||||
self.warnings: List[str] = []
|
||||
|
||||
|
||||
def import_psa(psa_reader: PsaReader, armature_object: bpy.types.Object, options: PsaImportOptions) -> PsaImportResult:
|
||||
def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_names: List[str], bone_mapping_mode: str = 'EXACT') -> Optional[int]:
|
||||
"""
|
||||
@param psa_bone_name: The name of the PSA bone.
|
||||
@param armature_bone_names: The names of the bones in the armature.
|
||||
@param bone_mapping_mode: One of 'EXACT' or 'CASE_INSENSITIVE'.
|
||||
@return: The index of the armature bone that corresponds to the given PSA bone, or None if no such bone exists.
|
||||
"""
|
||||
for armature_bone_index, armature_bone_name in enumerate(armature_bone_names):
|
||||
if bone_mapping_mode == 'CASE_INSENSITIVE':
|
||||
if armature_bone_name.lower() == psa_bone_name.lower():
|
||||
return armature_bone_index
|
||||
else:
|
||||
if armature_bone_name == psa_bone_name:
|
||||
return armature_bone_index
|
||||
return None
|
||||
|
||||
|
||||
def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult:
|
||||
result = PsaImportResult()
|
||||
sequences = map(lambda x: psa_reader.sequences[x], options.sequence_names)
|
||||
sequences = [psa_reader.sequences[x] for x in options.sequence_names]
|
||||
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.
|
||||
psa_to_armature_bone_indices = {}
|
||||
armature_to_psa_bone_indices = {}
|
||||
armature_bone_names = [x.name for x in armature_data.bones]
|
||||
psa_bone_names = []
|
||||
duplicate_mappings = []
|
||||
|
||||
for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
|
||||
psa_bone_name: str = psa_bone.name.decode('windows-1252')
|
||||
try:
|
||||
armature_bone_index = _get_armature_bone_index_for_psa_bone(psa_bone_name, armature_bone_names, options.bone_mapping_mode)
|
||||
if armature_bone_index is not None:
|
||||
# Ensure that no other PSA bone has been mapped to this armature bone yet.
|
||||
if armature_bone_index not in armature_to_psa_bone_indices:
|
||||
psa_to_armature_bone_indices[psa_bone_index] = armature_bone_names.index(psa_bone_name)
|
||||
except ValueError:
|
||||
# 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
|
||||
armature_to_psa_bone_indices[armature_bone_index] = psa_bone_index
|
||||
else:
|
||||
# This armature bone has already been mapped to a PSA bone.
|
||||
duplicate_mappings.append((psa_bone_index, armature_bone_index, armature_to_psa_bone_indices[armature_bone_index]))
|
||||
psa_bone_names.append(armature_bone_names[armature_bone_index])
|
||||
else:
|
||||
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.'
|
||||
)
|
||||
# Warn about duplicate bone mappings.
|
||||
if len(duplicate_mappings) > 0:
|
||||
for (psa_bone_index, armature_bone_index, mapped_psa_bone_index) in duplicate_mappings:
|
||||
psa_bone_name = psa_bone_names[psa_bone_index]
|
||||
armature_bone_name = armature_bone_names[armature_bone_index]
|
||||
mapped_psa_bone_name = psa_bone_names[mapped_psa_bone_index]
|
||||
result.warnings.append(f'PSA bone {psa_bone_index} ({psa_bone_name}) could not be mapped to armature bone {armature_bone_index} ({armature_bone_name}) because the armature bone is already mapped to PSA bone {mapped_psa_bone_index} ({mapped_psa_bone_name})')
|
||||
|
||||
# Report if there are missing bones in the target armature.
|
||||
missing_bone_names = set(psa_bone_names).difference(set(armature_bone_names))
|
||||
@@ -151,9 +158,11 @@ def import_psa(psa_reader: PsaReader, armature_object: bpy.types.Object, options
|
||||
import_bone.orig_quat = armature_bone.matrix_local.to_quaternion()
|
||||
import_bone.post_quat = import_bone.orig_quat.conjugated()
|
||||
|
||||
context.window_manager.progress_begin(0, len(sequences))
|
||||
|
||||
# Create and populate the data for new sequences.
|
||||
actions = []
|
||||
for sequence in sequences:
|
||||
for sequence_index, sequence in enumerate(sequences):
|
||||
# Add the action.
|
||||
sequence_name = sequence.name.decode('windows-1252')
|
||||
action_name = options.action_name_prefix + sequence_name
|
||||
@@ -195,7 +204,7 @@ def import_psa(psa_reader: PsaReader, armature_object: bpy.types.Object, options
|
||||
# This bone has writeable keyframes for this frame.
|
||||
key_data = sequence_data_matrix[frame_index, bone_index]
|
||||
# Calculate the local-space key data for the bone.
|
||||
sequence_data_matrix[frame_index, bone_index] = calculate_fcurve_data(import_bone, key_data)
|
||||
sequence_data_matrix[frame_index, bone_index] = _calculate_fcurve_data(import_bone, key_data)
|
||||
|
||||
# Write the keyframes out.
|
||||
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float)
|
||||
@@ -221,6 +230,8 @@ def import_psa(psa_reader: PsaReader, armature_object: bpy.types.Object, options
|
||||
|
||||
actions.append(action)
|
||||
|
||||
context.window_manager.progress_update(sequence_index)
|
||||
|
||||
# If the user specifies, store the new animations as strips on a non-contributing NLA track.
|
||||
if options.should_stash:
|
||||
if armature_object.animation_data is None:
|
||||
@@ -231,396 +242,6 @@ def import_psa(psa_reader: PsaReader, armature_object: bpy.types.Object, options
|
||||
nla_track.mute = True
|
||||
nla_track.strips.new(name=action.name, start=0, action=action)
|
||||
|
||||
context.window_manager.progress_end()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
empty_set = set()
|
||||
|
||||
|
||||
class PsaImportActionListItem(PropertyGroup):
|
||||
action_name: StringProperty(options=empty_set)
|
||||
is_selected: BoolProperty(default=False, options=empty_set)
|
||||
|
||||
|
||||
def load_psa_file(context, filepath: str):
|
||||
pg = context.scene.psa_import
|
||||
pg.sequence_list.clear()
|
||||
pg.psa.bones.clear()
|
||||
pg.psa_error = ''
|
||||
try:
|
||||
# Read the file and populate the action list.
|
||||
p = os.path.abspath(filepath)
|
||||
psa_reader = PsaReader(p)
|
||||
for sequence in psa_reader.sequences.values():
|
||||
item = pg.sequence_list.add()
|
||||
item.action_name = sequence.name.decode('windows-1252')
|
||||
for psa_bone in psa_reader.bones:
|
||||
item = pg.psa.bones.add()
|
||||
item.bone_name = psa_bone.name.decode('windows-1252')
|
||||
except Exception as e:
|
||||
pg.psa_error = str(e)
|
||||
|
||||
|
||||
def on_psa_file_path_updated(cls, context):
|
||||
load_psa_file(context, cls.filepath)
|
||||
|
||||
|
||||
class PsaBonePropertyGroup(PropertyGroup):
|
||||
bone_name: StringProperty(options=empty_set)
|
||||
|
||||
|
||||
class PsaDataPropertyGroup(PropertyGroup):
|
||||
bones: CollectionProperty(type=PsaBonePropertyGroup)
|
||||
sequence_count: IntProperty(default=0)
|
||||
|
||||
|
||||
class PsaImportPropertyGroup(PropertyGroup):
|
||||
psa_error: StringProperty(default='')
|
||||
psa: PointerProperty(type=PsaDataPropertyGroup)
|
||||
sequence_list: CollectionProperty(type=PsaImportActionListItem)
|
||||
sequence_list_index: IntProperty(name='', default=0)
|
||||
should_use_fake_user: BoolProperty(default=True, name='Fake User',
|
||||
description='Assign each imported action a fake user so that the data block is '
|
||||
'saved even it has no users',
|
||||
options=empty_set)
|
||||
should_stash: BoolProperty(default=False, name='Stash',
|
||||
description='Stash each imported action as a strip on a new non-contributing NLA track',
|
||||
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)
|
||||
should_overwrite: BoolProperty(default=False, name='Overwrite', 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')
|
||||
should_write_keyframes: BoolProperty(default=True, name='Keyframes', 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)')
|
||||
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
|
||||
sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected',
|
||||
description='Only show selected sequences')
|
||||
sequence_use_filter_invert: BoolProperty(default=False, options=empty_set)
|
||||
sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression',
|
||||
description='Filter using regular expressions', options=empty_set)
|
||||
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]:
|
||||
bitflag_filter_item = 1 << 30
|
||||
flt_flags = [bitflag_filter_item] * len(sequences)
|
||||
|
||||
if pg.sequence_filter_name is not None:
|
||||
# Filter name is non-empty.
|
||||
if pg.sequence_use_filter_regex:
|
||||
# Use regular expression. If regex pattern doesn't compile, just ignore it.
|
||||
try:
|
||||
regex = re.compile(pg.sequence_filter_name)
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not regex.match(sequence.action_name):
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
except re.error:
|
||||
pass
|
||||
else:
|
||||
# User regular text matching.
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not fnmatch.fnmatch(sequence.action_name, f'*{pg.sequence_filter_name}*'):
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if pg.sequence_filter_is_selected:
|
||||
for i, sequence in enumerate(sequences):
|
||||
if not sequence.is_selected:
|
||||
flt_flags[i] &= ~bitflag_filter_item
|
||||
|
||||
if pg.sequence_use_filter_invert:
|
||||
# Invert filter flags for all items.
|
||||
for i, sequence in enumerate(sequences):
|
||||
flt_flags[i] ^= bitflag_filter_item
|
||||
|
||||
return flt_flags
|
||||
|
||||
|
||||
def get_visible_sequences(pg: PsaImportPropertyGroup, sequences) -> List[PsaImportActionListItem]:
|
||||
bitflag_filter_item = 1 << 30
|
||||
visible_sequences = []
|
||||
for i, flag in enumerate(filter_sequences(pg, sequences)):
|
||||
if bool(flag & bitflag_filter_item):
|
||||
visible_sequences.append(sequences[i])
|
||||
return visible_sequences
|
||||
|
||||
|
||||
class PSA_UL_SequenceList(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_property, index, flt_flag):
|
||||
row = layout.row(align=True)
|
||||
split = row.split(align=True, factor=0.75)
|
||||
column = split.row(align=True)
|
||||
column.alignment = 'LEFT'
|
||||
column.prop(item, 'is_selected', icon_only=True)
|
||||
column.label(text=getattr(item, 'action_name'))
|
||||
|
||||
def draw_filter(self, context, layout):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
row = layout.row()
|
||||
sub_row = row.row(align=True)
|
||||
sub_row.prop(pg, 'sequence_filter_name', text="")
|
||||
sub_row.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
|
||||
sub_row.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT')
|
||||
sub_row.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT')
|
||||
|
||||
def filter_items(self, context, data, property_):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
sequences = getattr(data, property_)
|
||||
flt_flags = filter_sequences(pg, sequences)
|
||||
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'action_name')
|
||||
return flt_flags, flt_neworder
|
||||
|
||||
|
||||
class PSA_UL_ImportSequenceList(PSA_UL_SequenceList, UIList):
|
||||
pass
|
||||
|
||||
|
||||
class PSA_UL_ImportActionList(PSA_UL_SequenceList, UIList):
|
||||
pass
|
||||
|
||||
|
||||
class PsaImportSequencesFromText(Operator):
|
||||
bl_idname = 'psa_import.sequences_select_from_text'
|
||||
bl_label = 'Select By Text List'
|
||||
bl_description = 'Select sequences by name from text list'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
return len(pg.sequence_list) > 0
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self, width=256)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
layout.label(icon='INFO', text='Each sequence name should be on a new line.')
|
||||
layout.prop(pg, 'select_text', text='')
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
if pg.select_text is None:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'No text block selected')
|
||||
return {'CANCELLED'}
|
||||
contents = pg.select_text.as_string()
|
||||
count = 0
|
||||
for line in contents.split('\n'):
|
||||
for sequence in pg.sequence_list:
|
||||
if sequence.action_name == line:
|
||||
sequence.is_selected = True
|
||||
count += 1
|
||||
self.report({'INFO'}, f'Selected {count} sequence(s)')
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaImportSequencesSelectAll(Operator):
|
||||
bl_idname = 'psa_import.sequences_select_all'
|
||||
bl_label = 'All'
|
||||
bl_description = 'Select all sequences'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
has_unselected_actions = any(map(lambda action: not action.is_selected, visible_sequences))
|
||||
return len(visible_sequences) > 0 and has_unselected_actions
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
for sequence in visible_sequences:
|
||||
sequence.is_selected = True
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaImportSequencesDeselectAll(Operator):
|
||||
bl_idname = 'psa_import.sequences_deselect_all'
|
||||
bl_label = 'None'
|
||||
bl_description = 'Deselect all visible sequences'
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
has_selected_sequences = any(map(lambda sequence: sequence.is_selected, visible_sequences))
|
||||
return len(visible_sequences) > 0 and has_selected_sequences
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
|
||||
for sequence in visible_sequences:
|
||||
sequence.is_selected = False
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PsaImportSelectFile(Operator):
|
||||
bl_idname = 'psa_import.select_file'
|
||||
bl_label = 'Select'
|
||||
bl_options = {'INTERNAL'}
|
||||
bl_description = 'Select a PSA file from which to import animations'
|
||||
filepath: bpy.props.StringProperty(subtype='FILE_PATH')
|
||||
filter_glob: bpy.props.StringProperty(default="*.psa", options={'HIDDEN'})
|
||||
|
||||
def execute(self, context):
|
||||
getattr(context.scene, 'psa_import').psa_file_path = self.filepath
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
class PsaImportOperator(Operator, ImportHelper):
|
||||
bl_idname = 'psa_import.import'
|
||||
bl_label = 'Import'
|
||||
bl_description = 'Import the selected animations into the scene as actions'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
|
||||
filename_ext = '.psa'
|
||||
filter_glob: StringProperty(default='*.psa', options={'HIDDEN'})
|
||||
filepath: StringProperty(
|
||||
name='File Path',
|
||||
description='File path used for importing the PSA file',
|
||||
maxlen=1024,
|
||||
default='',
|
||||
update=on_psa_file_path_updated)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
active_object = context.view_layer.objects.active
|
||||
if active_object is None or active_object.type != 'ARMATURE':
|
||||
cls.poll_message_set('The active object must be an armature')
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
psa_reader = PsaReader(self.filepath)
|
||||
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected]
|
||||
|
||||
options = PsaImportOptions()
|
||||
options.sequence_names = sequence_names
|
||||
options.should_use_fake_user = pg.should_use_fake_user
|
||||
options.should_stash = pg.should_stash
|
||||
options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else ''
|
||||
options.should_overwrite = pg.should_overwrite
|
||||
options.should_write_metadata = pg.should_write_metadata
|
||||
options.should_write_keyframes = pg.should_write_keyframes
|
||||
options.should_convert_to_samples = pg.should_convert_to_samples
|
||||
options.bone_mapping_mode = pg.bone_mapping_mode
|
||||
|
||||
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)')
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event):
|
||||
# Attempt to load the PSA file for the pre-selected file.
|
||||
load_psa_file(context, self.filepath)
|
||||
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def draw(self, context: bpy.types.Context):
|
||||
layout = self.layout
|
||||
pg = getattr(context.scene, 'psa_import')
|
||||
|
||||
if pg.psa_error:
|
||||
row = layout.row()
|
||||
row.label(text='Select a PSA file', icon='ERROR')
|
||||
else:
|
||||
box = layout.box()
|
||||
|
||||
box.label(text=f'Sequences ({len(pg.sequence_list)})', icon='ARMATURE_DATA')
|
||||
|
||||
# Select buttons.
|
||||
rows = max(3, min(len(pg.sequence_list), 10))
|
||||
|
||||
row = box.row()
|
||||
col = row.column()
|
||||
|
||||
row2 = col.row(align=True)
|
||||
row2.label(text='Select')
|
||||
row2.operator(PsaImportSequencesFromText.bl_idname, text='', icon='TEXT')
|
||||
row2.operator(PsaImportSequencesSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT')
|
||||
row2.operator(PsaImportSequencesDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT')
|
||||
|
||||
col = col.row()
|
||||
col.template_list('PSA_UL_ImportSequenceList', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows)
|
||||
|
||||
col = layout.column(heading='')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_overwrite')
|
||||
|
||||
col = layout.column(heading='Write')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_write_keyframes')
|
||||
col.prop(pg, 'should_write_metadata')
|
||||
|
||||
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.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_convert_to_samples')
|
||||
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_stash')
|
||||
col.prop(pg, 'should_use_action_name_prefix')
|
||||
|
||||
if pg.should_use_action_name_prefix:
|
||||
col.prop(pg, 'action_name_prefix')
|
||||
|
||||
|
||||
classes = (
|
||||
PsaImportActionListItem,
|
||||
PsaBonePropertyGroup,
|
||||
PsaDataPropertyGroup,
|
||||
PsaImportPropertyGroup,
|
||||
PSA_UL_SequenceList,
|
||||
PSA_UL_ImportSequenceList,
|
||||
PSA_UL_ImportActionList,
|
||||
PsaImportSequencesSelectAll,
|
||||
PsaImportSequencesDeselectAll,
|
||||
PsaImportSequencesFromText,
|
||||
PsaImportOperator,
|
||||
PsaImportSelectFile,
|
||||
)
|
||||
|
||||
25
io_scene_psk_psa/psa/writer.py
Normal file
25
io_scene_psk_psa/psa/writer.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from ctypes import Structure, sizeof
|
||||
from typing import Type
|
||||
|
||||
from .data import Psa
|
||||
from ..data import Section
|
||||
|
||||
|
||||
def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None):
|
||||
section = Section()
|
||||
section.name = name
|
||||
if data_type is not None and data is not None:
|
||||
section.data_size = sizeof(data_type)
|
||||
section.data_count = len(data)
|
||||
fp.write(section)
|
||||
if data is not None:
|
||||
for datum in data:
|
||||
fp.write(datum)
|
||||
|
||||
|
||||
def write_psa(psa: Psa, path: str):
|
||||
with open(path, 'wb') as fp:
|
||||
write_section(fp, b'ANIMHEAD')
|
||||
write_section(fp, b'BONENAMES', Psa.Bone, psa.bones)
|
||||
write_section(fp, b'ANIMINFO', Psa.Sequence, list(psa.sequences.values()))
|
||||
write_section(fp, b'ANIMKEYS', Psa.Key, psa.keys)
|
||||
@@ -1,5 +1,3 @@
|
||||
import typing
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
from bpy.types import Armature
|
||||
@@ -20,7 +18,7 @@ class PskBuildOptions(object):
|
||||
self.bone_group_indices: List[int] = []
|
||||
self.use_raw_mesh_data = True
|
||||
self.material_names: List[str] = []
|
||||
self.should_ignore_bone_name_restrictions = False
|
||||
self.should_enforce_bone_name_restrictions = False
|
||||
|
||||
|
||||
def get_psk_input_objects(context) -> PskInputObjects:
|
||||
@@ -52,7 +50,8 @@ def get_psk_input_objects(context) -> PskInputObjects:
|
||||
armature_modifier_objects.add(modifiers[0].object)
|
||||
|
||||
if len(armature_modifier_objects) > 1:
|
||||
raise RuntimeError('All selected meshes must have the same armature modifier')
|
||||
armature_modifier_names = [x.name for x in armature_modifier_objects]
|
||||
raise RuntimeError(f'All selected meshes must have the same armature modifier, encountered {len(armature_modifier_names)} ({", ".join(armature_modifier_names)})')
|
||||
elif len(armature_modifier_objects) == 1:
|
||||
input_objects.armature_object = list(armature_modifier_objects)[0]
|
||||
|
||||
@@ -84,7 +83,7 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
bones = [armature_data.bones[bone_name] for bone_name in bone_names]
|
||||
|
||||
# Check that all bone names are valid.
|
||||
if not options.should_ignore_bone_name_restrictions:
|
||||
if options.should_enforce_bone_name_restrictions:
|
||||
check_bone_names(map(lambda x: x.name, bones))
|
||||
|
||||
for bone in bones:
|
||||
@@ -98,7 +97,7 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
|
||||
psk_bone.parent_index = parent_index
|
||||
psk.bones[parent_index].children_count += 1
|
||||
except ValueError:
|
||||
psk_bone.parent_index = -1
|
||||
psk_bone.parent_index = 0
|
||||
|
||||
if bone.parent is not None:
|
||||
rotation = bone.matrix.to_quaternion().conjugated()
|
||||
|
||||
0
io_scene_psk_psa/psk/export/__init__.py
Normal file
0
io_scene_psk_psa/psk/export/__init__.py
Normal file
179
io_scene_psk_psa/psk/export/operators.py
Normal file
179
io_scene_psk_psa/psk/export/operators.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Operator
|
||||
from bpy_extras.io_utils import ExportHelper
|
||||
|
||||
from ..builder import build_psk, PskBuildOptions, get_psk_input_objects
|
||||
from ..writer import write_psk
|
||||
from ...helpers import populate_bone_group_list
|
||||
|
||||
|
||||
def is_bone_filter_mode_item_available(context, identifier):
|
||||
input_objects = get_psk_input_objects(context)
|
||||
armature_object = input_objects.armature_object
|
||||
if identifier == 'BONE_GROUPS':
|
||||
if not armature_object or not armature_object.pose or not armature_object.pose.bone_groups:
|
||||
return False
|
||||
# else if... you can set up other conditions if you add more options
|
||||
return True
|
||||
|
||||
|
||||
def populate_material_list(mesh_objects, material_list):
|
||||
material_list.clear()
|
||||
|
||||
material_names = []
|
||||
for mesh_object in mesh_objects:
|
||||
for i, material in enumerate(mesh_object.data.materials):
|
||||
# TODO: put this in the poll arg?
|
||||
if material is None:
|
||||
raise RuntimeError('Material cannot be empty (index ' + str(i) + ')')
|
||||
if material.name not in material_names:
|
||||
material_names.append(material.name)
|
||||
|
||||
for index, material_name in enumerate(material_names):
|
||||
m = material_list.add()
|
||||
m.material_name = material_name
|
||||
m.index = index
|
||||
|
||||
|
||||
class PSK_OT_material_list_move_up(Operator):
|
||||
bl_idname = 'psk_export.material_list_item_move_up'
|
||||
bl_label = 'Move Up'
|
||||
bl_options = {'INTERNAL'}
|
||||
bl_description = 'Move the selected material up one slot'
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
return pg.material_list_index > 0
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
pg.material_list.move(pg.material_list_index, pg.material_list_index - 1)
|
||||
pg.material_list_index -= 1
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PSK_OT_material_list_move_down(Operator):
|
||||
bl_idname = 'psk_export.material_list_item_move_down'
|
||||
bl_label = 'Move Down'
|
||||
bl_options = {'INTERNAL'}
|
||||
bl_description = 'Move the selected material down one slot'
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
return pg.material_list_index < len(pg.material_list) - 1
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
pg.material_list.move(pg.material_list_index, pg.material_list_index + 1)
|
||||
pg.material_list_index += 1
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PSK_OT_export(Operator, ExportHelper):
|
||||
bl_idname = 'export.psk'
|
||||
bl_label = 'Export'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
__doc__ = 'Export mesh and armature to PSK'
|
||||
filename_ext = '.psk'
|
||||
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'})
|
||||
|
||||
filepath: StringProperty(
|
||||
name='File Path',
|
||||
description='File path used for exporting the PSK file',
|
||||
maxlen=1024,
|
||||
default='')
|
||||
|
||||
def invoke(self, context, event):
|
||||
try:
|
||||
input_objects = get_psk_input_objects(context)
|
||||
except RuntimeError as e:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
|
||||
# Populate bone groups list.
|
||||
populate_bone_group_list(input_objects.armature_object, pg.bone_group_list)
|
||||
|
||||
try:
|
||||
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)
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
try:
|
||||
get_psk_input_objects(context)
|
||||
except RuntimeError as e:
|
||||
cls.poll_message_set(str(e))
|
||||
return False
|
||||
return True
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
|
||||
# MESH
|
||||
box = layout.box()
|
||||
box.label(text='Mesh', icon='MESH_DATA')
|
||||
box.prop(pg, 'use_raw_mesh_data')
|
||||
|
||||
# BONES
|
||||
box = layout.box()
|
||||
box.label(text='Bones', icon='BONE_DATA')
|
||||
bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static
|
||||
row = box.row(align=True)
|
||||
for item in bone_filter_mode_items:
|
||||
identifier = item.identifier
|
||||
item_layout = row.row(align=True)
|
||||
item_layout.prop_enum(pg, 'bone_filter_mode', item.identifier)
|
||||
item_layout.enabled = is_bone_filter_mode_item_available(context, identifier)
|
||||
|
||||
if pg.bone_filter_mode == 'BONE_GROUPS':
|
||||
row = box.row()
|
||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||
row.template_list('PSX_UL_bone_group_list', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows)
|
||||
|
||||
box.prop(pg, 'should_enforce_bone_name_restrictions')
|
||||
|
||||
# MATERIALS
|
||||
box = layout.box()
|
||||
box.label(text='Materials', icon='MATERIAL')
|
||||
row = box.row()
|
||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||
row.template_list('PSK_UL_materials', '', pg, 'material_list', pg, 'material_list_index', rows=rows)
|
||||
col = row.column(align=True)
|
||||
col.operator(PSK_OT_material_list_move_up.bl_idname, text='', icon='TRIA_UP')
|
||||
col.operator(PSK_OT_material_list_move_down.bl_idname, text='', icon='TRIA_DOWN')
|
||||
|
||||
def execute(self, context):
|
||||
pg = context.scene.psk_export
|
||||
options = PskBuildOptions()
|
||||
options.bone_filter_mode = pg.bone_filter_mode
|
||||
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
|
||||
options.use_raw_mesh_data = pg.use_raw_mesh_data
|
||||
options.material_names = [m.material_name for m in pg.material_list]
|
||||
options.should_enforce_bone_name_restrictions = pg.should_enforce_bone_name_restrictions
|
||||
|
||||
try:
|
||||
psk = build_psk(context, options)
|
||||
write_psk(psk, self.filepath)
|
||||
self.report({'INFO'}, f'PSK export successful')
|
||||
except RuntimeError as e:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
classes = (
|
||||
PSK_OT_material_list_move_up,
|
||||
PSK_OT_material_list_move_down,
|
||||
PSK_OT_export,
|
||||
)
|
||||
39
io_scene_psk_psa/psk/export/properties.py
Normal file
39
io_scene_psk_psa/psk/export/properties.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from bpy.props import EnumProperty, CollectionProperty, IntProperty, BoolProperty, StringProperty
|
||||
from bpy.types import PropertyGroup
|
||||
|
||||
from ...types import PSX_PG_bone_group_list_item
|
||||
|
||||
|
||||
class PSK_PG_material_list_item(PropertyGroup):
|
||||
material_name: StringProperty()
|
||||
index: IntProperty()
|
||||
|
||||
|
||||
class PSK_PG_export(PropertyGroup):
|
||||
bone_filter_mode: EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=set(),
|
||||
description='',
|
||||
items=(
|
||||
('ALL', 'All', 'All bones will be exported'),
|
||||
('BONE_GROUPS', 'Bone Groups',
|
||||
'Only bones belonging to the selected bone groups and their ancestors will be exported')
|
||||
)
|
||||
)
|
||||
bone_group_list: CollectionProperty(type=PSX_PG_bone_group_list_item)
|
||||
bone_group_list_index: IntProperty(default=0)
|
||||
use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh')
|
||||
material_list: CollectionProperty(type=PSK_PG_material_list_item)
|
||||
material_list_index: IntProperty(default=0)
|
||||
should_enforce_bone_name_restrictions: BoolProperty(
|
||||
default=False,
|
||||
name='Enforce Bone Name Restrictions',
|
||||
description='Enforce that bone names must only contain letters, numbers, spaces, hyphens and underscores.\n\n'
|
||||
'Depending on the engine, improper bone names might not be referenced correctly by scripts'
|
||||
)
|
||||
|
||||
|
||||
classes = (
|
||||
PSK_PG_material_list_item,
|
||||
PSK_PG_export,
|
||||
)
|
||||
12
io_scene_psk_psa/psk/export/ui.py
Normal file
12
io_scene_psk_psa/psk/export/ui.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from bpy.types import UIList
|
||||
|
||||
|
||||
class PSK_UL_materials(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
row = layout.row()
|
||||
row.label(text=str(getattr(item, 'material_name')), icon='MATERIAL')
|
||||
|
||||
|
||||
classes = (
|
||||
PSK_UL_materials,
|
||||
)
|
||||
@@ -1,272 +0,0 @@
|
||||
from typing import Type
|
||||
|
||||
from bpy.props import BoolProperty, StringProperty, CollectionProperty, IntProperty, EnumProperty
|
||||
from bpy.types import Operator, PropertyGroup, UIList
|
||||
from bpy_extras.io_utils import ExportHelper
|
||||
|
||||
from .builder import build_psk, PskBuildOptions, get_psk_input_objects
|
||||
from .data import *
|
||||
from ..helpers import populate_bone_group_list
|
||||
from ..types import BoneGroupListItem
|
||||
|
||||
MAX_WEDGE_COUNT = 65536
|
||||
MAX_POINT_COUNT = 4294967296
|
||||
MAX_BONE_COUNT = 256
|
||||
MAX_MATERIAL_COUNT = 256
|
||||
|
||||
|
||||
def _write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None):
|
||||
section = Section()
|
||||
section.name = name
|
||||
if data_type is not None and data is not None:
|
||||
section.data_size = sizeof(data_type)
|
||||
section.data_count = len(data)
|
||||
fp.write(section)
|
||||
if data is not None:
|
||||
for datum in data:
|
||||
fp.write(datum)
|
||||
|
||||
|
||||
def export_psk(psk: Psk, path: str):
|
||||
if len(psk.wedges) > MAX_WEDGE_COUNT:
|
||||
raise RuntimeError(f'Number of wedges ({len(psk.wedges)}) exceeds limit of {MAX_WEDGE_COUNT}')
|
||||
if len(psk.points) > 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:
|
||||
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:
|
||||
_write_section(fp, b'ACTRHEAD')
|
||||
_write_section(fp, b'PNTS0000', Vector3, psk.points)
|
||||
|
||||
wedges = []
|
||||
for index, w in enumerate(psk.wedges):
|
||||
wedge = Psk.Wedge16()
|
||||
wedge.material_index = w.material_index
|
||||
wedge.u = w.u
|
||||
wedge.v = w.v
|
||||
wedge.point_index = w.point_index
|
||||
wedges.append(wedge)
|
||||
|
||||
_write_section(fp, b'VTXW0000', Psk.Wedge16, wedges)
|
||||
_write_section(fp, b'FACE0000', Psk.Face, psk.faces)
|
||||
_write_section(fp, b'MATT0000', Psk.Material, psk.materials)
|
||||
_write_section(fp, b'REFSKELT', Psk.Bone, psk.bones)
|
||||
_write_section(fp, b'RAWWEIGHTS', Psk.Weight, psk.weights)
|
||||
|
||||
|
||||
def is_bone_filter_mode_item_available(context, identifier):
|
||||
input_objects = get_psk_input_objects(context)
|
||||
armature_object = input_objects.armature_object
|
||||
if identifier == 'BONE_GROUPS':
|
||||
if not armature_object or not armature_object.pose or not armature_object.pose.bone_groups:
|
||||
return False
|
||||
# else if... you can set up other conditions if you add more options
|
||||
return True
|
||||
|
||||
|
||||
class PSK_UL_MaterialList(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
row = layout.row()
|
||||
row.label(text=str(getattr(item, 'material_name')), icon='MATERIAL')
|
||||
|
||||
|
||||
class MaterialListItem(PropertyGroup):
|
||||
material_name: StringProperty()
|
||||
index: IntProperty()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.material_name
|
||||
|
||||
|
||||
def populate_material_list(mesh_objects, material_list):
|
||||
material_list.clear()
|
||||
|
||||
material_names = []
|
||||
for mesh_object in mesh_objects:
|
||||
for i, material in enumerate(mesh_object.data.materials):
|
||||
# TODO: put this in the poll arg?
|
||||
if material is None:
|
||||
raise RuntimeError('Material cannot be empty (index ' + str(i) + ')')
|
||||
if material.name not in material_names:
|
||||
material_names.append(material.name)
|
||||
|
||||
for index, material_name in enumerate(material_names):
|
||||
m = material_list.add()
|
||||
m.material_name = material_name
|
||||
m.index = index
|
||||
|
||||
|
||||
class PskMaterialListItemMoveUp(Operator):
|
||||
bl_idname = 'psk_export.material_list_item_move_up'
|
||||
bl_label = 'Move Up'
|
||||
bl_options = {'INTERNAL'}
|
||||
bl_description = 'Move the selected material up one slot'
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
return pg.material_list_index > 0
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
pg.material_list.move(pg.material_list_index, pg.material_list_index - 1)
|
||||
pg.material_list_index -= 1
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PskMaterialListItemMoveDown(Operator):
|
||||
bl_idname = 'psk_export.material_list_item_move_down'
|
||||
bl_label = 'Move Down'
|
||||
bl_options = {'INTERNAL'}
|
||||
bl_description = 'Move the selected material down one slot'
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
return pg.material_list_index < len(pg.material_list) - 1
|
||||
|
||||
def execute(self, context):
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
pg.material_list.move(pg.material_list_index, pg.material_list_index + 1)
|
||||
pg.material_list_index += 1
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PskExportOperator(Operator, ExportHelper):
|
||||
bl_idname = 'export.psk'
|
||||
bl_label = 'Export'
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
__doc__ = 'Export mesh and armature to PSK'
|
||||
filename_ext = '.psk'
|
||||
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'})
|
||||
|
||||
filepath: StringProperty(
|
||||
name='File Path',
|
||||
description='File path used for exporting the PSK file',
|
||||
maxlen=1024,
|
||||
default='')
|
||||
|
||||
def invoke(self, context, event):
|
||||
try:
|
||||
input_objects = get_psk_input_objects(context)
|
||||
except RuntimeError as e:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
|
||||
# Populate bone groups list.
|
||||
populate_bone_group_list(input_objects.armature_object, pg.bone_group_list)
|
||||
|
||||
try:
|
||||
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)
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
try:
|
||||
get_psk_input_objects(context)
|
||||
except RuntimeError as e:
|
||||
cls.poll_message_set(str(e))
|
||||
return False
|
||||
return True
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
pg = getattr(context.scene, 'psk_export')
|
||||
|
||||
layout.prop(pg, 'use_raw_mesh_data')
|
||||
|
||||
# BONES
|
||||
layout.label(text='Bones', icon='BONE_DATA')
|
||||
bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static
|
||||
row = layout.row(align=True)
|
||||
for item in bone_filter_mode_items:
|
||||
identifier = item.identifier
|
||||
item_layout = row.row(align=True)
|
||||
item_layout.prop_enum(pg, 'bone_filter_mode', item.identifier)
|
||||
item_layout.enabled = is_bone_filter_mode_item_available(context, identifier)
|
||||
|
||||
if pg.bone_filter_mode == 'BONE_GROUPS':
|
||||
row = layout.row()
|
||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||
row.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows)
|
||||
|
||||
layout.separator()
|
||||
|
||||
# MATERIALS
|
||||
layout.label(text='Materials', icon='MATERIAL')
|
||||
row = layout.row()
|
||||
rows = max(3, min(len(pg.bone_group_list), 10))
|
||||
row.template_list('PSK_UL_MaterialList', '', pg, 'material_list', pg, 'material_list_index', rows=rows)
|
||||
col = row.column(align=True)
|
||||
col.operator(PskMaterialListItemMoveUp.bl_idname, text='', icon='TRIA_UP')
|
||||
col.operator(PskMaterialListItemMoveDown.bl_idname, text='', icon='TRIA_DOWN')
|
||||
|
||||
layout.separator()
|
||||
|
||||
layout.prop(pg, 'should_ignore_bone_name_restrictions')
|
||||
|
||||
def execute(self, context):
|
||||
pg = context.scene.psk_export
|
||||
options = PskBuildOptions()
|
||||
options.bone_filter_mode = pg.bone_filter_mode
|
||||
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
|
||||
options.use_raw_mesh_data = pg.use_raw_mesh_data
|
||||
options.material_names = [m.material_name for m in pg.material_list]
|
||||
options.should_ignore_bone_name_restrictions = pg.should_ignore_bone_name_restrictions
|
||||
|
||||
try:
|
||||
psk = build_psk(context, options)
|
||||
export_psk(psk, self.filepath)
|
||||
self.report({'INFO'}, f'PSK export successful')
|
||||
except RuntimeError as e:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PskExportPropertyGroup(PropertyGroup):
|
||||
bone_filter_mode: EnumProperty(
|
||||
name='Bone Filter',
|
||||
options=set(),
|
||||
description='',
|
||||
items=(
|
||||
('ALL', 'All', 'All bones will be exported.'),
|
||||
('BONE_GROUPS', 'Bone Groups',
|
||||
'Only bones belonging to the selected bone groups and their ancestors will be exported.')
|
||||
)
|
||||
)
|
||||
bone_group_list: CollectionProperty(type=BoneGroupListItem)
|
||||
bone_group_list_index: IntProperty(default=0)
|
||||
use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh')
|
||||
material_list: CollectionProperty(type=MaterialListItem)
|
||||
material_list_index: IntProperty(default=0)
|
||||
should_ignore_bone_name_restrictions: BoolProperty(
|
||||
default=False,
|
||||
name='Ignore Bone Name Restrictions',
|
||||
description='Bone names restrictions will be ignored. Note that bone names without properly formatted names '
|
||||
'cannot be referenced in scripts'
|
||||
)
|
||||
|
||||
|
||||
classes = (
|
||||
MaterialListItem,
|
||||
PSK_UL_MaterialList,
|
||||
PskMaterialListItemMoveUp,
|
||||
PskMaterialListItemMoveDown,
|
||||
PskExportOperator,
|
||||
PskExportPropertyGroup,
|
||||
)
|
||||
0
io_scene_psk_psa/psk/import_/__init__.py
Normal file
0
io_scene_psk_psa/psk/import_/__init__.py
Normal file
144
io_scene_psk_psa/psk/import_/operators.py
Normal file
144
io_scene_psk_psa/psk/import_/operators.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from bpy.props import StringProperty, BoolProperty, EnumProperty, FloatProperty
|
||||
from bpy.types import Operator
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
|
||||
from ..importer import PskImportOptions, import_psk
|
||||
from ..reader import read_psk
|
||||
|
||||
empty_set = set()
|
||||
|
||||
|
||||
class PSK_OT_import(Operator, ImportHelper):
|
||||
bl_idname = 'import_scene.psk'
|
||||
bl_label = 'Import'
|
||||
bl_options = {'INTERNAL', 'UNDO', 'PRESET'}
|
||||
__doc__ = 'Load a PSK file'
|
||||
filename_ext = '.psk'
|
||||
filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'})
|
||||
filepath: StringProperty(
|
||||
name='File Path',
|
||||
description='File path used for exporting the PSK file',
|
||||
maxlen=1024,
|
||||
default='')
|
||||
|
||||
should_import_vertex_colors: BoolProperty(
|
||||
default=True,
|
||||
options=empty_set,
|
||||
name='Vertex Colors',
|
||||
description='Import vertex colors from PSKX files, if available'
|
||||
)
|
||||
vertex_color_space: EnumProperty(
|
||||
name='Vertex Color Space',
|
||||
options=empty_set,
|
||||
description='The source vertex color space',
|
||||
default='SRGBA',
|
||||
items=(
|
||||
('LINEAR', 'Linear', ''),
|
||||
('SRGBA', 'sRGBA', ''),
|
||||
)
|
||||
)
|
||||
should_import_vertex_normals: BoolProperty(
|
||||
default=True,
|
||||
name='Vertex Normals',
|
||||
options=empty_set,
|
||||
description='Import vertex normals, if available'
|
||||
)
|
||||
should_import_extra_uvs: BoolProperty(
|
||||
default=True,
|
||||
name='Extra UVs',
|
||||
options=empty_set,
|
||||
description='Import extra UV maps, if available'
|
||||
)
|
||||
should_import_mesh: BoolProperty(
|
||||
default=True,
|
||||
name='Import Mesh',
|
||||
options=empty_set,
|
||||
description='Import mesh'
|
||||
)
|
||||
should_import_materials: BoolProperty(
|
||||
default=True,
|
||||
name='Import Materials',
|
||||
options=empty_set,
|
||||
)
|
||||
should_reuse_materials: BoolProperty(
|
||||
default=True,
|
||||
name='Reuse Materials',
|
||||
options=empty_set,
|
||||
description='Existing materials with matching names will be reused when available'
|
||||
)
|
||||
should_import_skeleton: BoolProperty(
|
||||
default=True,
|
||||
name='Import Skeleton',
|
||||
options=empty_set,
|
||||
description='Import skeleton'
|
||||
)
|
||||
bone_length: FloatProperty(
|
||||
default=1.0,
|
||||
min=sys.float_info.epsilon,
|
||||
step=100,
|
||||
soft_min=1.0,
|
||||
name='Bone Length',
|
||||
options=empty_set,
|
||||
description='Length of the bones'
|
||||
)
|
||||
should_import_shape_keys: BoolProperty(
|
||||
default=True,
|
||||
name='Shape Keys',
|
||||
options=empty_set,
|
||||
description='Import shape keys, if available'
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
psk = read_psk(self.filepath)
|
||||
|
||||
options = PskImportOptions()
|
||||
options.name = os.path.splitext(os.path.basename(self.filepath))[0]
|
||||
options.should_import_mesh = self.should_import_mesh
|
||||
options.should_import_extra_uvs = self.should_import_extra_uvs
|
||||
options.should_import_vertex_colors = self.should_import_vertex_colors
|
||||
options.should_import_vertex_normals = self.should_import_vertex_normals
|
||||
options.vertex_color_space = self.vertex_color_space
|
||||
options.should_import_skeleton = self.should_import_skeleton
|
||||
options.bone_length = self.bone_length
|
||||
options.should_import_materials = self.should_import_materials
|
||||
options.should_import_shape_keys = self.should_import_shape_keys
|
||||
|
||||
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'}
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, 'should_import_materials')
|
||||
layout.prop(self, 'should_import_mesh')
|
||||
row = layout.column()
|
||||
row.use_property_split = True
|
||||
row.use_property_decorate = False
|
||||
if self.should_import_mesh:
|
||||
row.prop(self, 'should_import_vertex_normals')
|
||||
row.prop(self, 'should_import_extra_uvs')
|
||||
row.prop(self, 'should_import_vertex_colors')
|
||||
if self.should_import_vertex_colors:
|
||||
row.prop(self, 'vertex_color_space')
|
||||
row.prop(self, 'should_import_shape_keys')
|
||||
layout.prop(self, 'should_import_skeleton')
|
||||
row = layout.column()
|
||||
row.use_property_split = True
|
||||
row.use_property_decorate = False
|
||||
if self.should_import_skeleton:
|
||||
row.prop(self, 'bone_length')
|
||||
|
||||
|
||||
classes = (
|
||||
PSK_OT_import,
|
||||
)
|
||||
@@ -1,18 +1,13 @@
|
||||
import os
|
||||
import sys
|
||||
from math import inf
|
||||
from typing import Optional, List
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
from bpy.props import BoolProperty, EnumProperty, FloatProperty, StringProperty
|
||||
from bpy.types import Operator, VertexGroup
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
from bpy.types import VertexGroup
|
||||
from mathutils import Quaternion, Vector, Matrix
|
||||
|
||||
from .data import Psk
|
||||
from .reader import read_psk
|
||||
from ..helpers import rgb_to_srgb, is_bdk_addon_loaded
|
||||
|
||||
|
||||
@@ -277,139 +272,3 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
empty_set = set()
|
||||
|
||||
|
||||
class PskImportOperator(Operator, ImportHelper):
|
||||
bl_idname = 'import_scene.psk'
|
||||
bl_label = 'Import'
|
||||
bl_options = {'INTERNAL', 'UNDO', 'PRESET'}
|
||||
__doc__ = 'Load a PSK file'
|
||||
filename_ext = '.psk'
|
||||
filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'})
|
||||
filepath: StringProperty(
|
||||
name='File Path',
|
||||
description='File path used for exporting the PSK file',
|
||||
maxlen=1024,
|
||||
default='')
|
||||
|
||||
should_import_vertex_colors: BoolProperty(
|
||||
default=True,
|
||||
options=empty_set,
|
||||
name='Vertex Colors',
|
||||
description='Import vertex colors from PSKX files, if available'
|
||||
)
|
||||
vertex_color_space: EnumProperty(
|
||||
name='Vertex Color Space',
|
||||
options=empty_set,
|
||||
description='The source vertex color space',
|
||||
default='SRGBA',
|
||||
items=(
|
||||
('LINEAR', 'Linear', ''),
|
||||
('SRGBA', 'sRGBA', ''),
|
||||
)
|
||||
)
|
||||
should_import_vertex_normals: BoolProperty(
|
||||
default=True,
|
||||
name='Vertex Normals',
|
||||
options=empty_set,
|
||||
description='Import vertex normals, if available'
|
||||
)
|
||||
should_import_extra_uvs: BoolProperty(
|
||||
default=True,
|
||||
name='Extra UVs',
|
||||
options=empty_set,
|
||||
description='Import extra UV maps, if available'
|
||||
)
|
||||
should_import_mesh: BoolProperty(
|
||||
default=True,
|
||||
name='Import Mesh',
|
||||
options=empty_set,
|
||||
description='Import mesh'
|
||||
)
|
||||
should_import_materials: BoolProperty(
|
||||
default=True,
|
||||
name='Import Materials',
|
||||
options=empty_set,
|
||||
)
|
||||
should_reuse_materials: BoolProperty(
|
||||
default=True,
|
||||
name='Reuse Materials',
|
||||
options=empty_set,
|
||||
description='Existing materials with matching names will be reused when available'
|
||||
)
|
||||
should_import_skeleton: BoolProperty(
|
||||
default=True,
|
||||
name='Import Skeleton',
|
||||
options=empty_set,
|
||||
description='Import skeleton'
|
||||
)
|
||||
bone_length: FloatProperty(
|
||||
default=1.0,
|
||||
min=sys.float_info.epsilon,
|
||||
step=100,
|
||||
soft_min=1.0,
|
||||
name='Bone Length',
|
||||
options=empty_set,
|
||||
description='Length of the bones'
|
||||
)
|
||||
should_import_shape_keys: BoolProperty(
|
||||
default=True,
|
||||
name='Shape Keys',
|
||||
options=empty_set,
|
||||
description='Import shape keys, if available'
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
psk = read_psk(self.filepath)
|
||||
|
||||
options = PskImportOptions()
|
||||
options.name = os.path.splitext(os.path.basename(self.filepath))[0]
|
||||
options.should_import_mesh = self.should_import_mesh
|
||||
options.should_import_extra_uvs = self.should_import_extra_uvs
|
||||
options.should_import_vertex_colors = self.should_import_vertex_colors
|
||||
options.should_import_vertex_normals = self.should_import_vertex_normals
|
||||
options.vertex_color_space = self.vertex_color_space
|
||||
options.should_import_skeleton = self.should_import_skeleton
|
||||
options.bone_length = self.bone_length
|
||||
options.should_import_materials = self.should_import_materials
|
||||
options.should_import_shape_keys = self.should_import_shape_keys
|
||||
|
||||
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'}
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, 'should_import_materials')
|
||||
layout.prop(self, 'should_import_mesh')
|
||||
row = layout.column()
|
||||
row.use_property_split = True
|
||||
row.use_property_decorate = False
|
||||
if self.should_import_mesh:
|
||||
row.prop(self, 'should_import_vertex_normals')
|
||||
row.prop(self, 'should_import_extra_uvs')
|
||||
row.prop(self, 'should_import_vertex_colors')
|
||||
if self.should_import_vertex_colors:
|
||||
row.prop(self, 'vertex_color_space')
|
||||
row.prop(self, 'should_import_shape_keys')
|
||||
layout.prop(self, 'should_import_skeleton')
|
||||
row = layout.column()
|
||||
row.use_property_split = True
|
||||
row.use_property_decorate = False
|
||||
if self.should_import_skeleton:
|
||||
row.prop(self, 'bone_length')
|
||||
|
||||
|
||||
classes = (
|
||||
PskImportOperator,
|
||||
)
|
||||
|
||||
54
io_scene_psk_psa/psk/writer.py
Normal file
54
io_scene_psk_psa/psk/writer.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from ctypes import Structure, sizeof
|
||||
from typing import Type
|
||||
|
||||
from .data import Psk
|
||||
from ..data import Section, Vector3
|
||||
|
||||
MAX_WEDGE_COUNT = 65536
|
||||
MAX_POINT_COUNT = 4294967296
|
||||
MAX_BONE_COUNT = 256
|
||||
MAX_MATERIAL_COUNT = 256
|
||||
|
||||
|
||||
def _write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None):
|
||||
section = Section()
|
||||
section.name = name
|
||||
if data_type is not None and data is not None:
|
||||
section.data_size = sizeof(data_type)
|
||||
section.data_count = len(data)
|
||||
fp.write(section)
|
||||
if data is not None:
|
||||
for datum in data:
|
||||
fp.write(datum)
|
||||
|
||||
|
||||
def write_psk(psk: Psk, path: str):
|
||||
if len(psk.wedges) > MAX_WEDGE_COUNT:
|
||||
raise RuntimeError(f'Number of wedges ({len(psk.wedges)}) exceeds limit of {MAX_WEDGE_COUNT}')
|
||||
if len(psk.points) > 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:
|
||||
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:
|
||||
_write_section(fp, b'ACTRHEAD')
|
||||
_write_section(fp, b'PNTS0000', Vector3, psk.points)
|
||||
|
||||
wedges = []
|
||||
for index, w in enumerate(psk.wedges):
|
||||
wedge = Psk.Wedge16()
|
||||
wedge.material_index = w.material_index
|
||||
wedge.u = w.u
|
||||
wedge.v = w.v
|
||||
wedge.point_index = w.point_index
|
||||
wedges.append(wedge)
|
||||
|
||||
_write_section(fp, b'VTXW0000', Psk.Wedge16, wedges)
|
||||
_write_section(fp, b'FACE0000', Psk.Face, psk.faces)
|
||||
_write_section(fp, b'MATT0000', Psk.Material, psk.materials)
|
||||
_write_section(fp, b'REFSKELT', Psk.Bone, psk.bones)
|
||||
_write_section(fp, b'RAWWEIGHTS', Psk.Weight, psk.weights)
|
||||
@@ -2,7 +2,7 @@ from bpy.props import StringProperty, IntProperty, BoolProperty, FloatProperty
|
||||
from bpy.types import PropertyGroup, UIList, UILayout, Context, AnyType, Panel
|
||||
|
||||
|
||||
class PSX_UL_BoneGroupList(UIList):
|
||||
class PSX_UL_bone_group_list(UIList):
|
||||
|
||||
def draw_item(self, context: Context, layout: UILayout, data: AnyType, item: AnyType, icon: int,
|
||||
active_data: AnyType, active_property: str, index: int = 0, flt_flag: int = 0):
|
||||
@@ -11,20 +11,20 @@ class PSX_UL_BoneGroupList(UIList):
|
||||
row.label(text=str(getattr(item, 'count')), icon='BONE_DATA')
|
||||
|
||||
|
||||
class BoneGroupListItem(PropertyGroup):
|
||||
class PSX_PG_bone_group_list_item(PropertyGroup):
|
||||
name: StringProperty()
|
||||
index: IntProperty()
|
||||
count: IntProperty()
|
||||
is_selected: BoolProperty(default=False)
|
||||
|
||||
|
||||
class PSX_PG_ActionExportPropertyGroup(PropertyGroup):
|
||||
class PSX_PG_action_export(PropertyGroup):
|
||||
compression_ratio: FloatProperty(name='Compression Ratio', default=1.0, min=0.0, max=1.0, subtype='FACTOR', description='The key sampling ratio of the exported sequence.\n\nA compression ratio of 1.0 will export all frames, while a compression ratio of 0.5 will export half of the frames')
|
||||
key_quota: IntProperty(name='Key Quota', default=0, min=1, description='The minimum number of frames to be exported')
|
||||
|
||||
|
||||
class PSX_PT_ActionPropertyPanel(Panel):
|
||||
bl_idname = 'PSX_PT_ActionPropertyPanel'
|
||||
class PSX_PT_action(Panel):
|
||||
bl_idname = 'PSX_PT_action'
|
||||
bl_label = 'PSA Export'
|
||||
bl_space_type = 'DOPESHEET_EDITOR'
|
||||
bl_region_type = 'UI'
|
||||
@@ -43,8 +43,8 @@ class PSX_PT_ActionPropertyPanel(Panel):
|
||||
|
||||
|
||||
classes = (
|
||||
PSX_PG_ActionExportPropertyGroup,
|
||||
BoneGroupListItem,
|
||||
PSX_UL_BoneGroupList,
|
||||
PSX_PT_ActionPropertyPanel
|
||||
PSX_PG_action_export,
|
||||
PSX_PG_bone_group_list_item,
|
||||
PSX_UL_bone_group_list,
|
||||
PSX_PT_action
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user