Added support for collection exporters

This commit is contained in:
Colin Basnett
2024-07-17 01:38:32 -07:00
parent 14f5b0424c
commit 10a25dc036
6 changed files with 208 additions and 78 deletions

View File

@@ -108,7 +108,6 @@ def load_psa_file(context, filepath: str):
pg.psa_error = str(e) pg.psa_error = str(e)
def on_psa_file_path_updated(cls, context): def on_psa_file_path_updated(cls, context):
load_psa_file(context, cls.filepath) load_psa_file(context, cls.filepath)
@@ -261,6 +260,7 @@ class PSA_FH_import(FileHandler):
bl_idname = 'PSA_FH_import' bl_idname = 'PSA_FH_import'
bl_label = 'File handler for Unreal PSA import' bl_label = 'File handler for Unreal PSA import'
bl_import_operator = 'psa_import.import' bl_import_operator = 'psa_import.import'
bl_export_operator = 'psa_export.export'
bl_file_extensions = '.psa' bl_file_extensions = '.psa'
@classmethod @classmethod

View File

@@ -3,7 +3,7 @@ from typing import Optional
import bmesh import bmesh
import bpy import bpy
import numpy as np import numpy as np
from bpy.types import Armature, Material from bpy.types import Armature, Material, Collection, Context
from .data import * from .data import *
from .properties import triangle_type_and_bit_flags_to_poly_flags from .properties import triangle_type_and_bit_flags_to_poly_flags
@@ -20,30 +20,28 @@ class PskBuildOptions(object):
def __init__(self): def __init__(self):
self.bone_filter_mode = 'ALL' self.bone_filter_mode = 'ALL'
self.bone_collection_indices: List[int] = [] self.bone_collection_indices: List[int] = []
self.use_raw_mesh_data = True self.object_eval_state = 'EVALUATED'
self.materials: List[Material] = [] self.materials: List[Material] = []
self.should_enforce_bone_name_restrictions = False self.should_enforce_bone_name_restrictions = False
def get_psk_input_objects(context) -> PskInputObjects: def get_mesh_objects_for_collection(collection: Collection):
input_objects = PskInputObjects() for obj in collection.all_objects:
for selected_object in context.view_layer.objects.selected: if obj.type == 'MESH':
if selected_object.type == 'MESH': yield obj
input_objects.mesh_objects.append(selected_object)
if len(input_objects.mesh_objects) == 0:
raise RuntimeError('At least one mesh must be selected')
for mesh_object in input_objects.mesh_objects: def get_mesh_objects_for_context(context: Context):
if len(mesh_object.data.materials) == 0: for obj in context.view_layer.objects.selected:
raise RuntimeError(f'Mesh "{mesh_object.name}" must have at least one material') if obj.type == 'MESH':
yield obj
# Ensure that there are either no armature modifiers (static mesh)
# or that there is exactly one armature modifier object shared between def get_armature_for_mesh_objects(mesh_objects: List[Object]) -> Optional[Object]:
# all selected meshes # 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_modifier_objects = set()
for mesh_object in mesh_objects:
for mesh_object in input_objects.mesh_objects:
modifiers = [x for x in mesh_object.modifiers if x.type == 'ARMATURE'] modifiers = [x for x in mesh_object.modifiers if x.type == 'ARMATURE']
if len(modifiers) == 0: if len(modifiers) == 0:
continue continue
@@ -53,21 +51,46 @@ def get_psk_input_objects(context) -> PskInputObjects:
if len(armature_modifier_objects) > 1: if len(armature_modifier_objects) > 1:
armature_modifier_names = [x.name for x in armature_modifier_objects] armature_modifier_names = [x.name for x in armature_modifier_objects]
raise RuntimeError(f'All selected meshes must have the same armature modifier, encountered {len(armature_modifier_names)} ({", ".join(armature_modifier_names)})') 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: elif len(armature_modifier_objects) == 1:
input_objects.armature_object = list(armature_modifier_objects)[0] return list(armature_modifier_objects)[0]
else:
return None
def _get_psk_input_objects(mesh_objects: List[Object]) -> PskInputObjects:
if len(mesh_objects) == 0:
raise RuntimeError('At least one mesh must be selected')
for mesh_object in mesh_objects:
if len(mesh_object.data.materials) == 0:
raise RuntimeError(f'Mesh "{mesh_object.name}" must have at least one material')
input_objects = PskInputObjects()
input_objects.mesh_objects = mesh_objects
input_objects.armature_object = get_armature_for_mesh_objects(mesh_objects)
return input_objects return input_objects
def get_psk_input_objects_for_context(context: Context) -> PskInputObjects:
mesh_objects = list(get_mesh_objects_for_context(context))
return _get_psk_input_objects(mesh_objects)
def get_psk_input_objects_for_collection(collection: Collection) -> PskInputObjects:
mesh_objects = list(get_mesh_objects_for_collection(collection))
return _get_psk_input_objects(mesh_objects)
class PskBuildResult(object): class PskBuildResult(object):
def __init__(self): def __init__(self):
self.psk = None self.psk = None
self.warnings: List[str] = [] self.warnings: List[str] = []
def build_psk(context, options: PskBuildOptions) -> PskBuildResult: def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult:
input_objects = get_psk_input_objects(context)
armature_object: bpy.types.Object = input_objects.armature_object armature_object: bpy.types.Object = input_objects.armature_object
result = PskBuildResult() result = PskBuildResult()
@@ -160,47 +183,48 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult:
material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots] material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots]
# MESH DATA # MESH DATA
if options.use_raw_mesh_data: match options.object_eval_state:
mesh_object = input_mesh_object case 'ORIGINAL':
mesh_data = input_mesh_object.data mesh_object = input_mesh_object
else: mesh_data = input_mesh_object.data
# Create a copy of the mesh object after non-armature modifiers are applied. case 'EVALUATED':
# Create a copy of the mesh object after non-armature modifiers are applied.
# Temporarily force the armature into the rest position. # Temporarily force the armature into the rest position.
# We will undo this later. # We will undo this later.
old_pose_position = None old_pose_position = None
if armature_object is not None: if armature_object is not None:
old_pose_position = armature_object.data.pose_position old_pose_position = armature_object.data.pose_position
armature_object.data.pose_position = 'REST' armature_object.data.pose_position = 'REST'
depsgraph = context.evaluated_depsgraph_get() depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new() bm = bmesh.new()
bm.from_object(input_mesh_object, depsgraph) bm.from_object(input_mesh_object, depsgraph)
mesh_data = bpy.data.meshes.new('') mesh_data = bpy.data.meshes.new('')
bm.to_mesh(mesh_data) bm.to_mesh(mesh_data)
del bm del bm
mesh_object = bpy.data.objects.new('', mesh_data) mesh_object = bpy.data.objects.new('', mesh_data)
mesh_object.matrix_world = input_mesh_object.matrix_world mesh_object.matrix_world = input_mesh_object.matrix_world
scale = (input_mesh_object.scale.x, input_mesh_object.scale.y, input_mesh_object.scale.z) scale = (input_mesh_object.scale.x, input_mesh_object.scale.y, input_mesh_object.scale.z)
# Negative scaling in Blender results in inverted normals after the scale is applied. However, if the scale # 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 # 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. # 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 # 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 # 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 # 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. # 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 should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1
# Copy the vertex groups # Copy the vertex groups
for vertex_group in input_mesh_object.vertex_groups: for vertex_group in input_mesh_object.vertex_groups:
mesh_object.vertex_groups.new(name=vertex_group.name) mesh_object.vertex_groups.new(name=vertex_group.name)
# Restore the previous pose position on the armature. # Restore the previous pose position on the armature.
if old_pose_position is not None: if old_pose_position is not None:
armature_object.data.pose_position = old_pose_position armature_object.data.pose_position = old_pose_position
vertex_offset = len(psk.points) vertex_offset = len(psk.points)
@@ -305,7 +329,7 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult:
w.weight = weight w.weight = weight
psk.weights.append(w) psk.weights.append(w)
if not options.use_raw_mesh_data: if options.object_eval_state == 'EVALUATED':
bpy.data.objects.remove(mesh_object) bpy.data.objects.remove(mesh_object)
bpy.data.meshes.remove(mesh_data) bpy.data.meshes.remove(mesh_data)
del mesh_data del mesh_data

View File

@@ -1,35 +1,40 @@
from bpy.props import StringProperty from typing import List
from bpy.types import Operator
import bpy
from bpy.props import StringProperty, BoolProperty, EnumProperty
from bpy.types import Operator, Context, Object
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from ..builder import build_psk, PskBuildOptions, get_psk_input_objects from .properties import object_eval_state_items
from ..builder import build_psk, PskBuildOptions, get_psk_input_objects_for_context, \
get_psk_input_objects_for_collection
from ..writer import write_psk from ..writer import write_psk
from ...shared.helpers import populate_bone_collection_list from ...shared.helpers import populate_bone_collection_list
def is_bone_filter_mode_item_available(context, identifier): def is_bone_filter_mode_item_available(context, identifier):
input_objects = get_psk_input_objects(context) input_objects = get_psk_input_objects_for_context(context)
armature_object = input_objects.armature_object armature_object = input_objects.armature_object
if identifier == 'BONE_COLLECTIONS': if identifier == 'BONE_COLLECTIONS':
if armature_object is None or armature_object.data is None or len(armature_object.data.collections) == 0: if armature_object is None or armature_object.data is None or len(armature_object.data.collections) == 0:
return False return False
# else if... you can set up other conditions if you add more options
return True return True
def populate_material_list(mesh_objects, material_list): def get_materials_for_mesh_objects(mesh_objects: List[Object]):
material_list.clear()
materials = [] materials = []
for mesh_object in mesh_objects: for mesh_object in mesh_objects:
for i, material_slot in enumerate(mesh_object.material_slots): for i, material_slot in enumerate(mesh_object.material_slots):
material = material_slot.material material = material_slot.material
# TODO: put this in the poll arg?
if material is None: if material is None:
raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')') raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')')
if material not in materials: if material not in materials:
materials.append(material) materials.append(material)
return materials
def populate_material_list(mesh_objects, material_list):
materials = get_materials_for_mesh_objects(mesh_objects)
material_list.clear()
for index, material in enumerate(materials): for index, material in enumerate(materials):
m = material_list.add() m = material_list.add()
m.material = material m.material = material
@@ -72,6 +77,84 @@ class PSK_OT_material_list_move_down(Operator):
return {'FINISHED'} return {'FINISHED'}
class PSK_OT_export_collection(Operator, ExportHelper):
bl_idname = 'export.psk_collection'
bl_label = 'Export'
bl_options = {'INTERNAL'}
filename_ext = '.psk'
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'})
filepath: StringProperty(
name='File Path',
description='File path used for exporting the PSK file',
maxlen=1024,
default='',
subtype='FILE_PATH')
collection: StringProperty(options={'HIDDEN'})
object_eval_state: EnumProperty(
items=object_eval_state_items,
name='Object Evaluation State',
default='EVALUATED'
)
should_enforce_bone_name_restrictions: BoolProperty(
default=False,
name='Enforce Bone Name Restrictions',
description='Enforce that bone names must only contain letters, numbers, spaces, hyphens and underscores.\n\n'
'Depending on the engine, improper bone names might not be referenced correctly by scripts'
)
def execute(self, context):
collection = bpy.data.collections.get(self.collection)
try:
input_objects = get_psk_input_objects_for_collection(collection)
except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'}
options = PskBuildOptions()
options.bone_filter_mode = 'ALL'
options.object_eval_state = self.object_eval_state
options.materials = get_materials_for_mesh_objects(input_objects.mesh_objects)
options.should_enforce_bone_name_restrictions = self.should_enforce_bone_name_restrictions
try:
result = build_psk(context, input_objects, options)
for warning in result.warnings:
self.report({'WARNING'}, warning)
write_psk(result.psk, self.filepath)
if len(result.warnings) > 0:
self.report({'WARNING'}, f'PSK export successful with {len(result.warnings)} warnings')
else:
self.report({'INFO'}, f'PSK export successful')
except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'}
return {'FINISHED'}
def draw(self, context: Context):
layout = self.layout
# MESH
mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False)
mesh_header.label(text='Mesh', icon='MESH_DATA')
if mesh_panel:
flow = mesh_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'object_eval_state', text='Data')
# BONES
bones_header, bones_panel = layout.panel('Bones', default_closed=False)
bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel:
flow = bones_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'should_enforce_bone_name_restrictions')
class PSK_OT_export(Operator, ExportHelper): class PSK_OT_export(Operator, ExportHelper):
bl_idname = 'export.psk' bl_idname = 'export.psk'
bl_label = 'Export' bl_label = 'Export'
@@ -88,7 +171,7 @@ class PSK_OT_export(Operator, ExportHelper):
def invoke(self, context, event): def invoke(self, context, event):
try: try:
input_objects = get_psk_input_objects(context) input_objects = get_psk_input_objects_for_context(context)
except RuntimeError as e: except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
@@ -110,7 +193,7 @@ class PSK_OT_export(Operator, ExportHelper):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
try: try:
get_psk_input_objects(context) get_psk_input_objects_for_context(context)
except RuntimeError as e: except RuntimeError as e:
cls.poll_message_set(str(e)) cls.poll_message_set(str(e))
return False return False
@@ -118,16 +201,20 @@ class PSK_OT_export(Operator, ExportHelper):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
pg = getattr(context.scene, 'psk_export') pg = getattr(context.scene, 'psk_export')
# MESH # MESH
mesh_header, mesh_panel = layout.panel('01_mesh', default_closed=False) mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False)
mesh_header.label(text='Mesh', icon='MESH_DATA') mesh_header.label(text='Mesh', icon='MESH_DATA')
if mesh_panel: if mesh_panel:
mesh_panel.prop(pg, 'use_raw_mesh_data') flow = mesh_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'object_eval_state', text='Data')
# BONES # BONES
bones_header, bones_panel = layout.panel('02_bones', default_closed=False) bones_header, bones_panel = layout.panel('Bones', default_closed=False)
bones_header.label(text='Bones', icon='BONE_DATA') bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel: if bones_panel:
bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static bone_filter_mode_items = pg.bl_rna.properties['bone_filter_mode'].enum_items_static
@@ -146,7 +233,7 @@ class PSK_OT_export(Operator, ExportHelper):
bones_panel.prop(pg, 'should_enforce_bone_name_restrictions') bones_panel.prop(pg, 'should_enforce_bone_name_restrictions')
# MATERIALS # MATERIALS
materials_header, materials_panel = layout.panel('03_materials', default_closed=False) materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials', icon='MATERIAL') materials_header.label(text='Materials', icon='MATERIAL')
if materials_panel: if materials_panel:
row = materials_panel.row() row = materials_panel.row()
@@ -157,16 +244,19 @@ class PSK_OT_export(Operator, ExportHelper):
col.operator(PSK_OT_material_list_move_down.bl_idname, text='', icon='TRIA_DOWN') col.operator(PSK_OT_material_list_move_down.bl_idname, text='', icon='TRIA_DOWN')
def execute(self, context): def execute(self, context):
pg = context.scene.psk_export pg = getattr(context.scene, 'psk_export')
input_objects = get_psk_input_objects_for_context(context)
options = PskBuildOptions() options = PskBuildOptions()
options.bone_filter_mode = pg.bone_filter_mode 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.index for x in pg.bone_collection_list if x.is_selected]
options.use_raw_mesh_data = pg.use_raw_mesh_data options.object_eval_state = pg.object_eval_state
options.materials = [m.material for m in pg.material_list] options.materials = [m.material for m in pg.material_list]
options.should_enforce_bone_name_restrictions = pg.should_enforce_bone_name_restrictions options.should_enforce_bone_name_restrictions = pg.should_enforce_bone_name_restrictions
try: try:
result = build_psk(context, options) result = build_psk(context, input_objects, options)
for warning in result.warnings: for warning in result.warnings:
self.report({'WARNING'}, warning) self.report({'WARNING'}, warning)
write_psk(result.psk, self.filepath) write_psk(result.psk, self.filepath)
@@ -185,4 +275,5 @@ classes = (
PSK_OT_material_list_move_up, PSK_OT_material_list_move_up,
PSK_OT_material_list_move_down, PSK_OT_material_list_move_down,
PSK_OT_export, PSK_OT_export,
PSK_OT_export_collection,
) )

View File

@@ -5,6 +5,12 @@ from ...shared.types import PSX_PG_bone_collection_list_item
empty_set = set() empty_set = set()
object_eval_state_items = (
('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'),
('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'),
)
class PSK_PG_material_list_item(PropertyGroup): class PSK_PG_material_list_item(PropertyGroup):
material: PointerProperty(type=Material) material: PointerProperty(type=Material)
index: IntProperty() index: IntProperty()
@@ -23,7 +29,11 @@ class PSK_PG_export(PropertyGroup):
) )
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item) bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
bone_collection_list_index: IntProperty(default=0) bone_collection_list_index: IntProperty(default=0)
use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh') object_eval_state: EnumProperty(
items=object_eval_state_items,
name='Object Evaluation State',
default='EVALUATED'
)
material_list: CollectionProperty(type=PSK_PG_material_list_item) material_list: CollectionProperty(type=PSK_PG_material_list_item)
material_list_index: IntProperty(default=0) material_list_index: IntProperty(default=0)
should_enforce_bone_name_restrictions: BoolProperty( should_enforce_bone_name_restrictions: BoolProperty(

View File

@@ -13,8 +13,9 @@ empty_set = set()
class PSK_FH_import(FileHandler): class PSK_FH_import(FileHandler):
bl_idname = 'PSK_FH_import' bl_idname = 'PSK_FH_import'
bl_label = 'File handler for Unreal PSK/PSKX import' bl_label = 'Unreal PSK'
bl_import_operator = 'import_scene.psk' bl_import_operator = 'import_scene.psk'
bl_export_operator = 'export.psk_collection'
bl_file_extensions = '.psk;.pskx' bl_file_extensions = '.psk;.pskx'
@classmethod @classmethod

View File

@@ -1,3 +1,4 @@
import os
from ctypes import Structure, sizeof from ctypes import Structure, sizeof
from typing import Type from typing import Type
@@ -34,6 +35,9 @@ def write_psk(psk: Psk, path: str):
elif len(psk.bones) == 0: elif len(psk.bones) == 0:
raise RuntimeError(f'At least one bone must be marked for export') 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: with open(path, 'wb') as fp:
_write_section(fp, b'ACTRHEAD') _write_section(fp, b'ACTRHEAD')
_write_section(fp, b'PNTS0000', Vector3, psk.points) _write_section(fp, b'PNTS0000', Vector3, psk.points)