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:
@@ -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.
|
||||
pose_bone_matrix = pose_bone.matrix
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,68 +93,140 @@ class PskBuildResult(object):
|
||||
self.warnings: List[str] = []
|
||||
|
||||
|
||||
def _get_mesh_export_space_matrix(armature_objects: Iterable[Object], export_space: str) -> Matrix:
|
||||
if not armature_objects:
|
||||
return Matrix.Identity(4)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
match export_space:
|
||||
case 'WORLD':
|
||||
return Matrix.Identity(4)
|
||||
case 'ARMATURE':
|
||||
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 _:
|
||||
assert False, f'Invalid export space: {export_space}'
|
||||
|
||||
|
||||
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.
|
||||
'''
|
||||
for material_slot in obj.material_slots:
|
||||
if material_slot.material is None:
|
||||
yield 0
|
||||
else:
|
||||
try:
|
||||
yield material_names.index(material_slot.material.name)
|
||||
except ValueError:
|
||||
yield 0
|
||||
|
||||
|
||||
def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult:
|
||||
armature_object: bpy.types.Object = input_objects.armature_object
|
||||
armature_objects = list(input_objects.armature_objects)
|
||||
|
||||
result = PskBuildResult()
|
||||
psk = Psk()
|
||||
bones = []
|
||||
bones: List[Bone] = []
|
||||
|
||||
def get_export_space_matrix():
|
||||
match options.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)
|
||||
case _:
|
||||
raise ValueError(f'Invalid export space: {options.export_space}')
|
||||
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()
|
||||
|
||||
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.
|
||||
total_bone_count = sum(len(armature_object.data.bones) for armature_object in armature_objects)
|
||||
|
||||
if armature_object is None or len(armature_object.data.bones) == 0:
|
||||
# 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 = bytes('root', encoding='windows-1252')
|
||||
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:
|
||||
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]
|
||||
# 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)
|
||||
|
||||
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
|
||||
)
|
||||
armature_object_root_bone_indices[None] = 0
|
||||
|
||||
# MATERIALS
|
||||
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()
|
||||
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.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)
|
||||
@@ -166,99 +240,90 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
|
||||
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_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]
|
||||
|
||||
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
|
||||
|
||||
armature_object = get_armature_for_mesh_object(obj)
|
||||
|
||||
should_flip_normals = False
|
||||
|
||||
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.
|
||||
'''
|
||||
for material_slot in obj.material_slots:
|
||||
if material_slot.material is None:
|
||||
yield 0
|
||||
else:
|
||||
try:
|
||||
yield material_names.index(material_slot.material.name)
|
||||
except ValueError:
|
||||
yield 0
|
||||
|
||||
# MATERIALS
|
||||
material_indices = list(get_material_name_indices(obj, material_names))
|
||||
# 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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -32,27 +32,30 @@ 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)
|
||||
|
||||
with open(path, 'wb') as fp:
|
||||
_write_section(fp, b'ACTRHEAD')
|
||||
_write_section(fp, b'PNTS0000', Vector3, psk.points)
|
||||
try:
|
||||
with open(path, 'wb') as fp:
|
||||
_write_section(fp, b'ACTRHEAD')
|
||||
_write_section(fp, b'PNTS0000', Vector3, psk.points)
|
||||
|
||||
wedges = []
|
||||
for index, w in enumerate(psk.wedges):
|
||||
wedge = Psk.Wedge16()
|
||||
wedge.material_index = w.material_index
|
||||
wedge.u = w.u
|
||||
wedge.v = w.v
|
||||
wedge.point_index = w.point_index
|
||||
wedges.append(wedge)
|
||||
wedges = []
|
||||
for index, w in enumerate(psk.wedges):
|
||||
wedge = Psk.Wedge16()
|
||||
wedge.material_index = w.material_index
|
||||
wedge.u = w.u
|
||||
wedge.v = w.v
|
||||
wedge.point_index = w.point_index
|
||||
wedges.append(wedge)
|
||||
|
||||
_write_section(fp, b'VTXW0000', Psk.Wedge16, wedges)
|
||||
_write_section(fp, b'FACE0000', Psk.Face, psk.faces)
|
||||
_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)
|
||||
_write_section(fp, b'VTXW0000', Psk.Wedge16, wedges)
|
||||
_write_section(fp, b'FACE0000', Psk.Face, psk.faces)
|
||||
_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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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,24 +54,27 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
|
||||
|
||||
bone_collection_list.clear()
|
||||
|
||||
armature = cast(Armature, armature_object.data)
|
||||
for armature_object in armature_objects:
|
||||
armature = cast(Armature, armature_object.data)
|
||||
|
||||
if armature is None:
|
||||
return
|
||||
if armature is None:
|
||||
return
|
||||
|
||||
item = bone_collection_list.add()
|
||||
item.name = 'Unassigned'
|
||||
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))
|
||||
item.is_selected = unassigned_collection_is_selected
|
||||
|
||||
for bone_collection_index, bone_collection in enumerate(armature.collections_all):
|
||||
item = bone_collection_list.add()
|
||||
item.name = bone_collection.name
|
||||
item.index = bone_collection_index
|
||||
item.count = len(bone_collection.bones)
|
||||
item.is_selected = bone_collection.name in selected_assigned_collection_names if has_selected_collections else True
|
||||
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))
|
||||
item.is_selected = unassigned_collection_is_selected
|
||||
|
||||
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)
|
||||
item.is_selected = bone_collection.name in selected_assigned_collection_names if has_selected_collections else True
|
||||
|
||||
|
||||
def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_collection_indices: Iterable[int]) -> List[str]:
|
||||
@@ -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}'
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user