Compare commits

..

5 Commits

Author SHA1 Message Date
Colin Basnett
37f7cc4d9f Increment version to 8.2.4 2025-11-08 18:28:21 -08:00
Colin Basnett
93083f09f8 Fix #135: Extra UV maps have incorrect data
This was caused by a regression caused by 29831d7f09.

The test for importing extra UVs has been updated to check that the data is different between the different UV layers.
2025-11-08 18:27:40 -08:00
Colin Basnett
75660f9dc1 Incremented version to 8.2.3 2025-10-31 12:43:09 -07:00
Colin Basnett
5421ac5151 Removed debugging code 2025-10-31 12:42:14 -07:00
Colin Basnett
9dcbb74058 Fix for missing transform source and broken scale controls on PSK export dialog 2025-10-31 12:41:14 -07:00
11 changed files with 56 additions and 76 deletions

View File

@@ -1,6 +1,6 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "io_scene_psk_psa" id = "io_scene_psk_psa"
version = "8.2.2" version = "8.2.4"
name = "Unreal PSK/PSA (.psk/.psa)" name = "Unreal PSK/PSA (.psk/.psa)"
tagline = "Import and export PSK and PSA files used in Unreal Engine" tagline = "Import and export PSK and PSA files used in Unreal Engine"
maintainer = "Colin Basnett <cmbasnett@gmail.com>" maintainer = "Colin Basnett <cmbasnett@gmail.com>"

View File

@@ -1,10 +1,10 @@
from bpy.types import Action, AnimData, Context, Object, PoseBone from bpy.types import Action, AnimData, Context, Object, PoseBone
from .data import Psa from .data import Psa
from typing import Dict, List, Optional, Tuple, Iterable from typing import Dict, List, Optional, Tuple
from mathutils import Matrix, Quaternion, Vector from mathutils import Matrix, Quaternion, Vector
from ..shared.helpers import PsxBoneCollection, create_psx_bones, get_coordinate_system_transform from ..shared.helpers import create_psx_bones, get_coordinate_system_transform
class PsaBuildSequence: class PsaBuildSequence:
@@ -14,8 +14,8 @@ class PsaBuildSequence:
self.frame_start: int = 0 self.frame_start: int = 0
self.frame_end: int = 0 self.frame_end: int = 0
def __init__(self, armature_objects: Iterable[Object], anim_data: AnimData): def __init__(self, armature_object: Object, anim_data: AnimData):
self.armature_objects = list(armature_objects) self.armature_object = armature_object
self.anim_data = anim_data self.anim_data = anim_data
self.name: str = '' self.name: str = ''
self.nla_state: PsaBuildSequence.NlaState = PsaBuildSequence.NlaState() self.nla_state: PsaBuildSequence.NlaState = PsaBuildSequence.NlaState()
@@ -27,9 +27,10 @@ class PsaBuildSequence:
class PsaBuildOptions: class PsaBuildOptions:
def __init__(self): def __init__(self):
self.armature_objects: List[Object] = [] self.armature_objects: List[Object] = []
self.animation_data: Optional[AnimData] = None
self.sequences: List[PsaBuildSequence] = [] self.sequences: List[PsaBuildSequence] = []
self.bone_filter_mode: str = 'ALL' self.bone_filter_mode: str = 'ALL'
self.bone_collection_indices: List[PsxBoneCollection] = [] self.bone_collection_indices: List[PsaBoneCollectionIndex] = []
self.sequence_name_prefix: str = '' self.sequence_name_prefix: str = ''
self.sequence_name_suffix: str = '' self.sequence_name_suffix: str = ''
self.scale = 1.0 self.scale = 1.0
@@ -57,7 +58,7 @@ def _get_pose_bone_location_and_rotation(
if is_false_root_bone: if is_false_root_bone:
pose_bone_matrix = coordinate_system_transform pose_bone_matrix = coordinate_system_transform
elif pose_bone is not None and pose_bone.parent is not None: elif pose_bone.parent is not None:
pose_bone_matrix = pose_bone.matrix pose_bone_matrix = pose_bone.matrix
pose_bone_parent_matrix = pose_bone.parent.matrix pose_bone_parent_matrix = pose_bone.parent.matrix
pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix
@@ -108,7 +109,6 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
psa = Psa() psa = Psa()
# TODO: move this OUT!
armature_objects_for_bones = options.armature_objects armature_objects_for_bones = options.armature_objects
if options.sequence_source == 'ACTIVE_ACTION' and len(options.armature_objects) >= 2: if options.sequence_source == 'ACTIVE_ACTION' and len(options.armature_objects) >= 2:
# Make sure that the data-block for all the selected armature objects is the same. # Make sure that the data-block for all the selected armature objects is the same.
@@ -143,7 +143,7 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
export_sequence.name = export_sequence.name.strip() export_sequence.name = export_sequence.name.strip()
# Save each armature object's current action and frame so that we can restore the state once we are done. # Save each armature object's current action and frame so that we can restore the state once we are done.
saved_armature_object_actions = {o: o.animation_data.action if o.animation_data else None for o in options.armature_objects} saved_armature_object_actions = {o: o.animation_data.action for o in options.armature_objects}
saved_frame_current = context.scene.frame_current saved_frame_current = context.scene.frame_current
# Now build the PSA sequences. # Now build the PSA sequences.
@@ -184,10 +184,11 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
psa_sequence.key_reduction = 1.0 psa_sequence.key_reduction = 1.0
frame = float(frame_start) frame = float(frame_start)
export_sequence.anim_data.action = export_sequence.nla_state.action
assert context.view_layer # Link the action to the animation data and update view layer.
for armature_object in options.armature_objects:
armature_object.animation_data.action = export_sequence.nla_state.action
context.view_layer.update() context.view_layer.update()
def add_key(location: Vector, rotation: Quaternion): def add_key(location: Vector, rotation: Quaternion):
@@ -211,7 +212,7 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
armature_scales: Dict[Object, Vector] = {} armature_scales: Dict[Object, Vector] = {}
# Extract the scale from the world matrix of the evaluated armature object. # Extract the scale from the world matrix of the evaluated armature object.
for armature_object in export_sequence.armature_objects: for armature_object in options.armature_objects:
evaluated_armature_object = armature_object.evaluated_get(context.evaluated_depsgraph_get()) evaluated_armature_object = armature_object.evaluated_get(context.evaluated_depsgraph_get())
_, _, scale = evaluated_armature_object.matrix_world.decompose() _, _, scale = evaluated_armature_object.matrix_world.decompose()
scale *= options.scale scale *= options.scale
@@ -222,7 +223,6 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
# locations. # locations.
export_bones: List[PsaExportBone] = [] export_bones: List[PsaExportBone] = []
# TODO: we need different behavior here if it's ACTIVE_ACTION
for psx_bone, armature_object in psx_bone_create_result.bones: for psx_bone, armature_object in psx_bone_create_result.bones:
if armature_object is None: if armature_object is None:
export_bones.append(PsaExportBone(None, None, Vector((1.0, 1.0, 1.0)))) export_bones.append(PsaExportBone(None, None, Vector((1.0, 1.0, 1.0))))

View File

@@ -472,7 +472,7 @@ class PSA_OT_export(Operator, ExportHelper):
for action_item 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_item.action.fcurves) == 0: if len(action_item.action.fcurves) == 0:
continue continue
export_sequence = PsaBuildSequence(self.armature_objects, animation_data) export_sequence = PsaBuildSequence(context.active_object, animation_data)
export_sequence.name = action_item.name export_sequence.name = action_item.name
export_sequence.nla_state.action = action_item.action export_sequence.nla_state.action = action_item.action
export_sequence.nla_state.frame_start = action_item.frame_start export_sequence.nla_state.frame_start = action_item.frame_start
@@ -483,7 +483,7 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequences.append(export_sequence) export_sequences.append(export_sequence)
case 'TIMELINE_MARKERS': case 'TIMELINE_MARKERS':
for marker_item in filter(lambda x: x.is_selected, pg.marker_list): for marker_item in filter(lambda x: x.is_selected, pg.marker_list):
export_sequence = PsaBuildSequence(self.armature_objects, animation_data) export_sequence = PsaBuildSequence(context.active_object, animation_data)
export_sequence.name = marker_item.name export_sequence.name = marker_item.name
export_sequence.nla_state.frame_start = marker_item.frame_start export_sequence.nla_state.frame_start = marker_item.frame_start
export_sequence.nla_state.frame_end = marker_item.frame_end export_sequence.nla_state.frame_end = marker_item.frame_end
@@ -494,7 +494,7 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequences.append(export_sequence) export_sequences.append(export_sequence)
case 'NLA_TRACK_STRIPS': case 'NLA_TRACK_STRIPS':
for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list): for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list):
export_sequence = PsaBuildSequence(self.armature_objects, animation_data) export_sequence = PsaBuildSequence(context.active_object, animation_data)
export_sequence.name = nla_strip_item.name export_sequence.name = nla_strip_item.name
export_sequence.nla_state.frame_start = nla_strip_item.frame_start export_sequence.nla_state.frame_start = nla_strip_item.frame_start
export_sequence.nla_state.frame_end = nla_strip_item.frame_end export_sequence.nla_state.frame_end = nla_strip_item.frame_end
@@ -504,7 +504,7 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequences.append(export_sequence) export_sequences.append(export_sequence)
case 'ACTIVE_ACTION': case 'ACTIVE_ACTION':
for active_action_item in filter(lambda x: x.is_selected, pg.active_action_list): for active_action_item in filter(lambda x: x.is_selected, pg.active_action_list):
export_sequence = PsaBuildSequence([active_action_item.armature_object], active_action_item.armature_object.animation_data) export_sequence = PsaBuildSequence(active_action_item.armature_object, active_action_item.armature_object.animation_data)
action = active_action_item.action action = active_action_item.action
export_sequence.name = action.name export_sequence.name = action.name
export_sequence.nla_state.action = action export_sequence.nla_state.action = action
@@ -522,6 +522,8 @@ class PSA_OT_export(Operator, ExportHelper):
return {'CANCELLED'} return {'CANCELLED'}
options = PsaBuildOptions() options = PsaBuildOptions()
options.armature_objects = self.armature_objects
options.animation_data = animation_data
options.sequences = export_sequences options.sequences = export_sequences
options.bone_filter_mode = pg.bone_filter_mode options.bone_filter_mode = pg.bone_filter_mode
options.bone_collection_indices = [PsxBoneCollection(x.armature_object_name, x.armature_data_name, x.index) for x in pg.bone_collection_list if x.is_selected] options.bone_collection_indices = [PsxBoneCollection(x.armature_object_name, x.armature_data_name, x.index) for x in pg.bone_collection_list if x.is_selected]

View File

@@ -13,7 +13,7 @@ class PSA_UL_export_sequences(UIList):
# Show the filtering options by default. # Show the filtering options by default.
self.use_filter_show = True self.use_filter_show = True
def draw_item(self, context, layout, data, item, icon, active_data, active_property, index, flt_flag): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
item = typing_cast(PSA_PG_export_action_list_item, item) item = typing_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
@@ -44,7 +44,7 @@ class PSA_UL_export_sequences(UIList):
subrow.prop(pg, 'sequence_filter_pose_marker', icon_only=True, icon='PMARKER') subrow.prop(pg, 'sequence_filter_pose_marker', icon_only=True, icon='PMARKER')
subrow.prop(pg, 'sequence_filter_reversed', text='', icon='FRAME_PREV') subrow.prop(pg, 'sequence_filter_reversed', text='', icon='FRAME_PREV')
def filter_items(self, context, data, property): def filter_items(self, context, data, prop):
pg = getattr(context.scene, 'psa_export') pg = getattr(context.scene, 'psa_export')
actions = getattr(data, prop) actions = getattr(data, prop)
flt_flags = filter_sequences(pg, actions) flt_flags = filter_sequences(pg, actions)

View File

@@ -375,7 +375,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
if animation_data is None: if animation_data is None:
animation_data = armature_object.animation_data_create() animation_data = armature_object.animation_data_create()
for action in actions: for action in actions:
nla_track = animation_data.nla_tracks.new() nla_track = armature_object.animation_data.nla_tracks.new()
nla_track.name = action.name nla_track.name = action.name
nla_track.mute = True nla_track.mute = True
nla_track.strips.new(name=action.name, start=0, action=action) nla_track.strips.new(name=action.name, start=0, action=action)

View File

@@ -17,7 +17,7 @@ def _try_fix_cue4parse_issue_103(sequences) -> bool:
# Manually set the frame_start_index for each sequence. This assumes that the sequences are in order with # Manually set the frame_start_index for each sequence. This assumes that the sequences are in order with
# no shared frames between sequences (all exporters that I know of do this, so it's a safe assumption). # no shared frames between sequences (all exporters that I know of do this, so it's a safe assumption).
frame_start_index = 0 frame_start_index = 0
for sequence in sequences: for i, sequence in enumerate(sequences):
sequence.frame_start_index = frame_start_index sequence.frame_start_index = frame_start_index
frame_start_index += sequence.frame_count frame_start_index += sequence.frame_count
return True return True

View File

@@ -241,7 +241,7 @@ def get_psk_build_options_from_property_group(scene: Scene, pg: PskExportMixin)
match pg.transform_source: match pg.transform_source:
case 'SCENE': case 'SCENE':
transform_source = getattr(scene, 'psx_export') transform_source = getattr(scene, 'psx_export')
case 'SELF': case 'CUSTOM':
transform_source = pg transform_source = pg
case _: case _:
assert False, f'Invalid transform source: {pg.transform_source}' assert False, f'Invalid transform source: {pg.transform_source}'
@@ -486,9 +486,24 @@ class PSK_OT_export(Operator, ExportHelper):
flow.use_property_split = True flow.use_property_split = True
flow.use_property_decorate = False flow.use_property_decorate = False
flow.prop(pg, 'export_space') flow.prop(pg, 'export_space')
flow.prop(pg, 'scale') flow.prop(pg, 'transform_source')
flow.prop(pg, 'forward_axis')
flow.prop(pg, 'up_axis') flow = transform_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
match pg.transform_source:
case 'SCENE':
transform_source = getattr(context.scene, 'psx_export')
flow.enabled = False
case 'CUSTOM':
transform_source = pg
case _:
assert False, f'Invalid transform source: {pg.transform_source}'
flow.prop(transform_source, 'scale')
flow.prop(transform_source, 'forward_axis')
flow.prop(transform_source, 'up_axis')
# Extended Format # Extended Format
extended_format_header, extended_format_panel = layout.panel('Extended Format', default_closed=False) extended_format_header, extended_format_panel = layout.panel('Extended Format', default_closed=False)

View File

@@ -1,14 +1,10 @@
import os import os
from pathlib import Path from pathlib import Path
from typing import cast as typing_cast from bpy.props import CollectionProperty, StringProperty
from bpy.props import CollectionProperty, StringProperty, FloatProperty, EnumProperty from bpy.types import Context, FileHandler, Operator, OperatorFileListElement, UILayout
from bpy.types import Armature, Context, FileHandler, Operator, OperatorFileListElement, UILayout
from bpy_extras.io_utils import ImportHelper from bpy_extras.io_utils import ImportHelper
from ...shared.helpers import get_coordinate_system_transform
from ...shared.types import AxisMixin
from ..importer import PskImportOptions, import_psk from ..importer import PskImportOptions, import_psk
from ..properties import PskImportMixin from ..properties import PskImportMixin
from ..reader import read_psk from ..reader import read_psk
@@ -166,46 +162,6 @@ class PSK_OT_import_drag_and_drop(Operator, PskImportMixin):
return {'FINISHED'} return {'FINISHED'}
class PSK_OT_create_bones_from_selected_objects(Operator, AxisMixin):
bl_idname = 'psk.create_bones_from_selected_objects'
bl_label = 'Create Bones from Selected Objects'
bl_options = {'UNDO'}
length: FloatProperty(name='Length', subtype='DISTANCE', default=0.01)
@classmethod
def poll(cls, context: Context) -> bool:
return context.active_object is not None and context.active_object.type == 'ARMATURE'
def invoke(self, context, event):
assert context.window_manager
return context.window_manager.invoke_props_dialog(self)
def execute(self, context: Context):
armature_object = context.active_object
assert armature_object
armature_data = typing_cast(Armature, armature_object.data)
axis_transform = get_coordinate_system_transform(self.forward_axis, self.up_axis)
import bpy
bpy.ops.object.mode_set(mode='EDIT')
for index, obj in enumerate(context.selected_objects):
if obj == armature_object:
continue
edit_bone_matrix = armature_object.matrix_world.inverted() @ obj.matrix_world
edit_bone = armature_data.edit_bones.new(f'{obj.name}_{index}')
# translation, rotation, _ = edit_bone_matrix.decompose()
edit_bone.length = self.length
edit_bone.matrix = edit_bone_matrix @ axis_transform
bpy.ops.object.mode_set(mode='OBJECT')
return {'FINISHED'}
# TODO: move to another file # TODO: move to another file
class PSK_FH_import(FileHandler): class PSK_FH_import(FileHandler):
bl_idname = 'PSK_FH_import' bl_idname = 'PSK_FH_import'

View File

@@ -53,7 +53,7 @@ class PskImportResult:
self.mesh_object: Optional[Object] = None self.mesh_object: Optional[Object] = None
@property @property
def root_object(self) -> Optional[Object]: def root_object(self) -> Object:
return self.armature_object if self.armature_object is not None else self.mesh_object return self.armature_object if self.armature_object is not None else self.mesh_object
@@ -210,8 +210,9 @@ def import_psk(psk: Psk, context: Context, name: str, options: PskImportOptions)
for face_index, face in enumerate(psk.faces): for face_index, face in enumerate(psk.faces):
if face_index in invalid_face_indices: if face_index in invalid_face_indices:
continue continue
for wedge in map(lambda i: psk.wedges[i], reversed(face.wedge_indices)): for wedge_index in reversed(face.wedge_indices):
uv_layer_data[uv_layer_data_index] = wedge.u, 1.0 - wedge.v u, v = psk.extra_uvs[wedge_index_offset + wedge_index]
uv_layer_data[uv_layer_data_index] = u, 1.0 - v
uv_layer_data_index += 1 uv_layer_data_index += 1
wedge_index_offset += len(psk.wedges) wedge_index_offset += len(psk.wedges)
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}')

View File

@@ -67,7 +67,7 @@ def read_psk(path: str) -> Psk:
case b'MRPHDATA': case b'MRPHDATA':
_read_types(fp, Psk.MorphData, section, psk.morph_data) _read_types(fp, Psk.MorphData, section, psk.morph_data)
case _: case _:
if section.name.startswith(b'EXTRAUVS'): if section.name.startswith(b'EXTRAUV'):
_read_types(fp, Vector2, section, psk.extra_uvs) _read_types(fp, Vector2, section, psk.extra_uvs)
else: else:
# Section is not handled, skip it. # Section is not handled, skip it.

View File

@@ -220,6 +220,12 @@ def test_psk_import_extra_uvs():
assert mesh_data.uv_layers[0].name == 'UVMap', "First UV layer should be named 'UVMap'" assert mesh_data.uv_layers[0].name == 'UVMap', "First UV layer should be named 'UVMap'"
assert mesh_data.uv_layers[1].name == 'EXTRAUV0', "Second UV layer should be named 'EXTRAUV0'" assert mesh_data.uv_layers[1].name == 'EXTRAUV0', "Second UV layer should be named 'EXTRAUV0'"
# Verify that the data is actually different
assert mesh_data.uv_layers[0].uv[0].vector.x == 0.92480468750
assert mesh_data.uv_layers[0].uv[0].vector.y == 0.90533447265625
assert mesh_data.uv_layers[1].uv[0].vector.x == 3.0517578125e-05
assert mesh_data.uv_layers[1].uv[0].vector.y == 0.999969482421875
def test_psk_import_materials(): def test_psk_import_materials():
assert bpy.ops.psk.import_file( assert bpy.ops.psk.import_file(