Added automated tests (testing PSA import for now, more to come)
This commit is contained in:
41
.github/workflows/main.yml
vendored
41
.github/workflows/main.yml
vendored
@@ -10,8 +10,10 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
blender-version: [ 4.2, 4.3, 4.4 ]
|
||||
env:
|
||||
BLENDER_VERSION: blender-4.2.0-linux-x64
|
||||
ADDON_NAME: io_scene_psk_psa
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -20,33 +22,48 @@ jobs:
|
||||
with:
|
||||
file: '${{ env.ADDON_NAME }}/blender_manifest.toml'
|
||||
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
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install libxxf86vm-dev -y
|
||||
sudo apt-get install libxfixes3 -y
|
||||
sudo apt-get install libxi-dev -y
|
||||
sudo apt-get install libxkbcommon-x11-0 -y
|
||||
sudo apt-get install libgl1 -y
|
||||
sudo apt-get install libglx-mesa0 -y
|
||||
- name: Download & Extract Blender
|
||||
sudo apt-get install python3.11 -y
|
||||
- name: Install Requirements
|
||||
run: |
|
||||
wget -q $BLENDER_URL
|
||||
tar -xf $BLENDER_FILENAME
|
||||
rm -rf $BLENDER_FILENAME
|
||||
- name: Add Blender executable to path
|
||||
python3 -m pip install --upgrade pip
|
||||
python3 -m pip install virtualenv
|
||||
python3 -m virtualenv venv
|
||||
source venv/bin/activate
|
||||
pip install pytest-blender
|
||||
pip install blender-downloader
|
||||
- name: Install Blender
|
||||
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
|
||||
run: |
|
||||
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
|
||||
unzip -q ${{ env.ADDON_NAME }}-${{ steps.read_manifest.outputs.value }}.zip -d ./artifact
|
||||
popd
|
||||
- name: Run tests
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
pytest -svv tests
|
||||
- name: Archive addon
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
26
README.md
26
README.md
@@ -10,28 +10,21 @@ This software is licensed under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0
|
||||
|
||||
# Features
|
||||
* 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.
|
||||
* 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.
|
||||
* 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
|
||||
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:
|
||||
|
||||
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.
|
||||
For Blender 4.1 and lower, see [Legacy Compatibility](#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.
|
||||
|
||||
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.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 |
|
||||
| [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
|
||||
## Exporting a PSK
|
||||
@@ -69,7 +62,7 @@ Critical bug fixes may be issued for legacy addon versions that are under [Blend
|
||||
# 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.
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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)?
|
||||
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.
|
||||
|
||||
|
||||
@@ -70,18 +70,18 @@ else:
|
||||
import bpy
|
||||
from bpy.props import PointerProperty
|
||||
|
||||
classes = shared_types.classes +\
|
||||
psk_properties.classes +\
|
||||
psk_ui.classes +\
|
||||
psk_import_operators.classes +\
|
||||
psk_export_properties.classes +\
|
||||
psk_export_operators.classes +\
|
||||
classes = shared_types.classes + \
|
||||
psk_properties.classes + \
|
||||
psk_ui.classes + \
|
||||
psk_import_operators.classes + \
|
||||
psk_export_properties.classes + \
|
||||
psk_export_operators.classes + \
|
||||
psk_export_ui.classes + \
|
||||
psa_export_properties.classes +\
|
||||
psa_export_operators.classes +\
|
||||
psa_export_properties.classes + \
|
||||
psa_export_operators.classes + \
|
||||
psa_export_ui.classes + \
|
||||
psa_import_properties.classes +\
|
||||
psa_import_operators.classes +\
|
||||
psa_import_properties.classes + \
|
||||
psa_import_operators.classes + \
|
||||
psa_import_ui.classes
|
||||
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@ def _get_pose_bone_location_and_rotation(
|
||||
coordinate_system_transform: Matrix,
|
||||
has_false_root_bone: bool,
|
||||
) -> Tuple[Vector, Quaternion]:
|
||||
# TODO: my kingdom for a Rust monad.
|
||||
is_false_root_bone = pose_bone is None and armature_object is None
|
||||
|
||||
if is_false_root_bone:
|
||||
pose_bone_matrix = coordinate_system_transform
|
||||
elif pose_bone.parent is not None:
|
||||
@@ -82,7 +82,6 @@ def _get_pose_bone_location_and_rotation(
|
||||
location = pose_bone_matrix.to_translation()
|
||||
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.
|
||||
if not has_false_root_bone or (pose_bone is None or pose_bone.parent is not None):
|
||||
location *= scale
|
||||
@@ -112,12 +111,9 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
|
||||
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.
|
||||
# Note that the PSA bones are just here to validate the hierarchy. The bind pose information is not used by the
|
||||
# engine.
|
||||
# Note that the PSA bones are just here to validate the hierarchy.
|
||||
# The bind pose information is not used by the engine.
|
||||
psa.bones = [psx_bone for psx_bone, _ in psx_bone_create_result.bones]
|
||||
|
||||
# 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 = 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_action = options.animation_data.action
|
||||
|
||||
# Now build the PSA sequences.
|
||||
# We actually alter the timeline frame and simply record the resultant pose bone matrices.
|
||||
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.
|
||||
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
|
||||
|
||||
context.view_layer.update()
|
||||
@@ -217,13 +211,10 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
|
||||
export_bones: List[PsaExportBone] = []
|
||||
|
||||
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:
|
||||
export_bones.append(PsaExportBone(None, None, Vector((1.0, 1.0, 1.0))))
|
||||
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')]
|
||||
|
||||
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,
|
||||
export_bone.scale,
|
||||
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))
|
||||
|
||||
@@ -283,7 +274,7 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
|
||||
export_space=options.export_space,
|
||||
scale=export_bone.scale,
|
||||
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))
|
||||
|
||||
@@ -310,7 +301,7 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
|
||||
export_space=options.export_space,
|
||||
scale=export_bone.scale,
|
||||
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)
|
||||
|
||||
@@ -322,9 +313,9 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
|
||||
|
||||
context.window_manager.progress_update(export_sequence_index)
|
||||
|
||||
# Restore the previous action & frame.
|
||||
# TODO: store each armature object's previous action
|
||||
options.animation_data.action = saved_action
|
||||
# Restore the previous actions & frame.
|
||||
for armature_object, action in saved_armature_object_actions.items():
|
||||
armature_object.animation_data.action = action
|
||||
|
||||
context.scene.frame_set(saved_frame_current)
|
||||
|
||||
|
||||
@@ -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_normals = property_group.should_import_vertex_normals
|
||||
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.should_import_materials = property_group.should_import_materials
|
||||
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.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(props, 'import_components')
|
||||
col.prop(props, 'components')
|
||||
|
||||
if props.should_import_mesh:
|
||||
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.use_property_split = True
|
||||
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_materials', text='Materials')
|
||||
col.prop(props, 'should_import_vertex_colors', text='Vertex Colors')
|
||||
if props.should_import_vertex_colors:
|
||||
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')
|
||||
|
||||
if props.should_import_skeleton:
|
||||
skeleton_header, skeleton_panel = layout.panel('skeleton_panel_id', default_closed=False)
|
||||
skeleton_header.label(text='Skeleton', icon='OUTLINER_DATA_ARMATURE')
|
||||
if props.should_import_armature:
|
||||
armature_header, armature_panel = layout.panel('armature_panel_id', default_closed=False)
|
||||
armature_header.label(text='Armature', icon='OUTLINER_DATA_ARMATURE')
|
||||
|
||||
if skeleton_panel:
|
||||
row = skeleton_panel.row()
|
||||
if armature_panel:
|
||||
row = armature_panel.row()
|
||||
col = row.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
|
||||
@@ -20,7 +20,7 @@ class PskImportOptions:
|
||||
self.vertex_color_space = 'SRGB'
|
||||
self.should_import_vertex_normals = True
|
||||
self.should_import_extra_uvs = True
|
||||
self.should_import_skeleton = True
|
||||
self.should_import_armature = True
|
||||
self.should_import_shape_keys = True
|
||||
self.bone_length = 1.0
|
||||
self.should_import_materials = True
|
||||
@@ -62,7 +62,7 @@ def import_psk(psk: Psk, context: Context, name: str, options: PskImportOptions)
|
||||
armature_object = None
|
||||
mesh_object = None
|
||||
|
||||
if options.should_import_skeleton:
|
||||
if options.should_import_armature:
|
||||
# Armature
|
||||
armature_data = bpy.data.armatures.new(name)
|
||||
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))
|
||||
for vertex_color_index in range(len(psk.vertex_colors)):
|
||||
psk_vertex_colors[vertex_color_index,:] = psk.vertex_colors[vertex_color_index].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
|
||||
if options.vertex_color_space == '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]))
|
||||
|
||||
# Map the PSK vertex colors to the face corners.
|
||||
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)
|
||||
|
||||
# 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.object = 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)
|
||||
|
||||
try:
|
||||
|
||||
@@ -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):
|
||||
return self.import_components in {'ALL', 'MESH'}
|
||||
return self.components in {'ALL', 'MESH'}
|
||||
|
||||
|
||||
def should_import_skleton_get(self):
|
||||
return self.import_components in {'ALL', 'SKELETON'}
|
||||
return self.components in {'ALL', 'ARMATURE'}
|
||||
|
||||
|
||||
class PskImportMixin:
|
||||
@@ -73,7 +74,7 @@ class PskImportMixin:
|
||||
default=True,
|
||||
name='Import Vertex Normals',
|
||||
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(
|
||||
default=True,
|
||||
@@ -81,14 +82,14 @@ class PskImportMixin:
|
||||
options=set(),
|
||||
description='Import extra UV maps, if available'
|
||||
)
|
||||
import_components: EnumProperty(
|
||||
name='Import Components',
|
||||
components: EnumProperty(
|
||||
name='Components',
|
||||
options=set(),
|
||||
description='Determine which components to import',
|
||||
description='Which components to import',
|
||||
items=(
|
||||
('ALL', 'Mesh & Skeleton', 'Import mesh and skeleton'),
|
||||
('ALL', 'Mesh & Armature', 'Import mesh and armature'),
|
||||
('MESH', 'Mesh Only', 'Import mesh only'),
|
||||
('SKELETON', 'Skeleton Only', 'Import skeleton only'),
|
||||
('ARMATURE', 'Armature Only', 'Import armature only'),
|
||||
),
|
||||
default='ALL'
|
||||
)
|
||||
@@ -101,7 +102,7 @@ class PskImportMixin:
|
||||
name='Import Materials',
|
||||
options=set(),
|
||||
)
|
||||
should_import_skeleton: BoolProperty(
|
||||
should_import_armature: BoolProperty(
|
||||
name='Import Skeleton',
|
||||
get=should_import_skleton_get,
|
||||
)
|
||||
@@ -119,7 +120,7 @@ class PskImportMixin:
|
||||
default=True,
|
||||
name='Import Shape Keys',
|
||||
options=set(),
|
||||
description='Import shape keys, if available'
|
||||
description='Import shape keys, if available.\n\nThis is only supported for PSKX files'
|
||||
)
|
||||
scale: FloatProperty(
|
||||
name='Scale',
|
||||
|
||||
@@ -272,6 +272,10 @@ class PsxBoneCreateResult:
|
||||
self.armature_object_root_bone_indices = armature_object_root_bone_indices
|
||||
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(
|
||||
armature_objects: List[Object],
|
||||
|
||||
6
pyproject.toml
Normal file
6
pyproject.toml
Normal 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
0
tests/__init__.py
Normal file
BIN
tests/data/CS_Sarge_S0_Skelmesh.pskx
Normal file
BIN
tests/data/CS_Sarge_S0_Skelmesh.pskx
Normal file
Binary file not shown.
BIN
tests/data/Slurp_Monster_Axe_LOD0.psk
LFS
Normal file
BIN
tests/data/Slurp_Monster_Axe_LOD0.psk
LFS
Normal file
Binary file not shown.
BIN
tests/data/Suzanne.psk
LFS
Normal file
BIN
tests/data/Suzanne.psk
LFS
Normal file
Binary file not shown.
281
tests/psk_import_test.py
Normal file
281
tests/psk_import_test.py
Normal 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
1
tests/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
pytest
|
||||
Reference in New Issue
Block a user