Compare commits

...

15 Commits

Author SHA1 Message Date
Colin Basnett
bc98c26793 Initial commit for UE1 exec command export 2023-08-25 16:57:26 -07:00
Colin Basnett
3de1f075dd Minor clean-up 2023-08-25 16:56:59 -07:00
Colin Basnett
560ec8fecd Fixed a bug where exported PSKs would always use the mesh data's material instead of the object's material 2023-08-18 18:22:16 -07:00
Colin Basnett
8c74987f5b Added human readable errors when bone, material or sequence names cannot be encoded into the Windows-1252 code page 2023-08-18 18:20:29 -07:00
Colin Basnett
60c7f2125a Fixed a bug with the previous commit. 2023-08-16 15:52:28 -07:00
Colin Basnett
07ccc8c650 Added sequence reversing functionality for timeline markers 2023-08-16 12:00:26 -07:00
Colin Basnett
c4c00ca49e Fixed a bug where exporting animations from NLA strip markers would result in corrupted animations 2023-08-15 15:26:44 -07:00
Colin Basnett
b6e5a13e5f Fuxed a bug where it was not possible to export from markers
Stupid typo, thanks Python
2023-08-15 15:16:07 -07:00
Colin Basnett
d823af9526 Merge branch 'master' into feature-nla-track-sequence-5x
# Conflicts:
#	io_scene_psk_psa/psa/export/operators.py
2023-08-14 23:18:01 -07:00
Colin Basnett
741357d0af Incremented version to 5.0.3 2023-08-14 22:43:26 -07:00
Colin Basnett
fb2ab89766 Fixed a compatibility issue caused by the fix to #43 2023-08-14 22:42:59 -07:00
Colin Basnett
b20d19d072 Added NLA track sequence source option (reimplementation of feature-nla-track-sequence-source branch).
This just needs a bit of time in the oven to test it out.
2023-08-11 02:09:16 -07:00
Colin Basnett
d0d6deb63c Changed name of faces that couldn't be added to "invalid" faces instead of "degenerate" faces
This is because a face can still be rejected by being a duplicate, but it doesn't necessarily have to be degenerate (i.e., a line or point).
2023-08-10 23:38:56 -07:00
Colin Basnett
e9b09dc651 Incremented version to 5.0.2 2023-08-09 21:03:49 -07:00
Colin Basnett
ed89e78927 Fix #43: Wedges can now address vertices with indices greater than 65535 2023-08-09 20:14:24 -07:00
12 changed files with 216 additions and 106 deletions

View File

@@ -1,7 +1,7 @@
bl_info = { bl_info = {
"name": "PSK/PSA Importer/Exporter", "name": "PSK/PSA Importer/Exporter",
"author": "Colin Basnett, Yurii Ti", "author": "Colin Basnett, Yurii Ti",
"version": (5, 0, 1), "version": (5, 0, 3),
"blender": (3, 4, 0), "blender": (3, 4, 0),
"description": "PSK/PSA Import/Export (.psk/.psa)", "description": "PSK/PSA Import/Export (.psk/.psa)",
"warning": "", "warning": "",

View File

@@ -1,4 +1,3 @@
import datetime
import re import re
import typing import typing
from collections import Counter from collections import Counter
@@ -9,24 +8,6 @@ import bpy.types
from bpy.types import NlaStrip, Object, AnimData from bpy.types import NlaStrip, Object, AnimData
class Timer:
def __enter__(self):
self.start = datetime.datetime.now()
self.interval = None
return self
def __exit__(self, *args):
self.end = datetime.datetime.now()
self.interval = self.end - self.start
@property
def duration(self):
if self.interval is not None:
return self.interval
else:
return datetime.datetime.now() - self.start
def rgb_to_srgb(c: float): def rgb_to_srgb(c: float):
if c > 0.0031308: if c > 0.0031308:
return 1.055 * (pow(c, (1.0 / 2.4))) - 0.055 return 1.055 * (pow(c, (1.0 / 2.4))) - 0.055
@@ -34,7 +15,7 @@ def rgb_to_srgb(c: float):
return 12.92 * c return 12.92 * c
def get_nla_strips_in_timeframe(animation_data: AnimData, frame_min: float, frame_max: float) -> List[NlaStrip]: def get_nla_strips_in_frame_range(animation_data: AnimData, frame_min: float, frame_max: float) -> List[NlaStrip]:
if animation_data is None: if animation_data is None:
return [] return []
strips = [] strips = []
@@ -143,7 +124,7 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_g
# Split out the bone indices and the instigator bone names into separate lists. # Split out the bone indices and the instigator bone names into separate lists.
# We use the bone names for the return values because the bone name is a more universal way of referencing them. # We use the bone names for the return values because the bone name is a more universal way of referencing them.
# For example, users of this function may modify bone lists, which would invalidate the indices and require a # For example, users of this function may modify bone lists, which would invalidate the indices and require an
# index mapping scheme to resolve it. Using strings is more comfy and results in less code downstream. # index mapping scheme to resolve it. Using strings is more comfy and results in less code downstream.
instigator_bone_names = [bones[x[1]].name if x[1] is not None else None for x in bone_indices] instigator_bone_names = [bones[x[1]].name if x[1] is not None else None for x in bone_indices]
bone_names = [bones[x[0]].name for x in bone_indices] bone_names = [bones[x[0]].name for x in bone_indices]

View File

@@ -31,6 +31,7 @@ class PsaBuildOptions:
self.sequence_name_prefix: str = '' self.sequence_name_prefix: str = ''
self.sequence_name_suffix: str = '' self.sequence_name_suffix: str = ''
self.root_motion: bool = False self.root_motion: bool = False
self.unreal_engine_1_mode: 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):
@@ -91,7 +92,11 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
# Build list of PSA bones. # Build list of PSA bones.
for bone in bones: for bone in bones:
psa_bone = Psa.Bone() psa_bone = Psa.Bone()
psa_bone.name = bytes(bone.name, encoding='windows-1252')
try:
psa_bone.name = bytes(bone.name, encoding='windows-1252')
except UnicodeEncodeError:
raise RuntimeError(f'Bone name "{bone.name}" contains characters that cannot be encoded in the Windows-1252 codepage')
try: try:
parent_index = bones.index(bone.parent) parent_index = bones.index(bone.parent)
@@ -165,7 +170,10 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
frame_step = -frame_step frame_step = -frame_step
psa_sequence = Psa.Sequence() psa_sequence = Psa.Sequence()
psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252') try:
psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252')
except UnicodeEncodeError:
raise RuntimeError(f'Sequence name "{export_sequence.name}" contains characters that cannot be encoded in the Windows-1252 codepage')
psa_sequence.frame_count = frame_count psa_sequence.frame_count = frame_count
psa_sequence.frame_start_index = frame_start_index psa_sequence.frame_start_index = frame_start_index
psa_sequence.fps = frame_count / sequence_duration psa_sequence.fps = frame_count / sequence_duration

View File

@@ -1,3 +1,4 @@
import os.path
import re import re
from collections import Counter from collections import Counter
from typing import List, Iterable, Dict, Tuple from typing import List, Iterable, Dict, Tuple
@@ -8,10 +9,10 @@ from bpy.types import Context, Armature, Action, Object, AnimData, TimelineMarke
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from bpy_types import Operator from bpy_types import Operator
from io_scene_psk_psa.helpers import populate_bone_group_list, get_nla_strips_in_timeframe from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences
from io_scene_psk_psa.psa.builder import build_psa, PsaBuildSequence, PsaBuildOptions from ..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 ..writer import write_psa, write_psa_import_commands
from io_scene_psk_psa.psa.writer import write_psa from ...helpers import populate_bone_group_list, get_nla_strips_in_frame_range
def is_action_for_armature(armature: Armature, action: Action): def is_action_for_armature(armature: Armature, action: Action):
@@ -80,12 +81,14 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature):
continue continue
if marker_name.startswith('#'): if marker_name.startswith('#'):
continue continue
item = pg.marker_list.add()
item.name = marker_name
item.is_selected = False
frame_start, frame_end = sequence_frame_ranges[marker_name] frame_start, frame_end = sequence_frame_ranges[marker_name]
item.frame_start = frame_start sequences = get_sequences_from_name_and_frame_range(marker_name, frame_start, frame_end)
item.frame_end = frame_end for (sequence_name, frame_start, frame_end) in sequences:
item = pg.marker_list.add()
item.name = sequence_name
item.is_selected = False
item.frame_start = frame_start
item.frame_end = frame_end
def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float: def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float:
@@ -150,7 +153,7 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context:
if next_marker_index < len(sorted_timeline_markers): 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. # 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 frame_end = sorted_timeline_markers[next_marker_index].frame
nla_strips = get_nla_strips_in_timeframe(animation_data, marker.frame, frame_end) nla_strips = get_nla_strips_in_frame_range(animation_data, marker.frame, frame_end)
if len(nla_strips) > 0: if len(nla_strips) > 0:
frame_end = min(frame_end, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips))) 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))) frame_start = max(frame_start, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips)))
@@ -174,11 +177,9 @@ def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context:
return sequence_frame_ranges return sequence_frame_ranges
def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]: def get_sequences_from_name_and_frame_range(name: str, frame_start: int, frame_end: int) -> List[Tuple[str, int, int]]:
frame_start = int(action.frame_range[0])
frame_end = int(action.frame_range[1])
reversed_pattern = r'(.+)/(.+)' reversed_pattern = r'(.+)/(.+)'
reversed_match = re.match(reversed_pattern, action.name) reversed_match = re.match(reversed_pattern, name)
if reversed_match: if reversed_match:
forward_name = reversed_match.group(1) forward_name = reversed_match.group(1)
backwards_name = reversed_match.group(2) backwards_name = reversed_match.group(2)
@@ -187,7 +188,13 @@ def get_sequences_from_action(action: Action) -> List[Tuple[str, int, int]]:
(backwards_name, frame_end, frame_start) (backwards_name, frame_end, frame_start)
] ]
else: else:
return [(action.name, frame_start, frame_end)] return [(name, frame_start, frame_end)]
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])
return get_sequences_from_name_and_frame_range(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]]: 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]]:
@@ -196,17 +203,7 @@ def get_sequences_from_action_pose_marker(action: Action, pose_markers: List[Tim
frame_end = pose_markers[pose_marker_index + 1].frame frame_end = pose_markers[pose_marker_index + 1].frame
else: else:
frame_end = int(action.frame_range[1]) frame_end = int(action.frame_range[1])
reversed_pattern = r'(.+)/(.+)' return get_sequences_from_name_and_frame_range(pose_marker.name, frame_start, frame_end)
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]: def get_visible_sequences(pg: PSA_PG_export, sequences) -> List[PSA_PG_export_action_list_item]:
@@ -254,12 +251,18 @@ class PSA_OT_export(Operator, ExportHelper):
# SOURCE # SOURCE
layout.prop(pg, 'sequence_source', text='Source') layout.prop(pg, 'sequence_source', text='Source')
if pg.sequence_source == 'TIMELINE_MARKERS': if pg.sequence_source in {'TIMELINE_MARKERS', 'NLA_TRACK_STRIPS'}:
# ANIMDATA SOURCE # ANIMDATA SOURCE
layout.prop(pg, 'should_override_animation_data') layout.prop(pg, 'should_override_animation_data')
if pg.should_override_animation_data: if pg.should_override_animation_data:
layout.prop(pg, 'animation_data_override', text='') layout.prop(pg, 'animation_data_override', text='')
if pg.sequence_source == 'NLA_TRACK_STRIPS':
flow = layout.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'nla_track')
# SELECT ALL/NONE # SELECT ALL/NONE
row = layout.row(align=True) row = layout.row(align=True)
row.label(text='Select') row.label(text='Select')
@@ -269,25 +272,19 @@ class PSA_OT_export(Operator, ExportHelper):
# ACTIONS # ACTIONS
if pg.sequence_source == 'ACTIONS': if pg.sequence_source == 'ACTIONS':
rows = max(3, min(len(pg.action_list), 10)) rows = max(3, min(len(pg.action_list), 10))
layout.template_list('PSA_UL_export_sequences', '', 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
col.use_property_decorate = False
col.prop(pg, 'sequence_name_prefix')
col.prop(pg, 'sequence_name_suffix')
elif pg.sequence_source == 'TIMELINE_MARKERS': elif pg.sequence_source == 'TIMELINE_MARKERS':
rows = max(3, min(len(pg.marker_list), 10)) rows = max(3, min(len(pg.marker_list), 10))
layout.template_list('PSA_UL_export_sequences', '', pg, 'marker_list', pg, 'marker_list_index', layout.template_list('PSA_UL_export_sequences', '', pg, 'marker_list', pg, 'marker_list_index', rows=rows)
rows=rows) elif pg.sequence_source == 'NLA_TRACK_STRIPS':
rows = max(3, min(len(pg.nla_strip_list), 10))
layout.template_list('PSA_UL_export_sequences', '', pg, 'nla_strip_list', pg, 'nla_strip_list_index', rows=rows)
col = layout.column() col = layout.column()
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'sequence_name_prefix') col.prop(pg, 'sequence_name_prefix')
col.prop(pg, 'sequence_name_suffix') col.prop(pg, 'sequence_name_suffix')
# Determine if there is going to be a naming conflict and display an error, if so. # Determine if there is going to be a naming conflict and display an error, if so.
selected_items = [x for x in pg.action_list if x.is_selected] selected_items = [x for x in pg.action_list if x.is_selected]
@@ -317,8 +314,9 @@ class PSA_OT_export(Operator, ExportHelper):
layout.separator() layout.separator()
# ROOT MOTION
layout.prop(pg, 'root_motion', text='Root Motion') layout.prop(pg, 'root_motion', text='Root Motion')
layout.prop(pg, 'should_write_import_commands', text='Write Import Commands')
@classmethod @classmethod
def _check_context(cls, context): def _check_context(cls, context):
@@ -358,8 +356,10 @@ class PSA_OT_export(Operator, ExportHelper):
# Ensure that we actually have items that we are going to be exporting. # Ensure that we actually have items that we are going to be exporting.
if pg.sequence_source == 'ACTIONS' and len(pg.action_list) == 0: if pg.sequence_source == 'ACTIONS' and len(pg.action_list) == 0:
raise RuntimeError('No actions were selected for export') raise RuntimeError('No actions were selected for export')
elif pg.sequence_source == 'TIMELINE_MARKERS' and len(pg.marker_names) == 0: elif pg.sequence_source == 'TIMELINE_MARKERS' and len(pg.marker_list) == 0:
raise RuntimeError('No timeline markers were selected for export') raise RuntimeError('No timeline markers were selected for export')
elif pg.sequence_source == 'NLA_TRACK_STRIPS' and len(pg.nla_strip_list) == 0:
raise RuntimeError('No NLA track strips were selected for export')
# Populate the export sequence list. # Populate the export sequence list.
animation_data_object = get_animation_data_object(context) animation_data_object = get_animation_data_object(context)
@@ -371,29 +371,38 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequences: List[PsaBuildSequence] = [] export_sequences: List[PsaBuildSequence] = []
if pg.sequence_source == 'ACTIONS': if pg.sequence_source == 'ACTIONS':
for action in filter(lambda x: x.is_selected, pg.action_list): for action_item in filter(lambda x: x.is_selected, pg.action_list):
if len(action.action.fcurves) == 0: if len(action_item.action.fcurves) == 0:
continue continue
export_sequence = PsaBuildSequence() export_sequence = PsaBuildSequence()
export_sequence.nla_state.action = action.action export_sequence.nla_state.action = action_item.action
export_sequence.name = action.name export_sequence.name = action_item.name
export_sequence.nla_state.frame_start = action.frame_start export_sequence.nla_state.frame_start = action_item.frame_start
export_sequence.nla_state.frame_end = action.frame_end export_sequence.nla_state.frame_end = action_item.frame_end
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action.action]) export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action_item.action])
export_sequence.compression_ratio = action.action.psa_export.compression_ratio export_sequence.compression_ratio = action_item.action.psa_export.compression_ratio
export_sequence.key_quota = action.action.psa_export.key_quota export_sequence.key_quota = action_item.action.psa_export.key_quota
export_sequences.append(export_sequence) export_sequences.append(export_sequence)
elif pg.sequence_source == 'TIMELINE_MARKERS': elif pg.sequence_source == 'TIMELINE_MARKERS':
for marker in pg.marker_list: for marker_item in filter(lambda x: x.is_selected, pg.marker_list):
export_sequence = PsaBuildSequence() export_sequence = PsaBuildSequence()
export_sequence.name = marker.name export_sequence.name = marker_item.name
export_sequence.nla_state.action = None export_sequence.nla_state.action = None
export_sequence.nla_state.frame_start = marker.frame_start export_sequence.nla_state.frame_start = marker_item.frame_start
export_sequence.nla_state.frame_end = marker.frame_end export_sequence.nla_state.frame_end = marker_item.frame_end
nla_strips_actions = set( nla_strips_actions = set(
map(lambda x: x.action, get_nla_strips_in_timeframe(animation_data, marker.frame_start, marker.frame_end))) map(lambda x: x.action, get_nla_strips_in_frame_range(animation_data, marker_item.frame_start, marker_item.frame_end)))
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, nla_strips_actions) export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, nla_strips_actions)
export_sequences.append(export_sequence) export_sequences.append(export_sequence)
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list):
export_sequence = PsaBuildSequence()
export_sequence.name = nla_strip_item.name
export_sequence.nla_state.action = None
export_sequence.nla_state.frame_start = nla_strip_item.frame_start
export_sequence.nla_state.frame_end = nla_strip_item.frame_end
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [nla_strip_item.action])
export_sequences.append(export_sequence)
else: else:
raise ValueError(f'Unhandled sequence source: {pg.sequence_source}') raise ValueError(f'Unhandled sequence source: {pg.sequence_source}')
@@ -416,6 +425,12 @@ class PSA_OT_export(Operator, ExportHelper):
write_psa(psa, self.filepath) write_psa(psa, self.filepath)
if pg.should_write_import_commands:
# Replace the extension in the file path to be ".uc" instead of ".psa".
# This is because the Unreal Engine 1 import command expects the file to have a ".uc" extension.
filepath = os.path.splitext(self.filepath)[0] + '.uc'
write_psa_import_commands(psa, filepath)
return {'FINISHED'} return {'FINISHED'}
@@ -432,6 +447,8 @@ class PSA_OT_export_actions_select_all(Operator):
return pg.action_list return pg.action_list
elif pg.sequence_source == 'TIMELINE_MARKERS': elif pg.sequence_source == 'TIMELINE_MARKERS':
return pg.marker_list return pg.marker_list
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
return pg.nla_strip_list
return None return None
@classmethod @classmethod
@@ -463,6 +480,8 @@ class PSA_OT_export_actions_deselect_all(Operator):
return pg.action_list return pg.action_list
elif pg.sequence_source == 'TIMELINE_MARKERS': elif pg.sequence_source == 'TIMELINE_MARKERS':
return pg.marker_list return pg.marker_list
elif pg.sequence_source == 'NLA_TRACK_STRIPS':
return pg.nla_strip_list
return None return None
@classmethod @classmethod

View File

@@ -1,10 +1,11 @@
import re
import sys import sys
from fnmatch import fnmatch from fnmatch import fnmatch
from typing import List from typing import List, Optional
from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \ from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty, CollectionProperty, IntProperty, \
StringProperty StringProperty
from bpy.types import PropertyGroup, Object, Action from bpy.types import PropertyGroup, Object, Action, AnimData, Context
from ...types import PSX_PG_bone_group_list_item from ...types import PSX_PG_bone_group_list_item
@@ -25,7 +26,7 @@ class PSA_PG_export_action_list_item(PropertyGroup):
is_pose_marker: BoolProperty(options={'HIDDEN'}) is_pose_marker: BoolProperty(options={'HIDDEN'})
class PSA_PG_export_timeline_markers(PropertyGroup): class PSA_PG_export_timeline_markers(PropertyGroup): # TODO: rename this to singular
marker_index: IntProperty() marker_index: IntProperty()
name: StringProperty() name: StringProperty()
is_selected: BoolProperty(default=True) is_selected: BoolProperty(default=True)
@@ -33,6 +34,51 @@ class PSA_PG_export_timeline_markers(PropertyGroup):
frame_end: IntProperty(options={'HIDDEN'}) frame_end: IntProperty(options={'HIDDEN'})
class PSA_PG_export_nla_strip_list_item(PropertyGroup):
name: StringProperty()
action: PointerProperty(type=Action)
frame_start: FloatProperty()
frame_end: FloatProperty()
is_selected: BoolProperty(default=True)
def nla_track_update_cb(self: 'PSA_PG_export', context: Context) -> None:
self.nla_strip_list.clear()
if context.object is None or context.object.animation_data is None:
return
match = re.match(r'^(\d+).+$', self.nla_track)
self.nla_track_index = int(match.group(1)) if match else -1
if self.nla_track_index >= 0:
nla_track = context.object.animation_data.nla_tracks[self.nla_track_index]
for nla_strip in nla_track.strips:
strip: PSA_PG_export_nla_strip_list_item = self.nla_strip_list.add()
strip.action = nla_strip.action
strip.name = nla_strip.name
strip.frame_start = nla_strip.frame_start
strip.frame_end = nla_strip.frame_end
def get_animation_data(pg: 'PSA_PG_export', context: Context) -> Optional[AnimData]:
animation_data_object = context.object
if pg.should_override_animation_data:
animation_data_object = pg.animation_data_override
return animation_data_object.animation_data if animation_data_object else None
def nla_track_search_cb(self, context: Context, edit_text: str):
pg = getattr(context.scene, 'psa_export')
animation_data = get_animation_data(pg, context)
if animation_data is None:
return
for index, nla_track in enumerate(animation_data.nla_tracks):
yield f'{index} - {nla_track.name}'
def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context):
# Reset NLA track selection
self.nla_track = ''
class PSA_PG_export(PropertyGroup): class PSA_PG_export(PropertyGroup):
root_motion: BoolProperty( root_motion: BoolProperty(
name='Root Motion', name='Root Motion',
@@ -46,10 +92,12 @@ class PSA_PG_export(PropertyGroup):
name='Override Animation Data', name='Override Animation Data',
options=empty_set, options=empty_set,
default=False, default=False,
description='Use the animation data from a different object instead of the selected object' description='Use the animation data from a different object instead of the selected object',
update=animation_data_override_update_cb,
) )
animation_data_override: PointerProperty( animation_data_override: PointerProperty(
type=Object, type=Object,
update=animation_data_override_update_cb,
poll=psa_export_property_group_animation_data_override_poll poll=psa_export_property_group_animation_data_override_poll
) )
sequence_source: EnumProperty( sequence_source: EnumProperty(
@@ -58,10 +106,18 @@ class PSA_PG_export(PropertyGroup):
description='', description='',
items=( items=(
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0), ('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers', 'MARKER_HLT', ('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences are delineated by scene timeline markers', 'MARKER_HLT', 1),
1), ('NLA_TRACK_STRIPS', 'NLA Track Strips', 'Sequences are delineated by the start & end times of strips on the selected NLA track', 'NLA', 2)
) )
) )
nla_track: StringProperty(
name='NLA Track',
options=empty_set,
description='',
search=nla_track_search_cb,
update=nla_track_update_cb
)
nla_track_index: IntProperty(name='NLA Track Index', default=-1)
fps_source: EnumProperty( fps_source: EnumProperty(
name='FPS Source', name='FPS Source',
options=empty_set, options=empty_set,
@@ -80,6 +136,8 @@ class PSA_PG_export(PropertyGroup):
action_list_index: IntProperty(default=0) action_list_index: IntProperty(default=0)
marker_list: CollectionProperty(type=PSA_PG_export_timeline_markers) marker_list: CollectionProperty(type=PSA_PG_export_timeline_markers)
marker_list_index: IntProperty(default=0) marker_list_index: IntProperty(default=0)
nla_strip_list: CollectionProperty(type=PSA_PG_export_nla_strip_list_item)
nla_strip_list_index: IntProperty(default=0)
bone_filter_mode: EnumProperty( bone_filter_mode: EnumProperty(
name='Bone Filter', name='Bone Filter',
options=empty_set, options=empty_set,
@@ -126,6 +184,12 @@ class PSA_PG_export(PropertyGroup):
name='Show Reversed', name='Show Reversed',
description='Show reversed sequences' description='Show reversed sequences'
) )
should_write_import_commands: BoolProperty(
default=True,
options=empty_set,
name='Write PSA Import Commands',
description='Write PSA import commands to a UnrealScript file in the same directory as the exported file'
)
def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]: def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:
@@ -145,7 +209,7 @@ def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:
if not pg.sequence_filter_asset: if not pg.sequence_filter_asset:
for i, sequence in enumerate(sequences): for i, sequence in enumerate(sequences):
if hasattr(sequence, 'action') and sequence.action.asset_data is not None: if hasattr(sequence, 'action') and sequence.action is not None and sequence.action.asset_data is not None:
flt_flags[i] &= ~bitflag_filter_item flt_flags[i] &= ~bitflag_filter_item
if not pg.sequence_filter_pose_marker: if not pg.sequence_filter_pose_marker:
@@ -164,5 +228,6 @@ def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:
classes = ( classes = (
PSA_PG_export_action_list_item, PSA_PG_export_action_list_item,
PSA_PG_export_timeline_markers, PSA_PG_export_timeline_markers,
PSA_PG_export_nla_strip_list_item,
PSA_PG_export, PSA_PG_export,
) )

View File

@@ -16,7 +16,7 @@ class PSA_UL_export_sequences(UIList):
item = cast(PSA_PG_export_action_list_item, item) item = cast(PSA_PG_export_action_list_item, item)
is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker
layout.prop(item, 'is_selected', icon_only=True, text=item.name) layout.prop(item, 'is_selected', icon_only=True, text=item.name)
if hasattr(item, 'action') and item.action.asset_data is not None: if hasattr(item, 'action') and item.action is not None and item.action.asset_data is not None:
layout.label(text='', icon='ASSET_MANAGER') layout.label(text='', icon='ASSET_MANAGER')
row = layout.row(align=True) row = layout.row(align=True)

View File

@@ -1,3 +1,4 @@
import os.path
from ctypes import Structure, sizeof from ctypes import Structure, sizeof
from typing import Type from typing import Type
@@ -23,3 +24,15 @@ def write_psa(psa: Psa, path: str):
write_section(fp, b'BONENAMES', Psa.Bone, psa.bones) write_section(fp, b'BONENAMES', Psa.Bone, psa.bones)
write_section(fp, b'ANIMINFO', Psa.Sequence, list(psa.sequences.values())) write_section(fp, b'ANIMINFO', Psa.Sequence, list(psa.sequences.values()))
write_section(fp, b'ANIMKEYS', Psa.Key, psa.keys) write_section(fp, b'ANIMKEYS', Psa.Key, psa.keys)
def write_psa_import_commands(psa: Psa, path: str):
anim = os.path.splitext(os.path.basename(path))[0]
with open(path, 'w') as fp:
for sequence_name, sequence in psa.sequences.items():
fp.write(f'#EXEC ANIM SEQUENCE '
f'ANIM={anim} '
f'SEQ={sequence_name} '
f'STARTFRAME={sequence.frame_start_index} '
f'NUMFRAMES={sequence.frame_count} '
f'RATE={sequence.fps}\n')

View File

@@ -88,7 +88,11 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
for bone in bones: for bone in bones:
psk_bone = Psk.Bone() psk_bone = Psk.Bone()
psk_bone.name = bytes(bone.name, encoding='windows-1252') try:
psk_bone.name = bytes(bone.name, encoding='windows-1252')
except UnicodeEncodeError:
raise RuntimeError(
f'Bone name "{bone.name}" contains characters that cannot be encoded in the Windows-1252 codepage')
psk_bone.flags = 0 psk_bone.flags = 0
psk_bone.children_count = 0 psk_bone.children_count = 0
@@ -129,14 +133,17 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
for material_name in material_names: for material_name in material_names:
psk_material = Psk.Material() psk_material = Psk.Material()
psk_material.name = bytes(material_name, encoding='windows-1252') try:
psk_material.name = bytes(material_name, encoding='windows-1252')
except UnicodeEncodeError:
raise RuntimeError(f'Material name "{material_name}" contains characters that cannot be encoded in the Windows-1252 codepage')
psk_material.texture_index = len(psk.materials) psk_material.texture_index = len(psk.materials)
psk.materials.append(psk_material) psk.materials.append(psk_material)
for input_mesh_object in input_objects.mesh_objects: for input_mesh_object in input_objects.mesh_objects:
# MATERIALS # MATERIALS
material_indices = [material_names.index(material.name) for material in input_mesh_object.data.materials] material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots]
# MESH DATA # MESH DATA
if options.use_raw_mesh_data: if options.use_raw_mesh_data:

View File

@@ -16,8 +16,7 @@ class Psk(object):
class Wedge16(Structure): class Wedge16(Structure):
_fields_ = [ _fields_ = [
('point_index', c_uint16), ('point_index', c_uint32),
('padding1', c_int16),
('u', c_float), ('u', c_float),
('v', c_float), ('v', c_float),
('material_index', c_uint8), ('material_index', c_uint8),

View File

@@ -22,10 +22,11 @@ def populate_material_list(mesh_objects, material_list):
material_names = [] material_names = []
for mesh_object in mesh_objects: for mesh_object in mesh_objects:
for i, material in enumerate(mesh_object.data.materials): for i, material_slot in enumerate(mesh_object.material_slots):
material = material_slot.material
# TODO: put this in the poll arg? # TODO: put this in the poll arg?
if material is None: if material is None:
raise RuntimeError('Material cannot be empty (index ' + str(i) + ')') raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')')
if material.name not in material_names: if material.name not in material_names:
material_names.append(material.name) material_names.append(material.name)

View File

@@ -131,7 +131,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
# Material already exists, just re-use it. # Material already exists, just re-use it.
material = bpy.data.materials[material_name] material = bpy.data.materials[material_name]
elif is_bdk_addon_loaded() and psk.has_material_references: elif is_bdk_addon_loaded() and psk.has_material_references:
# Material does not yet exist and we have the BDK addon installed. # Material does not yet exist, and we have the BDK addon installed.
# Attempt to load it using BDK addon's operator. # Attempt to load it using BDK addon's operator.
material_reference = psk.material_references[material_index] material_reference = psk.material_references[material_index]
if material_reference and bpy.ops.bdk.link_material(reference=material_reference) == {'FINISHED'}: if material_reference and bpy.ops.bdk.link_material(reference=material_reference) == {'FINISHED'}:
@@ -150,17 +150,22 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
bm.verts.ensure_lookup_table() bm.verts.ensure_lookup_table()
degenerate_face_indices = set() invalid_face_indices = set()
for face_index, face in enumerate(psk.faces): for face_index, face in enumerate(psk.faces):
point_indices = [bm.verts[psk.wedges[i].point_index] for i in reversed(face.wedge_indices)] point_indices = map(lambda i: psk.wedges[i].point_index, reversed(face.wedge_indices))
points = [bm.verts[i] for i in point_indices]
try: try:
bm_face = bm.faces.new(point_indices) bm_face = bm.faces.new(points)
bm_face.material_index = face.material_index bm_face.material_index = face.material_index
except ValueError: except ValueError:
degenerate_face_indices.add(face_index) # This happens for two reasons:
# 1. Two or more of the face's points are the same. (i.e, point indices of [0, 0, 1])
# 2. The face is a duplicate of another face. (i.e., point indices of [0, 1, 2] and [0, 1, 2])
invalid_face_indices.add(face_index)
if len(degenerate_face_indices) > 0: # TODO: Handle invalid faces better.
result.warnings.append(f'Discarded {len(degenerate_face_indices)} degenerate face(s).') if len(invalid_face_indices) > 0:
result.warnings.append(f'Discarded {len(invalid_face_indices)} invalid face(s).')
bm.to_mesh(mesh_data) bm.to_mesh(mesh_data)
@@ -168,7 +173,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
data_index = 0 data_index = 0
uv_layer = mesh_data.uv_layers.new(name='VTXW0000') uv_layer = mesh_data.uv_layers.new(name='VTXW0000')
for face_index, face in enumerate(psk.faces): for face_index, face in enumerate(psk.faces):
if face_index in degenerate_face_indices: if face_index in invalid_face_indices:
continue continue
face_wedges = [psk.wedges[i] for i in reversed(face.wedge_indices)] face_wedges = [psk.wedges[i] for i in reversed(face.wedge_indices)]
for wedge in face_wedges: for wedge in face_wedges:
@@ -183,7 +188,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
data_index = 0 data_index = 0
uv_layer = mesh_data.uv_layers.new(name=f'EXTRAUV{extra_uv_index}') uv_layer = mesh_data.uv_layers.new(name=f'EXTRAUV{extra_uv_index}')
for face_index, face in enumerate(psk.faces): for face_index, face in enumerate(psk.faces):
if face_index in degenerate_face_indices: if face_index in invalid_face_indices:
continue continue
for wedge_index in reversed(face.wedge_indices): for wedge_index in reversed(face.wedge_indices):
u, v = psk.extra_uvs[wedge_index_offset + wedge_index] u, v = psk.extra_uvs[wedge_index_offset + wedge_index]

View File

@@ -78,4 +78,16 @@ def read_psk(path: str) -> Psk:
''' '''
psk.material_references = _read_material_references(path) psk.material_references = _read_material_references(path)
'''
Tools like UEViewer and CUE4Parse write the point index as a 32-bit integer, exploiting the fact that due to struct
alignment, there were 16-bits of padding following the original 16-bit point index in the wedge struct.
However, this breaks compatibility with PSK files that were created with older tools that treated the
point index as a 16-bit integer and might have junk data written to the padding bits.
To work around this, we check if each point is still addressable using a 16-bit index, and if it is, assume the
point index is a 16-bit integer and truncate the high bits.
'''
if len(psk.points) <= 65536:
for wedge in psk.wedges:
wedge.point_index &= 0xFFFF
return psk return psk