Loads of changes

* Now asserting instead of throwing exceptions in some cases where we
don't want to fail gracefully
* Added "export space" option for PSA & PSK exports.
* Removed "Root Motion" option in PSA export, replace with a "Root"
  export space.
* Added support for exporting multiple armatures to a PSK (only works
  for collection exporter atm)
* More graceful error handling for IO errors (i.e., writing to a
   for which the user doesn't have permission)
* Proper handling for slotted actions.
This commit is contained in:
Colin Basnett
2025-03-28 22:48:08 -07:00
parent 1f6cbe7fb4
commit 91fe54f361
13 changed files with 394 additions and 234 deletions

View File

@@ -1,5 +1,3 @@
from typing import Optional
from bpy.types import Bone, Action, PoseBone
from .data import *
@@ -31,7 +29,6 @@ class PsaBuildOptions:
self.bone_collection_indices: List[int] = []
self.sequence_name_prefix: str = ''
self.sequence_name_suffix: str = ''
self.root_motion: bool = False
self.scale = 1.0
self.sampling_mode: str = 'INTERPOLATED' # One of ('INTERPOLATED', 'SUBFRAME')
self.export_space = 'WORLD'
@@ -39,18 +36,29 @@ class PsaBuildOptions:
self.up_axis = 'Z'
def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, root_motion: bool, scale: float, coordinate_system_transform: Matrix) -> Tuple[Vector, Quaternion]:
def _get_pose_bone_location_and_rotation(
pose_bone: PoseBone,
armature_object: Object,
export_space: str,
scale: Vector,
coordinate_system_transform: Matrix) -> Tuple[Vector, Quaternion]:
if pose_bone.parent is not None:
pose_bone_matrix = pose_bone.matrix
pose_bone_parent_matrix = pose_bone.parent.matrix
pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix
else:
if root_motion:
# Get the bone's pose matrix, taking the armature object's world matrix into account.
pose_bone_matrix = armature_object.matrix_world @ pose_bone.matrix
else:
# Use the bind pose matrix for the root bone.
# Get the bone's pose matrix and transform it into the export space.
# In the case of an 'ARMATURE' export space, this will be the inverse of armature object's world matrix.
# Otherwise, it will be the identity matrix.
# TODO: taking the pose bone matrix puts this in armature space.
pose_bone_matrix = Matrix.Identity(4)
match export_space:
case 'ARMATURE':
pose_bone_matrix = pose_bone.matrix
case 'WORLD':
pose_bone_matrix = armature_object.matrix_world @ pose_bone.matrix
case 'ROOT':
pose_bone_matrix = Matrix.Identity(4)
# The root bone is the only bone that should be transformed by the coordinate system transform, since all
# other bones are relative to their parent bones.
@@ -73,6 +81,8 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
psa = Psa()
armature_object = active_object
evaluated_armature_object = armature_object.evaluated_get(context.evaluated_depsgraph_get())
armature_data = typing.cast(Armature, armature_object.data)
bones: List[Bone] = list(iter(armature_data.bones))
@@ -94,17 +104,21 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
# The bone building code should be shared between the PSK and PSA exporters, since they both need to build a nearly identical bone list.
# TODO: The PSA bones are just here to validate the hierarchy. The pose information is not used by the engine.
# Build list of PSA bones.
psa.bones = convert_blender_bones_to_psx_bones(
bones=bones,
bone_class=Psa.Bone,
export_space=options.export_space,
armature_object_matrix_world=armature_object.matrix_world,
armature_object_matrix_world=armature_object.matrix_world, # evaluated_armature_object.matrix_world,
scale=options.scale,
forward_axis=options.forward_axis,
up_axis=options.up_axis
)
# We invert the export space matrix so that we neutralize the transform of the armature object.
export_space_matrix_inverse = get_export_space_matrix(options.export_space, armature_object)
# Add prefixes and suffices to the names of the export sequences and strip whitespace.
for export_sequence in options.sequences:
export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}'
@@ -124,7 +138,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
for export_sequence_index, export_sequence in enumerate(options.sequences):
# Look up the pose bones for the bones that are going to be exported.
pose_bones = [(bone_names.index(bone.name), bone) for bone in export_sequence.armature_object.pose.bones]
pose_bones = [(bone_names.index(bone.name), bone) for bone in armature_object.pose.bones]
pose_bones.sort(key=lambda x: x[0])
pose_bones = [x[1] for x in pose_bones]
pose_bones = [pose_bones[bone_index] for bone_index in bone_indices]
@@ -178,6 +192,13 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
key.time = 1.0 / psa_sequence.fps
psa.keys.append(key)
# TODO: In _get_pose_bone_location_and_rotation, we need to factor in the evaluated armature object's scale.
# Then also, if we're in ARMATURE export space, invert the pose bone matrix so that it's the identity matrix.
# TODO: extract the scale out of the evaluated_armature_object.matrix_world.
_, _, scale = evaluated_armature_object.matrix_world.decompose()
scale *= options.scale
match options.sampling_mode:
case 'INTERPOLATED':
# Used as a store for the last frame's pose bone locations and rotations.
@@ -203,9 +224,9 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(
pose_bone,
export_sequence.armature_object,
root_motion=options.root_motion,
scale=options.scale,
armature_object,
options.export_space,
scale,
coordinate_system_transform=coordinate_system_transform
)
last_frame_bone_poses.append((location, rotation))
@@ -225,9 +246,9 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(
pose_bone,
export_sequence.armature_object,
root_motion=options.root_motion,
scale=options.scale,
armature_object,
options.export_space,
scale,
coordinate_system_transform=coordinate_system_transform
)
next_frame_bone_poses.append((location, rotation))
@@ -249,7 +270,13 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
context.scene.frame_set(frame=int(frame), subframe=frame % 1.0)
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options)
location, rotation = _get_pose_bone_location_and_rotation(
pose_bone,
armature_object,
options.export_space,
scale,
coordinate_system_transform=coordinate_system_transform
)
add_key(location, rotation)
frame += frame_step

View File

@@ -27,7 +27,7 @@ def get_sequences_propnames_from_source(sequence_source: str) -> Optional[Tuple[
case 'ACTIVE_ACTION':
return 'active_action_list', 'active_action_list_index'
case _:
raise ValueError(f'Unhandled sequence source: {sequence_source}')
assert False, f'Invalid sequence source: {sequence_source}'
def is_action_for_object(obj: Object, action: Action):
@@ -84,7 +84,6 @@ def update_actions_and_timeline_markers(context: Context):
continue
for (name, frame_start, frame_end) in get_sequences_from_action(action):
print(name)
item = pg.action_list.add()
item.action = action
item.name = name
@@ -151,18 +150,18 @@ def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actio
# Get the minimum value of action metadata FPS values.
return min([action.psa_export.fps for action in actions])
case _:
raise RuntimeError(f'Invalid FPS source "{fps_source}"')
assert False, f'Invalid FPS source: {fps_source}'
def get_sequence_compression_ratio(compression_ratio_source: str, compression_ratio_custom: float, actions: Iterable[Action]) -> float:
match compression_ratio_source:
case 'ACTION_METADATA':
# Get the minimum value of action metadata compression ratio values.
return min([action.psa_export.compression_ratio for action in actions])
return min(map(lambda action: action.psa_export.compression_ratio, actions))
case 'CUSTOM':
return compression_ratio_custom
case _:
raise RuntimeError(f'Invalid compression ratio source "{compression_ratio_source}"')
assert False, f'Invalid compression ratio source: {compression_ratio_source}'
def get_animation_data_object(context: Context) -> Object:
@@ -389,7 +388,6 @@ class PSA_OT_export(Operator, ExportHelper):
flow = transform_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'root_motion')
flow.prop(pg, 'export_space')
flow.prop(pg, 'scale')
flow.prop(pg, 'forward_axis')
@@ -506,7 +504,7 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequence.key_quota = action.psa_export.key_quota
export_sequences.append(export_sequence)
case _:
raise ValueError(f'Unhandled sequence source: {pg.sequence_source}')
assert False, f'Invalid sequence source: {pg.sequence_source}'
if len(export_sequences) == 0:
self.report({'ERROR'}, 'No sequences were selected for export')
@@ -516,10 +514,9 @@ class PSA_OT_export(Operator, ExportHelper):
options.animation_data = animation_data
options.sequences = export_sequences
options.bone_filter_mode = pg.bone_filter_mode
options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected]
options.bone_collection_indices = [(x.armature_object_name, x.index) for x in pg.bone_collection_list if x.is_selected]
options.sequence_name_prefix = pg.sequence_name_prefix
options.sequence_name_suffix = pg.sequence_name_suffix
options.root_motion = pg.root_motion
options.scale = pg.scale
options.sampling_mode = pg.sampling_mode
options.export_space = pg.export_space

View File

@@ -103,14 +103,6 @@ def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context):
class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
root_motion: BoolProperty(
name='Root Motion',
options=empty_set,
default=False,
description='When enabled, the root bone will be transformed as it appears in the scene.\n\n'
'You might want to disable this if you are exporting an animation for an armature that is '
'attached to another object, such as a weapon or a shield',
)
should_override_animation_data: BoolProperty(
name='Override Animation Data',
options=empty_set,

View File

@@ -23,14 +23,14 @@ class PSA_UL_export_sequences(UIList):
row = layout.row(align=True)
row.alignment = 'RIGHT'
if item.frame_end < item.frame_start:
row.label(text='', icon='FRAME_PREV')
if is_pose_marker:
row.label(text=item.action.name, icon='PMARKER')
row.label(text=str(abs(item.frame_end - item.frame_start) + 1), icon='FRAME_PREV' if item.frame_end < item.frame_start else 'KEYFRAME')
if hasattr(item, 'armature_object') and item.armature_object is not None:
row.label(text=item.armature_object.name, icon='ARMATURE_DATA')
# row.label(text=item.action.name, icon='PMARKER' if is_pose_marker else 'ACTION_DATA')
def draw_filter(self, context, layout):
pg = getattr(context.scene, 'psa_export')
row = layout.row()

View File

@@ -93,7 +93,7 @@ class PsaImportMixin:
soft_max=60.0,
step=100,
)
compression_ratio_source: EnumProperty(name='Compression Ratio Source', items=compression_ratio_source_items)
compression_ratio_source: EnumProperty(name='Compression Ratio Source', items=compression_ratio_source_items, default='ACTION')
compression_ratio_custom: FloatProperty(
default=1.0,
name='Custom Compression Ratio',

View File

@@ -260,7 +260,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
case 'SEQUENCE':
target_fps = sequence.fps
case _:
raise ValueError(f'Unknown FPS source: {options.fps_source}')
assert False, f'Invalid FPS source: {options.fps_source}'
if options.should_write_keyframes:
# Remove existing f-curves.

View File

@@ -1,32 +1,36 @@
import typing
from typing import Optional
from collections import Counter
from typing import Dict, Generator, Set, Iterable, Optional, cast
import bmesh
import bpy
import numpy as np
from bpy.types import Material, Collection, Context
from bpy.types import Material, Collection, Context, Object, Armature, Bone
from .data import *
from .properties import triangle_type_and_bit_flags_to_poly_flags
from ..shared.dfs import dfs_collection_objects, dfs_view_layer_objects, DfsObject
from ..shared.helpers import *
from ..shared.helpers import get_coordinate_system_transform, convert_string_to_cp1252_bytes, \
get_export_bone_names, convert_blender_bones_to_psx_bones
class PskInputObjects(object):
def __init__(self):
self.mesh_objects: List[DfsObject] = []
self.armature_object: Optional[Object] = None
self.armature_objects: Set[Object] = set()
class PskBuildOptions(object):
def __init__(self):
self.bone_filter_mode = 'ALL'
self.bone_collection_indices: List[int] = []
self.bone_collection_indices: List[Tuple[str, int]] = []
self.object_eval_state = 'EVALUATED'
self.materials: List[Material] = []
self.scale = 1.0
self.export_space = 'WORLD'
self.forward_axis = 'X'
self.up_axis = 'Z'
self.root_bone_name = 'ROOT'
def get_mesh_objects_for_collection(collection: Collection) -> Iterable[DfsObject]:
@@ -39,26 +43,24 @@ def get_mesh_objects_for_context(context: Context) -> Iterable[DfsObject]:
yield dfs_object
def get_armature_for_mesh_objects(mesh_objects: Iterable[Object]) -> Optional[Object]:
def get_armature_for_mesh_object(mesh_object: Object) -> Optional[Object]:
for modifier in mesh_object.modifiers:
if modifier.type == 'ARMATURE':
return modifier.object
return None
def get_armatures_for_mesh_objects(mesh_objects: Iterable[Object]) -> Generator[Object, None, None]:
# Ensure that there are either no armature modifiers (static mesh) or that there is exactly one armature modifier
# object shared between all meshes.
armature_modifier_objects = set()
armature_objects = set()
for mesh_object in mesh_objects:
modifiers = [x for x in mesh_object.modifiers if x.type == 'ARMATURE']
if len(modifiers) == 0:
continue
elif len(modifiers) > 1:
raise RuntimeError(f'Mesh "{mesh_object.name}" must have only one armature modifier')
armature_modifier_objects.add(modifiers[0].object)
if len(armature_modifier_objects) > 1:
armature_modifier_names = [x.name for x in armature_modifier_objects]
raise RuntimeError(
f'All meshes must have the same armature modifier, encountered {len(armature_modifier_names)} ({", ".join(armature_modifier_names)})')
elif len(armature_modifier_objects) == 1:
return list(armature_modifier_objects)[0]
else:
return None
if modifiers[0].object in armature_objects:
continue
yield modifiers[0].object
def _get_psk_input_objects(mesh_objects: Iterable[DfsObject]) -> PskInputObjects:
@@ -68,7 +70,7 @@ def _get_psk_input_objects(mesh_objects: Iterable[DfsObject]) -> PskInputObjects
input_objects = PskInputObjects()
input_objects.mesh_objects = mesh_objects
input_objects.armature_object = get_armature_for_mesh_objects([x.obj for x in mesh_objects])
input_objects.armature_objects |= set(get_armatures_for_mesh_objects(map(lambda x: x.obj, mesh_objects)))
return input_objects
@@ -91,97 +93,32 @@ class PskBuildResult(object):
self.warnings: List[str] = []
def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult:
armature_object: bpy.types.Object = input_objects.armature_object
def _get_mesh_export_space_matrix(armature_objects: Iterable[Object], export_space: str) -> Matrix:
if not armature_objects:
return Matrix.Identity(4)
result = PskBuildResult()
psk = Psk()
bones = []
def get_object_space_space_matrix(obj: Object) -> Matrix:
translation, rotation, _ = obj.matrix_world.decompose()
# We neutralize the scale here because the scale is already applied to the mesh objects implicitly.
return Matrix.Translation(translation) @ rotation.to_matrix().to_4x4()
def get_export_space_matrix():
match options.export_space:
match export_space:
case 'WORLD':
return Matrix.Identity(4)
case 'ARMATURE':
if armature_object is not None:
return armature_object.matrix_world.inverted()
else:
return Matrix.Identity(4)
return get_object_space_space_matrix(armature_objects[0]).inverted()
case 'ROOT':
# TODO: multiply this by the root bone's local matrix
armature_object = armature_objects[0]
armature_data = cast(armature_object.data, Armature)
armature_space_matrix = get_object_space_space_matrix(armature_object) @ armature_data.bones[0].matrix_local
return armature_space_matrix.inverted()
case _:
raise ValueError(f'Invalid export space: {options.export_space}')
assert False, f'Invalid export space: {export_space}'
coordinate_system_matrix = get_coordinate_system_transform(options.forward_axis, options.up_axis)
coordinate_system_default_rotation = coordinate_system_matrix.to_quaternion()
export_space_matrix = get_export_space_matrix() # TODO: maybe neutralize the scale here?
scale_matrix = Matrix.Scale(options.scale, 4)
# We effectively need 3 transforms, I think:
# 1. The transform for the mesh vertices.
# 2. The transform for the bone locations.
# 3. The transform for the bone rotations.
if armature_object is None or len(armature_object.data.bones) == 0:
# If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the
# requirement that a PSK file must have at least one bone.
psk_bone = Psk.Bone()
psk_bone.name = bytes('root', encoding='windows-1252')
psk_bone.flags = 0
psk_bone.children_count = 0
psk_bone.parent_index = 0
psk_bone.location = Vector3.zero()
psk_bone.rotation = Quaternion.from_bpy_quaternion(coordinate_system_default_rotation)
psk.bones.append(psk_bone)
else:
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_indices)
armature_data = typing.cast(Armature, armature_object.data)
bones = [armature_data.bones[bone_name] for bone_name in bone_names]
psk.bones = convert_blender_bones_to_psx_bones(
bones, Psk.Bone,
options.export_space,
armature_object.matrix_world,
options.scale,
options.forward_axis,
options.up_axis
)
# MATERIALS
for material in options.materials:
psk_material = Psk.Material()
try:
psk_material.name = bytes(material.name, encoding='windows-1252')
except UnicodeEncodeError:
raise RuntimeError(f'Material name "{material.name}" contains characters that cannot be encoded in the Windows-1252 codepage')
psk_material.texture_index = len(psk.materials)
psk_material.poly_flags = triangle_type_and_bit_flags_to_poly_flags(material.psk.mesh_triangle_type,
material.psk.mesh_triangle_bit_flags)
psk.materials.append(psk_material)
# TODO: This wasn't left in a good state. We should detect if we need to add a "default" material.
# This can be done by checking if there is an empty material slot on any of the mesh objects, or if there are
# no material slots on any of the mesh objects.
# If so, it should be added to the end of the list of materials, and its index should mapped to a None value in the
# material indices list.
if len(psk.materials) == 0:
# Add a default material if no materials are present.
psk_material = Psk.Material()
psk_material.name = bytes('None', encoding='windows-1252')
psk.materials.append(psk_material)
context.window_manager.progress_begin(0, len(input_objects.mesh_objects))
material_names = [m.name for m in options.materials]
vertex_transform_matrix = scale_matrix @ coordinate_system_matrix @ export_space_matrix
for object_index, input_mesh_object in enumerate(input_objects.mesh_objects):
obj, instance_objects, matrix_world = input_mesh_object.obj, input_mesh_object.instance_objects, input_mesh_object.matrix_world
should_flip_normals = False
def get_material_name_indices(obj: Object, material_names: List[str]) -> Iterable[int]:
def _get_material_name_indices(obj: Object, material_names: List[str]) -> Iterable[int]:
'''
Returns the index of the material in the list of material names.
If the material is not found, the index 0 is returned.
@@ -195,70 +132,198 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
except ValueError:
yield 0
# MATERIALS
material_indices = list(get_material_name_indices(obj, material_names))
def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult:
armature_objects = list(input_objects.armature_objects)
result = PskBuildResult()
psk = Psk()
bones: List[Bone] = []
if options.export_space != 'WORLD' and len(armature_objects) > 1:
raise RuntimeError('When exporting multiple armatures, the Export Space must be World')
coordinate_system_matrix = get_coordinate_system_transform(options.forward_axis, options.up_axis)
coordinate_system_default_rotation = coordinate_system_matrix.to_quaternion()
scale_matrix = Matrix.Scale(options.scale, 4)
total_bone_count = sum(len(armature_object.data.bones) for armature_object in armature_objects)
# Store the index of the root bone for each armature object.
# We will need this later to correctly assign vertex weights.
armature_object_root_bone_indices = dict()
# Store the bone names to be exported for each armature object.
armature_object_bone_names: Dict[Object, List[str]] = dict()
for armature_object in armature_objects:
bone_collection_indices = [x[1] for x in options.bone_collection_indices if x[0] == armature_object.name]
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, bone_collection_indices)
armature_object_bone_names[armature_object] = bone_names
if len(armature_objects) == 0 or total_bone_count == 0:
# If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the
# requirement that a PSK file must have at least one bone.
psk_bone = Psk.Bone()
psk_bone.name = convert_string_to_cp1252_bytes(options.root_bone_name)
psk_bone.flags = 0
psk_bone.children_count = 0
psk_bone.parent_index = 0
psk_bone.location = Vector3.zero()
psk_bone.rotation = Quaternion.from_bpy_quaternion(coordinate_system_default_rotation)
psk.bones.append(psk_bone)
armature_object_root_bone_indices[None] = 0
else:
# If we have multiple armature objects, create a root bone at the world origin.
if len(armature_objects) > 1:
psk_bone = Psk.Bone()
psk_bone.name = convert_string_to_cp1252_bytes(options.root_bone_name)
psk_bone.flags = 0
psk_bone.children_count = total_bone_count
psk_bone.parent_index = 0
psk_bone.location = Vector3.zero()
psk_bone.rotation = Quaternion.from_bpy_quaternion(coordinate_system_default_rotation)
psk.bones.append(psk_bone)
armature_object_root_bone_indices[None] = 0
root_bone = psk.bones[0] if len(psk.bones) > 0 else None
for armature_object in armature_objects:
bone_names = armature_object_bone_names[armature_object]
armature_data = typing.cast(Armature, armature_object.data)
bones = [armature_data.bones[bone_name] for bone_name in bone_names]
psk_bones = convert_blender_bones_to_psx_bones(
bones=bones,
bone_class=Psk.Bone,
export_space=options.export_space,
armature_object_matrix_world=armature_object.matrix_world,
scale=options.scale,
forward_axis=options.forward_axis,
up_axis=options.up_axis,
root_bone=root_bone,
)
# If we are appending these bones to an existing list of bones, we need to adjust the parent indices.
if len(psk.bones) > 0:
parent_index_offset = len(psk.bones)
for bone in psk_bones[1:]:
bone.parent_index += parent_index_offset
armature_object_root_bone_indices[armature_object] = len(psk.bones)
psk.bones.extend(psk_bones)
# Check if there are bone name conflicts between armatures.
bone_name_counts = Counter(x.name.decode('windows-1252').upper() for x in psk.bones)
for bone_name, count in bone_name_counts.items():
if count > 1:
raise RuntimeError(f'Found {count} bones with the name "{bone_name}". Bone names must be unique when compared case-insensitively.')
# Materials
for material in options.materials:
psk_material = Psk.Material()
psk_material.name = convert_string_to_cp1252_bytes(material.name)
psk_material.texture_index = len(psk.materials)
psk_material.poly_flags = triangle_type_and_bit_flags_to_poly_flags(material.psk.mesh_triangle_type,
material.psk.mesh_triangle_bit_flags)
psk.materials.append(psk_material)
# TODO: This wasn't left in a good state. We should detect if we need to add a "default" material.
# This can be done by checking if there is an empty material slot on any of the mesh objects, or if there are
# no material slots on any of the mesh objects.
# If so, it should be added to the end of the list of materials, and its index should mapped to a None value in the
# material indices list.
if len(psk.materials) == 0:
# Add a default material if no materials are present.
psk_material = Psk.Material()
psk_material.name = convert_string_to_cp1252_bytes('None')
psk.materials.append(psk_material)
context.window_manager.progress_begin(0, len(input_objects.mesh_objects))
mesh_export_space_matrix = _get_mesh_export_space_matrix(armature_objects, options.export_space)
vertex_transform_matrix = scale_matrix @ coordinate_system_matrix @ mesh_export_space_matrix
original_armature_object_pose_positions = {armature_object: armature_object.data.pose_position for armature_object in armature_objects}
# Temporarily force the armature into the rest position.
# We will undo this later.
for armature_object in armature_objects:
armature_object.data.pose_position = 'REST'
material_names = [m.name for m in options.materials]
for object_index, input_mesh_object in enumerate(input_objects.mesh_objects):
obj, instance_objects, matrix_world = input_mesh_object.obj, input_mesh_object.instance_objects, input_mesh_object.matrix_world
armature_object = get_armature_for_mesh_object(obj)
should_flip_normals = False
# Material indices
material_indices = list(_get_material_name_indices(obj, material_names))
if len(material_indices) == 0:
# Add a default material if no materials are present.
material_indices = [0]
# MESH DATA
# Store the reference to the evaluated object and data so that we can clean them up later.
evaluated_mesh_object = None
evaluated_mesh_data = None
# Mesh data
match options.object_eval_state:
case 'ORIGINAL':
mesh_object = obj
mesh_data = obj.data
case 'EVALUATED':
# Create a copy of the mesh object after non-armature modifiers are applied.
# Temporarily force the armature into the rest position.
# We will undo this later.
old_pose_position = None
if armature_object is not None:
old_pose_position = armature_object.data.pose_position
armature_object.data.pose_position = 'REST'
depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new()
try:
bm.from_object(obj, depsgraph)
except ValueError:
except ValueError as e:
del bm
raise RuntimeError(f'Object "{obj.name}" is not evaluated.\n'
'This is likely because the object is in a collection that has been excluded from the view layer.')
'This is likely because the object is in a collection that has been excluded from the view layer.') from e
mesh_data = bpy.data.meshes.new('')
evaluated_mesh_data = bpy.data.meshes.new('')
mesh_data = evaluated_mesh_data
bm.to_mesh(mesh_data)
del bm
mesh_object = bpy.data.objects.new('', mesh_data)
evaluated_mesh_object = bpy.data.objects.new('', mesh_data)
mesh_object = evaluated_mesh_object
mesh_object.matrix_world = matrix_world
# Extract the scale from the matrix.
_, _, scale = matrix_world.decompose()
# Negative scaling in Blender results in inverted normals after the scale is applied. However, if the scale
# is not applied, the normals will appear unaffected in the viewport. The evaluated mesh data used in the
# export will have the scale applied, but this behavior is not obvious to the user.
# Negative scaling in Blender results in inverted normals after the scale is applied. However, if the
# scale is not applied, the normals will appear unaffected in the viewport. The evaluated mesh data used
# in the export will have the scale applied, but this behavior is not obvious to the user.
#
# In order to have the exporter be as WYSIWYG as possible, we need to check for negative scaling and invert
# the normals if necessary. If two axes have negative scaling and the third has positive scaling, the
# normals will be correct. We can detect this by checking if the number of negative scaling axes is odd. If
# it is, we need to invert the normals of the mesh by swapping the order of the vertices in each face.
# In order to have the exporter be as WYSIWYG as possible, we need to check for negative scaling and
# invert the normals if necessary. If two axes have negative scaling and the third has positive scaling,
# the normals will be correct. We can detect this by checking if the number of negative scaling axes is
# odd. If it is, we need to invert the normals of the mesh by swapping the order of the vertices in each
# face.
should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1
# Copy the vertex groups
for vertex_group in obj.vertex_groups:
mesh_object.vertex_groups.new(name=vertex_group.name)
# Restore the previous pose position on the armature.
if old_pose_position is not None:
armature_object.data.pose_position = old_pose_position
case _:
raise ValueError(f'Invalid object evaluation state: {options.object_eval_state}')
assert False, f'Invalid object evaluation state: {options.object_eval_state}'
vertex_offset = len(psk.points)
point_transform_matrix = vertex_transform_matrix @ mesh_object.matrix_world
# VERTICES
# Vertices
for vertex in mesh_data.vertices:
point = Vector3()
v = point_transform_matrix @ vertex.co
@@ -269,7 +334,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
uv_layer = mesh_data.uv_layers.active.data
# WEDGES
# Wedges
mesh_data.calc_loop_triangles()
# Build a list of non-unique wedges.
@@ -286,7 +351,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
for loop_index in triangle.loops:
wedges[loop_index].material_index = material_indices[triangle.material_index]
# Populate the list of wedges with unique wedges & build a look-up table of loop indices to wedge indices
# Populate the list of wedges with unique wedges & build a look-up table of loop indices to wedge indices.
wedge_indices = dict()
loop_wedge_indices = np.full(len(mesh_data.loops), -1)
for loop_index, wedge in enumerate(wedges):
@@ -299,7 +364,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
psk.wedges.append(wedge)
loop_wedge_indices[loop_index] = wedge_index
# FACES
# Faces
poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=True)
psk_face_start_index = len(psk.faces)
for f in mesh_data.loop_triangles:
@@ -316,17 +381,19 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
for face in psk.faces[psk_face_start_index:]:
face.wedge_indices[0], face.wedge_indices[2] = face.wedge_indices[2], face.wedge_indices[0]
# WEIGHTS
# Weights
if armature_object is not None:
armature_data = typing.cast(Armature, armature_object.data)
bone_index_offset = armature_object_root_bone_indices[armature_object]
# Because the vertex groups may contain entries for which there is no matching bone in the armature,
# we must filter them out and not export any weights for these vertex groups.
bone_names = [x.name for x in bones]
bone_names = armature_object_bone_names[armature_object]
vertex_group_names = [x.name for x in mesh_object.vertex_groups]
vertex_group_bone_indices = dict()
vertex_group_bone_indices: Dict[int, int] = dict()
for vertex_group_index, vertex_group_name in enumerate(vertex_group_names):
try:
vertex_group_bone_indices[vertex_group_index] = bone_names.index(vertex_group_name)
vertex_group_bone_indices[vertex_group_index] = bone_names.index(vertex_group_name) + bone_index_offset
except ValueError:
# The vertex group does not have a matching bone in the list of bones to be exported.
# Check to see if there is an associated bone for this vertex group that exists in the armature.
@@ -336,8 +403,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
bone = armature_data.bones[vertex_group_name]
while bone is not None:
try:
bone_index = bone_names.index(bone.name)
vertex_group_bone_indices[vertex_group_index] = bone_index
vertex_group_bone_indices[vertex_group_index] = bone_names.index(bone.name) + bone_index_offset
break
except ValueError:
bone = bone.parent
@@ -366,22 +432,30 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
psk.weights.append(w)
vertices_assigned_weights[vertex_index] = True
# Assign vertices that have not been assigned weights to the root bone.
# Assign vertices that have not been assigned weights to the root bone of the armature.
fallback_weight_bone_index = armature_object_root_bone_indices[armature_object]
for vertex_index, assigned in enumerate(vertices_assigned_weights):
if not assigned:
w = Psk.Weight()
w.bone_index = 0
w.bone_index = fallback_weight_bone_index
w.point_index = vertex_offset + vertex_index
w.weight = 1.0
psk.weights.append(w)
if options.object_eval_state == 'EVALUATED':
if evaluated_mesh_object is not None:
bpy.data.objects.remove(mesh_object)
del mesh_object
if evaluated_mesh_data is not None:
bpy.data.meshes.remove(mesh_data)
del mesh_data
context.window_manager.progress_update(object_index)
# Restore the original pose position of the armature objects.
for armature_object, pose_position in original_armature_object_pose_positions.items():
armature_object.data.pose_position = pose_position
context.window_manager.progress_end()
result.psk = psk

View File

@@ -1,3 +1,4 @@
from pathlib import Path
from typing import List, Optional, cast, Iterable
import bpy
@@ -75,10 +76,10 @@ class PSK_OT_populate_bone_collection_list(Operator):
except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'}
if input_objects.armature_object is None:
self.report({'ERROR_INVALID_CONTEXT'}, 'No armature found in collection')
if not input_objects.armature_objects:
self.report({'ERROR_INVALID_CONTEXT'}, 'No armature modifiers found on mesh objects')
return {'CANCELLED'}
populate_bone_collection_list(input_objects.armature_object, export_operator.bone_collection_list)
populate_bone_collection_list(input_objects.armature_objects, export_operator.bone_collection_list)
return {'FINISHED'}
@@ -236,10 +237,11 @@ def get_psk_build_options_from_property_group(pg: 'PSK_PG_export') -> PskBuildOp
options.object_eval_state = pg.object_eval_state
options.export_space = pg.export_space
options.bone_filter_mode = pg.bone_filter_mode
options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected]
options.bone_collection_indices = [(x.armature_object_name, x.index) for x in pg.bone_collection_list if x.is_selected]
options.scale = pg.scale
options.forward_axis = pg.forward_axis
options.up_axis = pg.up_axis
options.root_bone_name = pg.root_bone_name
# TODO: perhaps move this into the build function and replace the materials list with a material names list.
# materials = get_materials_for_mesh_objects(depsgraph, mesh_objects)
@@ -276,12 +278,13 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
return {'CANCELLED'}
options = get_psk_build_options_from_property_group(self)
filepath = str(Path(self.filepath).resolve())
try:
result = build_psk(context, input_objects, options)
for warning in result.warnings:
self.report({'WARNING'}, warning)
write_psk(result.psk, self.filepath)
write_psk(result.psk, filepath)
if len(result.warnings) > 0:
self.report({'WARNING'}, f'PSK export successful with {len(result.warnings)} warnings')
else:
@@ -299,7 +302,7 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
flow.use_property_split = True
flow.use_property_decorate = False
# MESH
# Mesh
mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False)
mesh_header.label(text='Mesh', icon='MESH_DATA')
if mesh_panel:
@@ -309,17 +312,26 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
flow.prop(self, 'object_eval_state', text='Data')
flow.prop(self, 'should_exclude_hidden_meshes')
# BONES
# Bones
bones_header, bones_panel = layout.panel('Bones', default_closed=False)
bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel:
draw_bone_filter_mode(bones_panel, self, True)
if self.bone_filter_mode == 'BONE_COLLECTIONS':
bones_panel.operator(PSK_OT_populate_bone_collection_list.bl_idname, icon='FILE_REFRESH')
rows = max(3, min(len(self.bone_collection_list), 10))
bones_panel.template_list('PSX_UL_bone_collection_list', '', self, 'bone_collection_list', self, 'bone_collection_list_index', rows=rows)
# MATERIALS
advanced_bones_header, advanced_bones_panel = bones_panel.panel('Advanced', default_closed=True)
advanced_bones_header.label(text='Advanced')
if advanced_bones_panel:
flow = advanced_bones_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'root_bone_name')
# Materials
materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials', icon='MATERIAL')
@@ -334,7 +346,7 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
col.separator()
col.operator(PSK_OT_material_list_name_add.bl_idname, text='', icon='ADD')
# TRANSFORM
# Transform
transform_header, transform_panel = layout.panel('Transform', default_closed=False)
transform_header.label(text='Transform')
if transform_panel:
@@ -368,13 +380,9 @@ class PSK_OT_export(Operator, ExportHelper):
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'}
if len(input_objects.mesh_objects) == 0:
self.report({'ERROR_INVALID_CONTEXT'}, 'No mesh objects selected')
return {'CANCELLED'}
pg = getattr(context.scene, 'psk_export')
populate_bone_collection_list(input_objects.armature_object, pg.bone_collection_list)
populate_bone_collection_list(input_objects.armature_objects, pg.bone_collection_list)
depsgraph = context.evaluated_depsgraph_get()
@@ -393,7 +401,7 @@ class PSK_OT_export(Operator, ExportHelper):
pg = getattr(context.scene, 'psk_export')
# MESH
# Mesh
mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False)
mesh_header.label(text='Mesh', icon='MESH_DATA')
if mesh_panel:
@@ -402,7 +410,7 @@ class PSK_OT_export(Operator, ExportHelper):
flow.use_property_decorate = False
flow.prop(pg, 'object_eval_state', text='Data')
# BONES
# Bones
bones_header, bones_panel = layout.panel('Bones', default_closed=False)
bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel:
@@ -412,7 +420,7 @@ class PSK_OT_export(Operator, ExportHelper):
rows = max(3, min(len(pg.bone_collection_list), 10))
row.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', rows=rows)
# MATERIALS
# Materials
materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials', icon='MATERIAL')
if materials_panel:
@@ -423,6 +431,18 @@ class PSK_OT_export(Operator, ExportHelper):
col.operator(PSK_OT_material_list_move_up.bl_idname, text='', icon='TRIA_UP')
col.operator(PSK_OT_material_list_move_down.bl_idname, text='', icon='TRIA_DOWN')
# Transform
transform_header, transform_panel = layout.panel('Transform', default_closed=False)
transform_header.label(text='Transform')
if transform_panel:
flow = transform_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'export_space')
flow.prop(pg, 'scale')
flow.prop(pg, 'forward_axis')
flow.prop(pg, 'up_axis')
def execute(self, context):
pg = getattr(context.scene, 'psk_export')

View File

@@ -12,11 +12,6 @@ object_eval_state_items = (
('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'),
)
export_space_items = [
('WORLD', 'World', 'Export in world space'),
('ARMATURE', 'Armature', 'Export in armature space'),
]
class PSK_PG_material_list_item(PropertyGroup):
material: PointerProperty(type=Material)
index: IntProperty()
@@ -54,6 +49,11 @@ class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin):
bone_collection_list_index: IntProperty(default=0)
material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item)
material_name_list_index: IntProperty(default=0)
root_bone_name: StringProperty(
name='Root Bone Name',
description='The name of the generated root bone when exporting multiple armatures',
default='ROOT',
)
class PSK_PG_export(PropertyGroup, PskExportMixin):

View File

@@ -32,12 +32,13 @@ def write_psk(psk: Psk, path: str):
raise RuntimeError(f'Number of materials ({len(psk.materials)}) exceeds limit of {MAX_MATERIAL_COUNT}')
if len(psk.bones) > MAX_BONE_COUNT:
raise RuntimeError(f'Number of bones ({len(psk.bones)}) exceeds limit of {MAX_BONE_COUNT}')
elif len(psk.bones) == 0:
if len(psk.bones) == 0:
raise RuntimeError(f'At least one bone must be marked for export')
# Make the directory for the file if it doesn't exist.
os.makedirs(os.path.dirname(path), exist_ok=True)
try:
with open(path, 'wb') as fp:
_write_section(fp, b'ACTRHEAD')
_write_section(fp, b'PNTS0000', Vector3, psk.points)
@@ -56,3 +57,5 @@ def write_psk(psk: Psk, path: str):
_write_section(fp, b'MATT0000', Psk.Material, psk.materials)
_write_section(fp, b'REFSKELT', Psk.Bone, psk.bones)
_write_section(fp, b'RAWWEIGHTS', Psk.Weight, psk.weights)
except PermissionError as e:
raise RuntimeError(f'The current user "{os.getlogin()}" does not have permission to write to "{path}"') from e

View File

@@ -161,13 +161,13 @@ class ForwardUpAxisMixin:
export_space_items = [
('WORLD', 'World', 'Export in world space'),
('ARMATURE', 'Armature', 'Export in armature space'),
('ARMATURE', 'Armature', 'Export the local space of the armature object'),
('ROOT', 'Root', 'Export in the space of the root bone')
]
class ExportSpaceMixin:
export_space: EnumProperty(
name='Export Space',
description='Space to export the mesh in',
items=export_space_items,
default='WORLD'
)

View File

@@ -29,7 +29,7 @@ def get_nla_strips_in_frame_range(animation_data: AnimData, frame_min: float, fr
yield strip
def populate_bone_collection_list(armature_object: Object, bone_collection_list: CollectionProperty) -> None:
def populate_bone_collection_list(armature_objects: Iterable[Object], bone_collection_list: CollectionProperty) -> None:
"""
Updates the bone collections collection.
@@ -39,7 +39,7 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
has_selected_collections = any([g.is_selected for g in bone_collection_list])
unassigned_collection_is_selected, selected_assigned_collection_names = True, []
if armature_object is None:
if not armature_objects:
return
if has_selected_collections:
@@ -54,13 +54,15 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
bone_collection_list.clear()
for armature_object in armature_objects:
armature = cast(Armature, armature_object.data)
if armature is None:
return
item = bone_collection_list.add()
item.name = 'Unassigned'
item.armature_object_name = armature_object.name
item.name = 'Unassigned' # TODO: localize
item.index = -1
# Count the number of bones without an assigned bone collection
item.count = sum(map(lambda bone: 1 if len(bone.collections) == 0 else 0, armature.bones))
@@ -68,6 +70,7 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
for bone_collection_index, bone_collection in enumerate(armature.collections_all):
item = bone_collection_list.add()
item.armature_object_name = armature_object.name
item.name = bone_collection.name
item.index = bone_collection_index
item.count = len(bone_collection.bones)
@@ -131,6 +134,7 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
bone_names = [bones[x[0]].name for x in bone_indices]
# Ensure that the hierarchy we are sending back has a single root bone.
# TODO: This is only relevant if we are exporting a single armature; how should we reorganize this call?
bone_indices = [x[0] for x in bone_indices]
root_bones = [bones[bone_index] for bone_index in bone_indices if bones[bone_index].parent is None]
if len(root_bones) > 1:
@@ -159,20 +163,25 @@ def is_bdk_addon_loaded() -> bool:
return 'bdk' in dir(bpy.ops)
def convert_string_to_cp1252_bytes(string: str) -> bytes:
try:
return bytes(string, encoding='windows-1252')
except UnicodeEncodeError as e:
raise RuntimeError(f'The string "{string}" contains characters that cannot be encoded in the Windows-1252 codepage') from e
# TODO: Perhaps export space should just be a transform matrix, since the below is not actually used unless we're using WORLD space.
def convert_blender_bones_to_psx_bones(
bones: List[bpy.types.Bone],
bones: Iterable[bpy.types.Bone],
bone_class: type,
export_space: str = 'WORLD', # perhaps export space should just be a transform matrix, since the below is not actually used unless we're using WORLD space.
export_space: str = 'WORLD',
armature_object_matrix_world: Matrix = Matrix.Identity(4),
scale = 1.0,
forward_axis: str = 'X',
up_axis: str = 'Z'
) -> Iterable[type]:
'''
Function that converts a Blender bone list into a bone list that
@param bones:
@return:
'''
up_axis: str = 'Z',
root_bone: Optional = None,
) -> Iterable:
scale_matrix = Matrix.Scale(scale, 4)
coordinate_system_transform = get_coordinate_system_transform(forward_axis, up_axis)
@@ -181,16 +190,7 @@ def convert_blender_bones_to_psx_bones(
psx_bones = []
for bone in bones:
psx_bone = bone_class()
try:
psx_bone.name = bytes(bone.name, encoding='windows-1252')
except UnicodeEncodeError:
raise RuntimeError(
f'Bone name "{bone.name}" contains characters that cannot be encoded in the Windows-1252 codepage')
# TODO: flags & children_count should be initialized to zero anyways, so we can probably remove these lines?
psx_bone.flags = 0
psx_bone.children_count = 0
psx_bone.name = convert_string_to_cp1252_bytes(bone.name)
try:
parent_index = bones.index(bone.parent)
@@ -205,6 +205,22 @@ def convert_blender_bones_to_psx_bones(
parent_head = inverse_parent_rotation @ bone.parent.head
parent_tail = inverse_parent_rotation @ bone.parent.tail
location = (parent_tail - parent_head) + bone.head
elif bone.parent is None and root_bone is not None:
# This is a special case for the root bone when export
# Because the root bone and child bones are in different spaces, we need to treat the root bone of this
# armature as though it were a child bone.
bone_rotation = bone.matrix.to_quaternion().conjugated()
local_rotation = armature_object_matrix_world.to_3x3().to_quaternion().conjugated()
rotation = bone_rotation @ local_rotation
translation, _, scale = armature_object_matrix_world.decompose()
# Invert the scale of the armature object matrix.
inverse_scale_matrix = Matrix.Identity(4)
inverse_scale_matrix[0][0] = 1.0 / scale.x
inverse_scale_matrix[1][1] = 1.0 / scale.y
inverse_scale_matrix[2][2] = 1.0 / scale.z
translation = translation @ inverse_scale_matrix
location = translation + bone.head
else:
def get_armature_local_matrix():
match export_space:
@@ -212,8 +228,10 @@ def convert_blender_bones_to_psx_bones(
return armature_object_matrix_world
case 'ARMATURE':
return Matrix.Identity(4)
case 'ROOT':
return bone.matrix.inverted()
case _:
raise ValueError(f'Invalid export space: {export_space}')
assert False, f'Invalid export space: {export_space}'
armature_local_matrix = get_armature_local_matrix()
location = armature_local_matrix @ bone.head
@@ -244,3 +262,23 @@ def convert_blender_bones_to_psx_bones(
psx_bones.append(psx_bone)
return psx_bones
# TODO: we need two different ones for the PSK and PSA.
# TODO: Figure out in what "space" the root bone is in for PSA animations.
# Maybe make a set of space-switching functions to make this easier to follow and figure out.
def get_export_space_matrix(export_space: str, armature_object: Optional[Object] = None) -> Matrix:
match export_space:
case 'WORLD':
return Matrix.Identity(4)
case 'ARMATURE':
# We do not care about the scale when dealing with export spaces, only the translation and rotation.
if armature_object is not None:
translation, rotation, _ = armature_object.matrix_world.decompose()
return (rotation.to_matrix().to_4x4() @ Matrix.Translation(translation)).inverted()
else:
return Matrix.Identity(4)
case 'ROOT':
pass
case _:
assert False, f'Invalid export space: {export_space}'

View File

@@ -1,3 +1,4 @@
import bpy
from bpy.props import StringProperty, IntProperty, BoolProperty, FloatProperty
from bpy.types import PropertyGroup, UIList, UILayout, Context, AnyType, Panel
@@ -7,11 +8,19 @@ class PSX_UL_bone_collection_list(UIList):
def draw_item(self, context: Context, layout: UILayout, data: AnyType, item: AnyType, icon: int,
active_data: AnyType, active_property: str, index: int = 0, flt_flag: int = 0):
row = layout.row()
row.prop(item, 'is_selected', text=getattr(item, 'name'))
row.label(text=str(getattr(item, 'count')), icon='BONE_DATA')
armature_object = bpy.data.objects.get(item.armature_object_name, None)
if armature_object is None:
row.label(icon='ERROR')
else:
row.label(text=armature_object.name, icon='ARMATURE_DATA')
class PSX_PG_bone_collection_list_item(PropertyGroup):
armature_object_name: StringProperty()
name: StringProperty()
index: IntProperty()
count: IntProperty()