Added automated tests (testing PSA import for now, more to come)

This commit is contained in:
Colin Basnett
2025-04-02 15:12:23 -07:00
parent 977153e4ad
commit 2b347bf064
15 changed files with 387 additions and 86 deletions

View File

@@ -10,8 +10,10 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
blender-version: [ 4.2, 4.3, 4.4 ]
env: env:
BLENDER_VERSION: blender-4.2.0-linux-x64
ADDON_NAME: io_scene_psk_psa ADDON_NAME: io_scene_psk_psa
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -20,33 +22,48 @@ jobs:
with: with:
file: '${{ env.ADDON_NAME }}/blender_manifest.toml' file: '${{ env.ADDON_NAME }}/blender_manifest.toml'
field: 'version' field: 'version'
- name: Set derived environment variables
run: |
echo "BLENDER_FILENAME=${{ env.BLENDER_VERSION }}.tar.xz" >> $GITHUB_ENV
echo "BLENDER_URL=https://mirrors.iu13.net/blender/release/Blender4.2/${{ env.BLENDER_VERSION }}.tar.xz" >> $GITHUB_ENV
- name: Install Blender Dependencies - name: Install Blender Dependencies
run: | run: |
sudo apt-get update -y
sudo apt-get install libxxf86vm-dev -y sudo apt-get install libxxf86vm-dev -y
sudo apt-get install libxfixes3 -y sudo apt-get install libxfixes3 -y
sudo apt-get install libxi-dev -y sudo apt-get install libxi-dev -y
sudo apt-get install libxkbcommon-x11-0 -y sudo apt-get install libxkbcommon-x11-0 -y
sudo apt-get install libgl1 -y sudo apt-get install libgl1 -y
sudo apt-get install libglx-mesa0 -y sudo apt-get install libglx-mesa0 -y
- name: Download & Extract Blender sudo apt-get install python3.11 -y
- name: Install Requirements
run: | run: |
wget -q $BLENDER_URL python3 -m pip install --upgrade pip
tar -xf $BLENDER_FILENAME python3 -m pip install virtualenv
rm -rf $BLENDER_FILENAME python3 -m virtualenv venv
- name: Add Blender executable to path source venv/bin/activate
pip install pytest-blender
pip install blender-downloader
- name: Install Blender
run: | run: |
echo "${{ github.workspace }}/${{ env.BLENDER_VERSION }}/" >> $GITHUB_PATH source venv/bin/activate
blender_executable="$(blender-downloader ${{ matrix.blender-version }} --extract --print-blender-executable)"
echo "BLENDER_EXECUTABLE=${blender_executable}" >> $GITHUB_ENV
blender_python="$(pytest-blender --blender-executable "$blender_executable")"
echo "BLENDER_PYTHON=${blender_python}" >> $GITHUB_ENV
# Write the BLENDER_PYTHON path to the console for debugging
# Deactivate the virtualenv to avoid conflicts with the system python
deactivate
$blender_python -m ensurepip
$blender_python -m pip install -r tests/requirements.txt
- name: Build extension - name: Build extension
run: | run: |
pushd ./${{ env.ADDON_NAME }} pushd ./${{ env.ADDON_NAME }}
blender --command extension build # Run blender using the environment variable set by the action
${{ env.BLENDER_EXECUTABLE }} --command extension build
mkdir artifact mkdir artifact
unzip -q ${{ env.ADDON_NAME }}-${{ steps.read_manifest.outputs.value }}.zip -d ./artifact unzip -q ${{ env.ADDON_NAME }}-${{ steps.read_manifest.outputs.value }}.zip -d ./artifact
popd popd
- name: Run tests
run: |
source venv/bin/activate
pytest -svv tests
- name: Archive addon - name: Archive addon
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:

View File

@@ -10,28 +10,21 @@ This software is licensed under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0
# Features # Features
* Full PSK/PSA import and export capabilities. * Full PSK/PSA import and export capabilities.
* Non-standard file section data is supported for import only (vertex normals, extra UV channels, vertex colors, shape keys). * Non-standard file section data (.pskx) is supported for import only (vertex normals, extra UV channels, vertex colors, shape keys).
* Fine-grained PSA sequence importing for efficient workflow when working with large PSA files. * Fine-grained PSA sequence importing for efficient workflow when working with large PSA files.
* PSA sequence metadata (e.g., frame rate) is preserved on import, allowing this data to be reused on export. * PSA sequence metadata (e.g., frame rate) is preserved on import, allowing this data to be reused on export.
* Specific bone collections can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers). * [Bone collections](https://docs.blender.org/manual/en/latest/animation/armatures/bones/bone_collections.html#bone-collections) can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers).
* PSA sequences can be exported directly from actions or delineated using a scene's [timeline markers](https://docs.blender.org/manual/en/latest/animation/markers.html), pose markers, or NLA track strips, allowing direct use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences. * PSA sequences can be exported directly from actions or delineated using a scene's [timeline markers](https://docs.blender.org/manual/en/latest/animation/markers.html), pose markers, or NLA track strips, allowing direct use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences.
* Manual re-ordering of material slots when exporting multiple mesh objects. * Manual re-ordering of material slots.
* Multiple armature objects can be exported to a single PSK or PSA file, allowing seamless use of [action slots](https://docs.blender.org/manual/en/latest/animation/actions.html#action-slots).
* Support for exporting instance collections.
# Installation # Installation
For Blender 4.2 and higher, it is recommended to download the latest version from the [Blender Extensions](https://extensions.blender.org/add-ons/io-scene-psk-psa/) platform. For Blender 4.2 and higher, it is recommended to download the latest version from the [Blender Extensions](https://extensions.blender.org/add-ons/io-scene-psk-psa/) platform.
For Blender 4.1 and lower, you can install the addon manually by following these steps: For Blender 4.1 and lower, see [Legacy Compatibility](#legacy-compatibility).
1. Download the .zip file of the latest compatible version for your Blender version (see [Legacy Compatibility](#legacy-compatibility)).
2. Open Blender.
3. Navigate to the Blender Preferences (`Edit` > `Preferences`).
4. Select the `Add-ons` tab.
5. Click the `Install...` button.
6. Select the .zip file that you downloaded earlier and click `Install Add-on`.
7. Enable the newly added `Import-Export: PSK/PSA Importer/Exporter` addon.
# Legacy Compatibility # Legacy Compatibility
Below is a table of the latest addon versions that are compatible with older versions of Blender. These versions are no longer maintained and may contain bugs that have been fixed in newer versions. It is recommended to use the latest version of the addon for the best experience. Below is a table of the latest addon versions that are compatible with older versions of Blender. These versions are no longer maintained and may contain bugs that have been fixed in newer versions. It is recommended to use the latest version of the addon for the best experience.
Critical bug fixes may be issued for legacy addon versions that are under [Blender's LTS maintenance period](https://www.blender.org/download/lts/). Once the LTS period has ended, legacy addon versions will no longer be supported by the maintainers of this repository, although the releases will still be available for download. Critical bug fixes may be issued for legacy addon versions that are under [Blender's LTS maintenance period](https://www.blender.org/download/lts/). Once the LTS period has ended, legacy addon versions will no longer be supported by the maintainers of this repository, although the releases will still be available for download.
@@ -41,7 +34,7 @@ Critical bug fixes may be issued for legacy addon versions that are under [Blend
| [4.1](https://www.blender.org/download/releases/4-1/) | [7.0.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/7.0.0) | No | | [4.1](https://www.blender.org/download/releases/4-1/) | [7.0.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/7.0.0) | No |
| [4.0](https://www.blender.org/download/releases/4-0/) | [6.2.1](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/6.2.1) | No | | [4.0](https://www.blender.org/download/releases/4-0/) | [6.2.1](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/6.2.1) | No |
| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.6](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.6) | June 2025 | | [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.6](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.6) | June 2025 |
| [2.93 - 3.3](https://www.blender.org/download/releases/3-3/) | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | September 2024 | | [2.93 - 3.3](https://www.blender.org/download/releases/3-3/) | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | ~~September 2024~~ |
# Usage # Usage
## Exporting a PSK ## Exporting a PSK
@@ -69,7 +62,7 @@ Critical bug fixes may be issued for legacy addon versions that are under [Blend
# FAQ # FAQ
## Why can't I see the animations imported from my PSA? ## 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. 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 in ways that the user may not expect.
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). 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).
@@ -80,6 +73,9 @@ The method I prefer is to simply change the Blender [scene properties](https://d
The second option is to simply change the `Scale` value on the PSK import dialog. This will scale the armature by the factor provided. Note that this is more destructive, but may be preferable if you don't intend on exporting PSKs or PSAs to a game engine. The second option is to simply change the `Scale` value on the PSK import dialog. This will scale the armature by the factor provided. Note that this is more destructive, but may be preferable if you don't intend on exporting PSKs or PSAs to a game engine.
## How do I control shading for PSK exports?
The PSK format does not support vertex normals and instead uses [smoothing groups](https://en.wikipedia.org/wiki/Smoothing_group) to control shading. Note that a mesh's Custom Split Normals Data will be ignored when exporting to PSK. Therefore, the best way to control shading is to use sharp edges and the Edge Split modifier.
## Why are the mesh normals not accurate when importing a PSK extracted from [UE Viewer](https://www.gildor.org/en/projects/umodel)? ## 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. 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.

View File

@@ -70,18 +70,18 @@ else:
import bpy import bpy
from bpy.props import PointerProperty from bpy.props import PointerProperty
classes = shared_types.classes +\ classes = shared_types.classes + \
psk_properties.classes +\ psk_properties.classes + \
psk_ui.classes +\ psk_ui.classes + \
psk_import_operators.classes +\ psk_import_operators.classes + \
psk_export_properties.classes +\ psk_export_properties.classes + \
psk_export_operators.classes +\ psk_export_operators.classes + \
psk_export_ui.classes + \ psk_export_ui.classes + \
psa_export_properties.classes +\ psa_export_properties.classes + \
psa_export_operators.classes +\ psa_export_operators.classes + \
psa_export_ui.classes + \ psa_export_ui.classes + \
psa_import_properties.classes +\ psa_import_properties.classes + \
psa_import_operators.classes +\ psa_import_operators.classes + \
psa_import_ui.classes psa_import_ui.classes

View File

@@ -49,8 +49,8 @@ def _get_pose_bone_location_and_rotation(
coordinate_system_transform: Matrix, coordinate_system_transform: Matrix,
has_false_root_bone: bool, has_false_root_bone: bool,
) -> Tuple[Vector, Quaternion]: ) -> Tuple[Vector, Quaternion]:
# TODO: my kingdom for a Rust monad.
is_false_root_bone = pose_bone is None and armature_object is None is_false_root_bone = pose_bone is None and armature_object is None
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.parent is not None: elif pose_bone.parent is not None:
@@ -82,7 +82,6 @@ def _get_pose_bone_location_and_rotation(
location = pose_bone_matrix.to_translation() location = pose_bone_matrix.to_translation()
rotation = pose_bone_matrix.to_quaternion().normalized() rotation = pose_bone_matrix.to_quaternion().normalized()
# TODO: this has gotten way more complicated than it needs to be.
# Don't apply scale to the root bone of armatures if we have a false root. # Don't apply scale to the root bone of armatures if we have a false root.
if not has_false_root_bone or (pose_bone is None or pose_bone.parent is not None): if not has_false_root_bone or (pose_bone is None or pose_bone.parent is not None):
location *= scale location *= scale
@@ -112,12 +111,9 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
bone_collection_indices=options.bone_collection_indices, bone_collection_indices=options.bone_collection_indices,
) )
# TODO: technically wrong, might not necessarily be true (i.e., if the armature object has no contributing bones).
has_false_root_bone = len(options.armature_objects) > 1
# Build list of PSA bones. # Build list of PSA bones.
# Note that the PSA bones are just here to validate the hierarchy. The bind pose information is not used by the # Note that the PSA bones are just here to validate the hierarchy.
# engine. # The bind pose information is not used by the engine.
psa.bones = [psx_bone for psx_bone, _ in psx_bone_create_result.bones] psa.bones = [psx_bone for psx_bone, _ in psx_bone_create_result.bones]
# No bones are going to be exported. # No bones are going to be exported.
@@ -129,11 +125,10 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}' export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}'
export_sequence.name = export_sequence.name.strip() export_sequence.name = export_sequence.name.strip()
# Save the 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 for o in options.armature_objects}
saved_frame_current = context.scene.frame_current saved_frame_current = context.scene.frame_current
saved_action = options.animation_data.action
# Now build the PSA sequences. # Now build the PSA sequences.
# We actually alter the timeline frame and simply record the resultant pose bone matrices. # We actually alter the timeline frame and simply record the resultant pose bone matrices.
frame_start_index = 0 frame_start_index = 0
@@ -179,7 +174,6 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
# Link the action to the animation data and update view layer. # Link the action to the animation data and update view layer.
for armature_object in options.armature_objects: for armature_object in options.armature_objects:
# TODO: change this to assign it to each armature object's animation data.
armature_object.animation_data.action = export_sequence.nla_state.action armature_object.animation_data.action = export_sequence.nla_state.action
context.view_layer.update() context.view_layer.update()
@@ -217,13 +211,10 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
export_bones: List[PsaExportBone] = [] export_bones: List[PsaExportBone] = []
for psx_bone, armature_object in psx_bone_create_result.bones: for psx_bone, armature_object in psx_bone_create_result.bones:
print(psx_bone, armature_object)
# TODO: look up the pose bone from the name in the PSX bone.
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))))
continue continue
# TODO: we need to look up the pose bones using the name.
pose_bone = armature_object.pose.bones[psx_bone.name.decode('windows-1252')] pose_bone = armature_object.pose.bones[psx_bone.name.decode('windows-1252')]
export_bones.append(PsaExportBone(pose_bone, armature_object, armature_scales[armature_object])) export_bones.append(PsaExportBone(pose_bone, armature_object, armature_scales[armature_object]))
@@ -260,7 +251,7 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
options.export_space, options.export_space,
export_bone.scale, export_bone.scale,
coordinate_system_transform=coordinate_system_transform, coordinate_system_transform=coordinate_system_transform,
has_false_root_bone=has_false_root_bone, has_false_root_bone=psx_bone_create_result.has_false_root_bone,
) )
last_frame_bone_poses.append((location, rotation)) last_frame_bone_poses.append((location, rotation))
@@ -283,7 +274,7 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
export_space=options.export_space, export_space=options.export_space,
scale=export_bone.scale, scale=export_bone.scale,
coordinate_system_transform=coordinate_system_transform, coordinate_system_transform=coordinate_system_transform,
has_false_root_bone=has_false_root_bone, has_false_root_bone=psx_bone_create_result.has_false_root_bone,
) )
next_frame_bone_poses.append((location, rotation)) next_frame_bone_poses.append((location, rotation))
@@ -310,7 +301,7 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
export_space=options.export_space, export_space=options.export_space,
scale=export_bone.scale, scale=export_bone.scale,
coordinate_system_transform=coordinate_system_transform, coordinate_system_transform=coordinate_system_transform,
has_false_root_bone=has_false_root_bone, has_false_root_bone=psx_bone_create_result.has_false_root_bone,
) )
add_key(location, rotation) add_key(location, rotation)
@@ -322,9 +313,9 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
context.window_manager.progress_update(export_sequence_index) context.window_manager.progress_update(export_sequence_index)
# Restore the previous action & frame. # Restore the previous actions & frame.
# TODO: store each armature object's previous action for armature_object, action in saved_armature_object_actions.items():
options.animation_data.action = saved_action armature_object.animation_data.action = action
context.scene.frame_set(saved_frame_current) context.scene.frame_set(saved_frame_current)

View File

@@ -16,7 +16,7 @@ def get_psk_import_options_from_properties(property_group: PskImportMixin):
options.should_import_vertex_colors = property_group.should_import_vertex_colors options.should_import_vertex_colors = property_group.should_import_vertex_colors
options.should_import_vertex_normals = property_group.should_import_vertex_normals options.should_import_vertex_normals = property_group.should_import_vertex_normals
options.vertex_color_space = property_group.vertex_color_space options.vertex_color_space = property_group.vertex_color_space
options.should_import_skeleton = property_group.should_import_skeleton options.should_import_armature = property_group.should_import_armature
options.bone_length = property_group.bone_length options.bone_length = property_group.bone_length
options.should_import_materials = property_group.should_import_materials options.should_import_materials = property_group.should_import_materials
options.should_import_shape_keys = property_group.should_import_shape_keys options.should_import_shape_keys = property_group.should_import_shape_keys
@@ -34,7 +34,7 @@ def psk_import_draw(layout: UILayout, props: PskImportMixin):
col = row.column() col = row.column()
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(props, 'import_components') col.prop(props, 'components')
if props.should_import_mesh: if props.should_import_mesh:
mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False) mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False)
@@ -45,20 +45,21 @@ def psk_import_draw(layout: UILayout, props: PskImportMixin):
col = row.column() col = row.column()
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(props, 'should_import_materials', text='Materials')
col.prop(props, 'should_import_vertex_normals', text='Vertex Normals')
col.prop(props, 'should_import_extra_uvs', text='Extra UVs') col.prop(props, 'should_import_extra_uvs', text='Extra UVs')
col.prop(props, 'should_import_materials', text='Materials')
col.prop(props, 'should_import_vertex_colors', text='Vertex Colors') col.prop(props, 'should_import_vertex_colors', text='Vertex Colors')
if props.should_import_vertex_colors: if props.should_import_vertex_colors:
col.prop(props, 'vertex_color_space') col.prop(props, 'vertex_color_space')
col.separator()
col.prop(props, 'should_import_vertex_normals', text='Vertex Normals')
col.prop(props, 'should_import_shape_keys', text='Shape Keys') col.prop(props, 'should_import_shape_keys', text='Shape Keys')
if props.should_import_skeleton: if props.should_import_armature:
skeleton_header, skeleton_panel = layout.panel('skeleton_panel_id', default_closed=False) armature_header, armature_panel = layout.panel('armature_panel_id', default_closed=False)
skeleton_header.label(text='Skeleton', icon='OUTLINER_DATA_ARMATURE') armature_header.label(text='Armature', icon='OUTLINER_DATA_ARMATURE')
if skeleton_panel: if armature_panel:
row = skeleton_panel.row() row = armature_panel.row()
col = row.column() col = row.column()
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False

View File

@@ -20,7 +20,7 @@ class PskImportOptions:
self.vertex_color_space = 'SRGB' self.vertex_color_space = 'SRGB'
self.should_import_vertex_normals = True self.should_import_vertex_normals = True
self.should_import_extra_uvs = True self.should_import_extra_uvs = True
self.should_import_skeleton = True self.should_import_armature = True
self.should_import_shape_keys = True self.should_import_shape_keys = True
self.bone_length = 1.0 self.bone_length = 1.0
self.should_import_materials = True self.should_import_materials = True
@@ -62,7 +62,7 @@ def import_psk(psk: Psk, context: Context, name: str, options: PskImportOptions)
armature_object = None armature_object = None
mesh_object = None mesh_object = None
if options.should_import_skeleton: if options.should_import_armature:
# Armature # Armature
armature_data = bpy.data.armatures.new(name) armature_data = bpy.data.armatures.new(name)
armature_object = bpy.data.objects.new(name, armature_data) armature_object = bpy.data.objects.new(name, armature_data)
@@ -213,12 +213,9 @@ def import_psk(psk: Psk, context: Context, name: str, options: PskImportOptions)
psk_vertex_colors = np.zeros((len(psk.vertex_colors), 4)) psk_vertex_colors = np.zeros((len(psk.vertex_colors), 4))
for vertex_color_index in range(len(psk.vertex_colors)): for vertex_color_index in range(len(psk.vertex_colors)):
psk_vertex_colors[vertex_color_index,:] = psk.vertex_colors[vertex_color_index].normalized() psk_vertex_colors[vertex_color_index,:] = psk.vertex_colors[vertex_color_index].normalized()
match options.vertex_color_space: if options.vertex_color_space == 'SRGBA':
case 'SRGBA':
for i in range(psk_vertex_colors.shape[0]): 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])) psk_vertex_colors[i, :3] = tuple(map(lambda x: rgb_to_srgb(x), psk_vertex_colors[i, :3]))
case _:
pass
# Map the PSK vertex colors to the face corners. # Map the PSK vertex colors to the face corners.
face_count = len(psk.faces) - len(invalid_face_indices) face_count = len(psk.faces) - len(invalid_face_indices)
@@ -276,12 +273,12 @@ def import_psk(psk: Psk, context: Context, name: str, options: PskImportOptions)
context.scene.collection.objects.link(mesh_object) context.scene.collection.objects.link(mesh_object)
# Add armature modifier to our mesh object. # Add armature modifier to our mesh object.
if options.should_import_skeleton: if options.should_import_armature:
armature_modifier = mesh_object.modifiers.new(name='Armature', type='ARMATURE') armature_modifier = mesh_object.modifiers.new(name='Armature', type='ARMATURE')
armature_modifier.object = armature_object armature_modifier.object = armature_object
mesh_object.parent = armature_object mesh_object.parent = armature_object
root_object = armature_object if options.should_import_skeleton else mesh_object root_object = armature_object if options.should_import_armature else mesh_object
root_object.scale = (options.scale, options.scale, options.scale) root_object.scale = (options.scale, options.scale, options.scale)
try: try:

View File

@@ -46,10 +46,11 @@ def poly_flags_to_triangle_type_and_bit_flags(poly_flags: int) -> (str, set[str]
def should_import_mesh_get(self): def should_import_mesh_get(self):
return self.import_components in {'ALL', 'MESH'} return self.components in {'ALL', 'MESH'}
def should_import_skleton_get(self): def should_import_skleton_get(self):
return self.import_components in {'ALL', 'SKELETON'} return self.components in {'ALL', 'ARMATURE'}
class PskImportMixin: class PskImportMixin:
@@ -73,7 +74,7 @@ class PskImportMixin:
default=True, default=True,
name='Import Vertex Normals', name='Import Vertex Normals',
options=set(), options=set(),
description='Import vertex normals, if available' description='Import vertex normals, if available.\n\nThis is only supported for PSKX files'
) )
should_import_extra_uvs: BoolProperty( should_import_extra_uvs: BoolProperty(
default=True, default=True,
@@ -81,14 +82,14 @@ class PskImportMixin:
options=set(), options=set(),
description='Import extra UV maps, if available' description='Import extra UV maps, if available'
) )
import_components: EnumProperty( components: EnumProperty(
name='Import Components', name='Components',
options=set(), options=set(),
description='Determine which components to import', description='Which components to import',
items=( items=(
('ALL', 'Mesh & Skeleton', 'Import mesh and skeleton'), ('ALL', 'Mesh & Armature', 'Import mesh and armature'),
('MESH', 'Mesh Only', 'Import mesh only'), ('MESH', 'Mesh Only', 'Import mesh only'),
('SKELETON', 'Skeleton Only', 'Import skeleton only'), ('ARMATURE', 'Armature Only', 'Import armature only'),
), ),
default='ALL' default='ALL'
) )
@@ -101,7 +102,7 @@ class PskImportMixin:
name='Import Materials', name='Import Materials',
options=set(), options=set(),
) )
should_import_skeleton: BoolProperty( should_import_armature: BoolProperty(
name='Import Skeleton', name='Import Skeleton',
get=should_import_skleton_get, get=should_import_skleton_get,
) )
@@ -119,7 +120,7 @@ class PskImportMixin:
default=True, default=True,
name='Import Shape Keys', name='Import Shape Keys',
options=set(), options=set(),
description='Import shape keys, if available' description='Import shape keys, if available.\n\nThis is only supported for PSKX files'
) )
scale: FloatProperty( scale: FloatProperty(
name='Scale', name='Scale',

View File

@@ -272,6 +272,10 @@ class PsxBoneCreateResult:
self.armature_object_root_bone_indices = armature_object_root_bone_indices self.armature_object_root_bone_indices = armature_object_root_bone_indices
self.armature_object_bone_names = armature_object_bone_names self.armature_object_bone_names = armature_object_bone_names
@property
def has_false_root_bone(self) -> bool:
return len(self.bones) > 0 and self.bones[0][1] is None
def create_psx_bones( def create_psx_bones(
armature_objects: List[Object], armature_objects: List[Object],

6
pyproject.toml Normal file
View File

@@ -0,0 +1,6 @@
[project]
name = "io_scene_psk_psa"
[pytest]
blender-addons-dirs = "io_scene_psk_psa"
testpaths = "tests"

0
tests/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

BIN
tests/data/Suzanne.psk LFS Normal file

Binary file not shown.

281
tests/psk_import_test.py Normal file
View File

@@ -0,0 +1,281 @@
import bpy
import pytest
SUZANNE_FILEPATH = 'tests/data/Suzanne.psk'
SARGE_FILEPATH = 'tests/data/CS_Sarge_S0_Skelmesh.pskx'
SLURP_MONSTER_AXE_FILEPATH = 'tests/data/Slurp_Monster_Axe_LOD0.psk'
@pytest.fixture(autouse=True)
def run_before_and_after_Tests(tmpdir):
# Setup: Run before the tests
bpy.ops.wm.read_homefile(app_template='')
yield
# Teardown: Run after the tests
pass
def test_psk_import_all():
assert bpy.ops.psk.import_file(
filepath=SUZANNE_FILEPATH,
components='ALL',
) == {'FINISHED'}
armature_object = bpy.data.objects.get('Suzanne', None)
assert armature_object.type == 'ARMATURE', "Armature object type should be ARMATURE"
assert armature_object is not None, "Armature object not found in the scene"
assert len(armature_object.children) == 1, "Armature object should have one child"
armature_data = armature_object.data
assert len(armature_data.bones) == 1, "Armature should have one bone"
mesh_object = bpy.data.objects.get('Suzanne.001', None)
assert mesh_object is not None, "Mesh object not found in the scene"
mesh_data = mesh_object.data
assert len(mesh_data.vertices) == 507
assert len(mesh_data.polygons) == 968
def test_psk_import_armature_only():
assert bpy.ops.psk.import_file(
filepath=SUZANNE_FILEPATH,
components='ARMATURE',
) == {'FINISHED'}
armature_object = bpy.data.objects.get('Suzanne', None)
assert armature_object.type == 'ARMATURE', "Armature object type should be ARMATURE"
assert armature_object is not None, "Armature object not found in the scene"
assert len(armature_object.children) == 0, "Armature object should have no children"
armature_data = armature_object.data
assert len(armature_data.bones) == 1, "Armature should have one bone"
def test_psk_import_mesh_only():
assert bpy.ops.psk.import_file(
filepath=SUZANNE_FILEPATH,
components='MESH',
) == {'FINISHED'}
mesh_object = bpy.data.objects.get('Suzanne', None)
assert mesh_object.type == 'MESH', "Mesh object type should be MESH"
assert mesh_object is not None, "Mesh object not found in the scene"
mesh_data = mesh_object.data
assert len(mesh_data.vertices) == 507
assert len(mesh_data.polygons) == 968
def test_psk_import_scale():
"""
Test the import of a PSK file with a scale factor of 2.0.
The scale factor is applied to the armature object.
"""
assert bpy.ops.psk.import_file(
filepath=SUZANNE_FILEPATH,
components='ALL',
scale=2.0,
) == {'FINISHED'}
armature_object = bpy.data.objects.get('Suzanne', None)
assert armature_object is not None, "Armature object not found in the scene"
assert armature_object.type == 'ARMATURE', "Armature object type should be ARMATURE"
assert tuple(armature_object.scale) == (2.0, 2.0, 2.0), "Armature object scale should be (2.0, 2.0, 2.0)"
def test_psk_import_bone_length():
bone_length = 1.25
assert bpy.ops.psk.import_file(
filepath=SUZANNE_FILEPATH,
components='ARMATURE',
bone_length=bone_length,
) == {'FINISHED'}
armature_object = bpy.data.objects.get('Suzanne', None)
assert armature_object is not None, "Armature object not found in the scene"
assert armature_object.type == 'ARMATURE', "Armature object type should be ARMATURE"
armature_data = armature_object.data
assert armature_data is not None, "Armature data not found in the scene"
assert len(armature_data.bones) == 1, "Armature should have one bone"
assert 'ROOT' in armature_data.bones, "Armature should have a bone named 'ROOT'"
root_bone = armature_data.bones['ROOT']
assert tuple(root_bone.head) == (0.0, 0.0, 0.0), "Bone head should be (0.0, 0.0, 0.0)"
assert tuple(root_bone.tail) == (0.0, bone_length, 0.0), f"Bone tail should be (0.0, {bone_length}, 0.0)"
def test_psk_import_with_vertex_normals():
assert bpy.ops.psk.import_file(
filepath=SARGE_FILEPATH,
components='MESH',
should_import_vertex_normals=True,
) == {'FINISHED'}
mesh_object = bpy.data.objects.get('CS_Sarge_S0_Skelmesh', None)
assert mesh_object is not None, "Mesh object not found in the scene"
assert mesh_object.type == 'MESH', "Mesh object type should be MESH"
mesh_data = mesh_object.data
assert mesh_data is not None, "Mesh data not found in the scene"
assert mesh_data.has_custom_normals, "Mesh should have custom normals"
def test_psk_import_without_vertex_normals():
assert bpy.ops.psk.import_file(
filepath=SARGE_FILEPATH,
components='MESH',
should_import_vertex_normals=False,
) == {'FINISHED'}
mesh_object = bpy.data.objects.get('CS_Sarge_S0_Skelmesh', None)
assert mesh_object is not None, "Mesh object not found in the scene"
assert mesh_object.type == 'MESH', "Mesh object type should be MESH"
mesh_data = mesh_object.data
assert mesh_data is not None, "Mesh data not found in the scene"
assert not mesh_data.has_custom_normals, "Mesh should not have custom normals"
def test_psk_import_with_vertex_colors_srgba():
assert bpy.ops.psk.import_file(
filepath=SARGE_FILEPATH,
components='MESH',
should_import_vertex_colors=True,
vertex_color_space='SRGBA',
) == {'FINISHED'}
mesh_object = bpy.data.objects.get('CS_Sarge_S0_Skelmesh', None)
assert mesh_object is not None, "Mesh object not found in the scene"
assert mesh_object.type == 'MESH', "Mesh object type should be MESH"
mesh_data = mesh_object.data
assert mesh_data is not None, "Mesh data not found in the scene"
assert len(mesh_data.color_attributes) == 1, "Mesh should have one vertex color layer"
assert mesh_data.color_attributes[0].name == 'VERTEXCOLOR', "Vertex color layer should be named 'VERTEXCOLOR'"
assert tuple(mesh_data.color_attributes[0].data[3303].color) == (0.34586891531944275, 0.0, 0.0, 1.0), "Unexpected vertex color value"
def test_psk_import_vertex_colors_linear():
assert bpy.ops.psk.import_file(
filepath=SARGE_FILEPATH,
components='MESH',
should_import_vertex_colors=True,
vertex_color_space='LINEAR',
) == {'FINISHED'}
mesh_object = bpy.data.objects.get('CS_Sarge_S0_Skelmesh', None)
assert mesh_object is not None, "Mesh object not found in the scene"
assert mesh_object.type == 'MESH', "Mesh object type should be MESH"
mesh_data = mesh_object.data
assert mesh_data is not None, "Mesh data not found in the scene"
assert len(mesh_data.color_attributes) == 1, "Mesh should have one vertex color layer"
assert mesh_data.color_attributes[0].name == 'VERTEXCOLOR', "Vertex color layer should be named 'VERTEXCOLOR'"
assert tuple(mesh_data.color_attributes[0].data[3303].color) == (0.09803921729326248, 0.0, 0.0, 1.0), "Unexpected vertex color value"
def test_psk_import_without_vertex_colors():
assert bpy.ops.psk.import_file(
filepath=SARGE_FILEPATH,
components='MESH',
should_import_vertex_colors=False,
) == {'FINISHED'}
mesh_object = bpy.data.objects.get('CS_Sarge_S0_Skelmesh', None)
assert mesh_object is not None, "Mesh object not found in the scene"
assert mesh_object.type == 'MESH', "Mesh object type should be MESH"
mesh_data = mesh_object.data
assert mesh_data is not None, "Mesh data not found in the scene"
assert len(mesh_data.color_attributes) == 0, "Mesh should not have any vertex color layers"
def test_psk_import_extra_uvs():
assert bpy.ops.psk.import_file(
filepath=SARGE_FILEPATH,
components='MESH',
should_import_vertex_colors=True,
vertex_color_space='LINEAR',
) == {'FINISHED'}
mesh_object = bpy.data.objects.get('CS_Sarge_S0_Skelmesh', None)
assert mesh_object is not None, "Mesh object not found in the scene"
assert mesh_object.type == 'MESH', "Mesh object type should be MESH"
mesh_data = mesh_object.data
assert mesh_data is not None, "Mesh data not found in the scene"
assert len(mesh_data.uv_layers) == 2, "Mesh should have two UV layers"
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'"
def test_psk_import_materials():
assert bpy.ops.psk.import_file(
filepath=SARGE_FILEPATH,
components='MESH',
) == {'FINISHED'}
mesh_object = bpy.data.objects.get('CS_Sarge_S0_Skelmesh', None)
assert mesh_object is not None, "Mesh object not found in the scene"
assert mesh_object.type == 'MESH', "Mesh object type should be MESH"
mesh_data = mesh_object.data
assert mesh_data is not None, "Mesh data not found in the scene"
assert len(mesh_data.materials) == 4, "Mesh should have four materials"
material_names = (
'CS_Sarge_S0_MI',
'TP_Core_Eye_MI',
'AB_Sarge_S0_E_StimPack_MI1',
'CS_Sarge_S0_MI'
)
for i, material in enumerate(mesh_data.materials):
assert material.name == material_names[i], f"Material {i} name should be {material_names[i]}"
def test_psk_import_shape_keys():
assert bpy.ops.psk.import_file(
filepath=SLURP_MONSTER_AXE_FILEPATH,
components='MESH',
) == {'FINISHED'}
mesh_object = bpy.data.objects.get('Slurp_Monster_Axe_LOD0', None)
assert mesh_object is not None, "Mesh object not found in the scene"
assert mesh_object.type == 'MESH', "Mesh object type should be MESH"
assert mesh_object.data.shape_keys is not None, "Mesh object should have shape keys"
shape_key_names = (
'MORPH_BASE',
'pickaxe',
'axe',
'Blob_03',
'Blob02',
'Blob01',
)
shape_keys = mesh_object.data.shape_keys.key_blocks
assert len(shape_keys) == 6, "Mesh object should have 6 shape keys"
for i, shape_key in enumerate(shape_keys):
assert shape_key.name == shape_key_names[i], f"Shape key {i} name should be {shape_key_names[i]}"
def test_psk_import_without_shape_keys():
assert bpy.ops.psk.import_file(
filepath=SLURP_MONSTER_AXE_FILEPATH,
components='MESH',
should_import_shape_keys=False,
) == {'FINISHED'}
mesh_object = bpy.data.objects.get('Slurp_Monster_Axe_LOD0', None)
assert mesh_object is not None, "Mesh object not found in the scene"
assert mesh_object.type == 'MESH', "Mesh object type should be MESH"
assert mesh_object.data.shape_keys is None, "Mesh object should not have shape keys"

1
tests/requirements.txt Normal file
View File

@@ -0,0 +1 @@
pytest