Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2f5985681 | ||
|
|
3d3bbb9296 | ||
|
|
fda976d083 | ||
|
|
7e6911c709 | ||
|
|
30586fa8bb | ||
|
|
361a7f0218 | ||
|
|
b471229584 | ||
|
|
d0f64a6546 | ||
|
|
82310d695c | ||
|
|
0c11b326af |
@@ -57,6 +57,12 @@ Bug fixes will be issued for legacy addon versions that are under [Blender's LTS
|
||||
> Note that in order to see the imported actions applied to your armature, you must use the [Dope Sheet](https://docs.blender.org/manual/en/latest/editors/dope_sheet/introduction.html) or [Nonlinear Animation](https://docs.blender.org/manual/en/latest/editors/nla/introduction.html) editors.
|
||||
|
||||
# FAQ
|
||||
|
||||
## Why can't I see the animations imported from my PSA?
|
||||
Simply importing an animation into the scene will not automatically apply the action to the armature. This is in part because a PSA can have multiple sequences imported from it, and also that it's generally bad form for importers to modify the scene when they don't need to.
|
||||
|
||||
The PSA importer creates [Actions](https://docs.blender.org/manual/en/latest/animation/actions.html) for each of the selected sequences in the PSA. These actions can be applied to your armature via the [Action Editor](https://docs.blender.org/manual/en/latest/editors/dope_sheet/action.html) or [NLA Editor](https://docs.blender.org/manual/en/latest/editors/nla/index.html).
|
||||
|
||||
## Why are the mesh normals not accurate when importing a PSK extracted from [UE Viewer](https://www.gildor.org/en/projects/umodel)?
|
||||
If preserving the mesh normals of models is important for your workflow, it is *not recommended* to export PSK files from UE Viewer. This is because UE Viewer makes no attempt to reconstruct the original [smoothing groups](https://en.wikipedia.org/wiki/Smoothing_group). As a result, the normals of imported PSK files will be incorrect when imported into Blender and will need to be manually fixed.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from bpy.app.handlers import persistent
|
||||
bl_info = {
|
||||
"name": "PSK/PSA Importer/Exporter",
|
||||
"author": "Colin Basnett, Yurii Ti",
|
||||
"version": (6, 1, 0),
|
||||
"version": (6, 1, 2),
|
||||
"blender": (4, 0, 0),
|
||||
"description": "PSK/PSA Import/Export (.psk/.psa)",
|
||||
"warning": "",
|
||||
|
||||
@@ -13,36 +13,66 @@ class PsaConfig:
|
||||
self.sequence_bone_flags: Dict[str, Dict[int, int]] = dict()
|
||||
|
||||
|
||||
def _load_config_file(file_path: str) -> ConfigParser:
|
||||
"""
|
||||
UEViewer exports a dialect of INI files that is not compatible with Python's ConfigParser.
|
||||
Specifically, it allows values in this format:
|
||||
|
||||
[Section]
|
||||
Key1
|
||||
Key2
|
||||
|
||||
This is not allowed in Python's ConfigParser, which requires a '=' character after each key name.
|
||||
To work around this, we'll modify the file to add the '=' character after each key name if it is missing.
|
||||
"""
|
||||
with open(file_path, 'r') as f:
|
||||
lines = f.read().split('\n')
|
||||
|
||||
lines = [re.sub(r'^\s*(\w+)\s*$', r'\1=', line) for line in lines]
|
||||
|
||||
contents = '\n'.join(lines)
|
||||
|
||||
config = ConfigParser()
|
||||
config.read_string(contents)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _get_bone_flags_from_value(value: str) -> int:
|
||||
match value:
|
||||
case 'all':
|
||||
return (REMOVE_TRACK_LOCATION | REMOVE_TRACK_ROTATION)
|
||||
case 'trans':
|
||||
return REMOVE_TRACK_LOCATION
|
||||
case 'rot':
|
||||
return REMOVE_TRACK_ROTATION
|
||||
case _:
|
||||
return 0
|
||||
|
||||
|
||||
def read_psa_config(psa_reader: PsaReader, file_path: str) -> PsaConfig:
|
||||
psa_config = PsaConfig()
|
||||
|
||||
config = ConfigParser()
|
||||
config.read(file_path)
|
||||
|
||||
psa_sequence_names = list(psa_reader.sequences.keys())
|
||||
lowercase_sequence_names = [sequence_name.lower() for sequence_name in psa_sequence_names]
|
||||
config = _load_config_file(file_path)
|
||||
|
||||
if config.has_section('RemoveTracks'):
|
||||
for key, value in config.items('RemoveTracks'):
|
||||
match = re.match(f'^(.+)\.(\d+)$', key)
|
||||
sequence_name = match.group(1)
|
||||
bone_index = int(match.group(2))
|
||||
|
||||
# Map the sequence name onto the actual sequence name in the PSA file.
|
||||
try:
|
||||
psa_sequence_names = list(psa_reader.sequences.keys())
|
||||
lowercase_sequence_names = [sequence_name.lower() for sequence_name in psa_sequence_names]
|
||||
sequence_name = psa_sequence_names[lowercase_sequence_names.index(sequence_name.lower())]
|
||||
except ValueError:
|
||||
pass
|
||||
# Sequence name is not in the PSA file.
|
||||
continue
|
||||
|
||||
if sequence_name not in psa_config.sequence_bone_flags:
|
||||
psa_config.sequence_bone_flags[sequence_name] = dict()
|
||||
|
||||
match value:
|
||||
case 'all':
|
||||
psa_config.sequence_bone_flags[sequence_name][bone_index] = (REMOVE_TRACK_LOCATION | REMOVE_TRACK_ROTATION)
|
||||
case 'trans':
|
||||
psa_config.sequence_bone_flags[sequence_name][bone_index] = REMOVE_TRACK_LOCATION
|
||||
case 'rot':
|
||||
psa_config.sequence_bone_flags[sequence_name][bone_index] = REMOVE_TRACK_ROTATION
|
||||
bone_index = int(match.group(2))
|
||||
psa_config.sequence_bone_flags[sequence_name][bone_index] = _get_bone_flags_from_value(value)
|
||||
|
||||
return psa_config
|
||||
|
||||
@@ -152,7 +152,7 @@ class PSA_PG_export(PropertyGroup):
|
||||
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'
|
||||
'may not be able to be referenced in-engine'
|
||||
)
|
||||
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
|
||||
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)
|
||||
|
||||
@@ -158,6 +158,10 @@ class PSA_OT_import(Operator, ImportHelper):
|
||||
psa_reader = PsaReader(self.filepath)
|
||||
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected]
|
||||
|
||||
if len(sequence_names) == 0:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected')
|
||||
return {'CANCELLED'}
|
||||
|
||||
options = PsaImportOptions()
|
||||
options.sequence_names = sequence_names
|
||||
options.should_use_fake_user = pg.should_use_fake_user
|
||||
@@ -171,14 +175,14 @@ class PSA_OT_import(Operator, ImportHelper):
|
||||
options.fps_source = pg.fps_source
|
||||
options.fps_custom = pg.fps_custom
|
||||
|
||||
if options.should_use_config_file:
|
||||
# Read the PSA config file if it exists.
|
||||
config_path = Path(self.filepath).with_suffix('.config')
|
||||
if config_path.exists():
|
||||
try:
|
||||
options.psa_config = read_psa_config(psa_reader, str(config_path))
|
||||
|
||||
if len(sequence_names) == 0:
|
||||
self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected')
|
||||
return {'CANCELLED'}
|
||||
except Exception as e:
|
||||
self.report({'WARNING'}, f'Failed to read PSA config file: {e}')
|
||||
|
||||
result = import_psa(context, psa_reader, context.view_layer.objects.active, options)
|
||||
|
||||
@@ -258,6 +262,8 @@ class PSA_OT_import(Operator, ImportHelper):
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_use_fake_user')
|
||||
col.prop(pg, 'should_stash')
|
||||
col.prop(pg, 'should_use_config_file')
|
||||
|
||||
col.prop(pg, 'should_use_action_name_prefix')
|
||||
|
||||
if pg.should_use_action_name_prefix:
|
||||
|
||||
@@ -32,6 +32,12 @@ class PSA_PG_import(PropertyGroup):
|
||||
description='Assign each imported action a fake user so that the data block is '
|
||||
'saved even it has no users',
|
||||
options=empty_set)
|
||||
should_use_config_file: BoolProperty(default=True, name='Use Config File',
|
||||
description='Use the .config file that is sometimes generated when the PSA '
|
||||
'file is exported from UEViewer. This file contains '
|
||||
'options that can be used to filter out certain bones tracks '
|
||||
'from the imported actions',
|
||||
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)
|
||||
|
||||
@@ -24,6 +24,7 @@ class PsaImportOptions(object):
|
||||
self.bone_mapping_mode = 'CASE_INSENSITIVE'
|
||||
self.fps_source = 'SEQUENCE'
|
||||
self.fps_custom: float = 30.0
|
||||
self.should_use_config_file = True
|
||||
self.psa_config: PsaConfig = PsaConfig()
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ class PSK_OT_import(Operator, ImportHelper):
|
||||
soft_min=1.0,
|
||||
name='Bone Length',
|
||||
options=empty_set,
|
||||
subtype='DISTANCE',
|
||||
description='Length of the bones'
|
||||
)
|
||||
should_import_shape_keys: BoolProperty(
|
||||
@@ -113,7 +114,7 @@ class PSK_OT_import(Operator, ImportHelper):
|
||||
message += '\n'.join(result.warnings)
|
||||
self.report({'WARNING'}, message)
|
||||
else:
|
||||
self.report({'INFO'}, f'PSK imported')
|
||||
self.report({'INFO'}, f'PSK imported ({options.name})')
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from math import inf
|
||||
from typing import Optional, List
|
||||
|
||||
import bmesh
|
||||
@@ -17,7 +16,7 @@ class PskImportOptions:
|
||||
self.should_import_mesh = True
|
||||
self.should_reuse_materials = True
|
||||
self.should_import_vertex_colors = True
|
||||
self.vertex_color_space = 'sRGB'
|
||||
self.vertex_color_space = 'SRGB'
|
||||
self.should_import_vertex_normals = True
|
||||
self.should_import_extra_uvs = True
|
||||
self.should_import_skeleton = True
|
||||
@@ -144,6 +143,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
|
||||
bm.verts.ensure_lookup_table()
|
||||
|
||||
# FACES
|
||||
invalid_face_indices = set()
|
||||
for face_index, face in enumerate(psk.faces):
|
||||
point_indices = map(lambda i: psk.wedges[i].point_index, reversed(face.wedge_indices))
|
||||
@@ -192,33 +192,28 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
|
||||
# VERTEX COLORS
|
||||
if psk.has_vertex_colors and options.should_import_vertex_colors:
|
||||
size = (len(psk.points), 4)
|
||||
vertex_colors = np.full(size, inf)
|
||||
vertex_color_data = mesh_data.vertex_colors.new(name='VERTEXCOLOR')
|
||||
ambiguous_vertex_color_point_indices = []
|
||||
# Convert vertex colors to sRGB if necessary.
|
||||
psk_vertex_colors = np.zeros((len(psk.vertex_colors), 4))
|
||||
for i in range(len(psk.vertex_colors)):
|
||||
psk_vertex_colors[i,:] = psk.vertex_colors[i].normalized()
|
||||
match options.vertex_color_space:
|
||||
case 'SRGBA':
|
||||
for i in range(psk_vertex_colors.shape[0]):
|
||||
psk_vertex_colors[i, :3] = tuple(map(lambda x: rgb_to_srgb(x), psk_vertex_colors[i, :3]))
|
||||
case _:
|
||||
pass
|
||||
|
||||
for wedge_index, wedge in enumerate(psk.wedges):
|
||||
point_index = wedge.point_index
|
||||
psk_vertex_color = psk.vertex_colors[wedge_index].normalized()
|
||||
if vertex_colors[point_index, 0] != inf and tuple(vertex_colors[point_index]) != psk_vertex_color:
|
||||
ambiguous_vertex_color_point_indices.append(point_index)
|
||||
else:
|
||||
vertex_colors[point_index] = psk_vertex_color
|
||||
# Map the PSK vertex colors to the face corners.
|
||||
face_corner_colors = np.full((len(psk.faces * 3), 4), 1.0)
|
||||
face_corner_color_index = 0
|
||||
for face_index, face in enumerate(psk.faces):
|
||||
for wedge_index in reversed(face.wedge_indices):
|
||||
face_corner_colors[face_corner_color_index] = psk_vertex_colors[wedge_index]
|
||||
face_corner_color_index += 1
|
||||
|
||||
if options.vertex_color_space == 'SRGBA':
|
||||
for i in range(vertex_colors.shape[0]):
|
||||
vertex_colors[i, :3] = tuple(map(lambda x: rgb_to_srgb(x), vertex_colors[i, :3]))
|
||||
|
||||
for loop_index, loop in enumerate(mesh_data.loops):
|
||||
vertex_color = vertex_colors[loop.vertex_index]
|
||||
if vertex_color is not None:
|
||||
vertex_color_data.data[loop_index].color = vertex_color
|
||||
else:
|
||||
vertex_color_data.data[loop_index].color = 1.0, 1.0, 1.0, 1.0
|
||||
|
||||
if len(ambiguous_vertex_color_point_indices) > 0:
|
||||
result.warnings.append(
|
||||
f'{len(ambiguous_vertex_color_point_indices)} vertex(es) with ambiguous vertex colors.')
|
||||
# Create the vertex color attribute.
|
||||
face_corner_color_attribute = mesh_data.attributes.new(name='VERTEXCOLOR', type='FLOAT_COLOR', domain='CORNER')
|
||||
face_corner_color_attribute.data.foreach_set('color', face_corner_colors.flatten())
|
||||
|
||||
# VERTEX NORMALS
|
||||
if psk.has_vertex_normals and options.should_import_vertex_normals:
|
||||
@@ -227,6 +222,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
for vertex_normal in psk.vertex_normals:
|
||||
normals.append(tuple(vertex_normal))
|
||||
mesh_data.normals_split_custom_set_from_vertices(normals)
|
||||
mesh_data.use_auto_smooth = True
|
||||
else:
|
||||
mesh_data.shade_smooth()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user