Empty material slots and meshes with no materials are now handled correctly.

* Meshes without materials will be treated as though they have a single empty material slot.
* Empty material slots are now treated as a "None" material on export instead of being disallowed.
* The "None" material slot is appended to the end of the material list unless otherwise specified through manual material ordering.
This commit is contained in:
Colin Basnett
2026-02-04 14:27:29 -08:00
parent 63ee31bb00
commit 4dac4d5115
3 changed files with 73 additions and 30 deletions

View File

@@ -73,14 +73,12 @@ def _get_mesh_export_space_matrix(node: ObjectNode | None, export_space: str) ->
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.
If the material is not found or the slot is empty, the index of 'None' 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)
material_name = material_slot.material.name if material_slot.material is not None else 'None'
yield material_names.index(material_name)
except ValueError:
yield 0
@@ -109,15 +107,34 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
psk.bones = [bone.psx_bone for bone in psx_bone_create_result.bones]
# Materials
mesh_objects = [dfs_object.obj for dfs_object in input_objects.mesh_dfs_objects]
match options.material_order_mode:
case 'AUTOMATIC':
mesh_objects = [dfs_object.obj for dfs_object in input_objects.mesh_dfs_objects]
materials = list(get_materials_for_mesh_objects(context.evaluated_depsgraph_get(), mesh_objects))
case 'MANUAL':
# The material name list may contain materials that are not on the mesh objects.
# Therefore, we can take the material_name_list as gospel and simply use it as a lookup table.
# If a look-up fails, replace it with an empty material.
materials = [bpy.data.materials.get(x, None) for x in options.material_name_list]
# Check if any mesh needs a None material (has no slots or empty slots)
needs_none_material = False
for mesh_object in mesh_objects:
evaluated_mesh_object = mesh_object.evaluated_get(context.evaluated_depsgraph_get())
if len(evaluated_mesh_object.material_slots) == 0:
needs_none_material = True
break
for material_slot in evaluated_mesh_object.material_slots:
if material_slot.material is None:
needs_none_material = True
break
if needs_none_material:
break
# Append None at the end if needed and not already present
if needs_none_material and None not in materials:
materials.append(None)
case _:
assert False, f'Invalid material order mode: {options.material_order_mode}'
@@ -130,11 +147,7 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
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.
# Ensure at least one material exists
if len(psk.materials) == 0:
# Add a default material if no materials are present.
psk_material = Psk.Material()
@@ -177,8 +190,12 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
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]
# If the mesh has no material slots, map to the 'None' material index
try:
none_material_index = material_names.index('None')
except ValueError:
none_material_index = 0
material_indices = [none_material_index]
# Store the reference to the evaluated object and data so that we can clean them up later.
evaluated_mesh_object = None

View File

@@ -29,7 +29,7 @@ def populate_material_name_list(depsgraph: Depsgraph, mesh_objects: Iterable[Obj
material_list.clear()
for index, material in enumerate(materials):
m = material_list.add()
m.material_name = material.name
m.material_name = material.name if material is not None else 'None'
m.index = index
@@ -173,19 +173,32 @@ class PSK_OT_material_list_name_move_down(Operator):
return {'FINISHED'}
def get_sorted_materials_by_names(materials: Iterable[Material], material_names: list[str]) -> list[Material]:
def get_sorted_materials_by_names(materials: Iterable[Material | None], material_names: list[str]) -> list[Material | None]:
"""
Sorts the materials by the order of the material names list. Any materials not in the list will be appended to the
end of the list in the order they are found.
end of the list in the order they are found. None materials (representing empty material slots) are always
appended at the very end.
@param materials: A list of materials to sort
@param materials: A list of materials to sort (can include None)
@param material_names: A list of material names to sort by
@return: A sorted list of materials
@return: A sorted list of materials (with None at the end if present)
"""
materials = list(materials)
has_none = None in materials
materials = [m for m in materials if m is not None]
materials_in_collection = [m for m in materials if m.name in material_names]
materials_not_in_collection = [m for m in materials if m.name not in material_names]
materials_in_collection = sorted(materials_in_collection, key=lambda x: material_names.index(x.name))
return materials_in_collection + materials_not_in_collection
result: list[Material | None] = []
result.extend(materials_in_collection)
result.extend(materials_not_in_collection)
if has_none:
result.append(None)
return result
def get_psk_build_options_from_property_group(scene: Scene, pg: PskExportMixin) -> PskBuildOptions:

View File

@@ -1,7 +1,7 @@
import bpy
from collections import Counter
from typing import Iterable, cast as typing_cast
from bpy.types import Armature, AnimData, Collection, Context, Object, ArmatureModifier, SpaceProperties, PropertyGroup
from bpy.types import Armature, AnimData, Collection, Context, Object, ArmatureModifier, SpaceProperties, PropertyGroup, Material
from mathutils import Matrix, Vector, Quaternion as BpyQuaternion
from psk_psa_py.shared.data import PsxBone, Quaternion, Vector3
@@ -619,16 +619,29 @@ class PskInputObjects(object):
def get_materials_for_mesh_objects(depsgraph: Depsgraph, mesh_objects: Iterable[Object]):
yielded_materials = set()
'''
Yields unique materials used by the given mesh objects.
If any mesh has no material slots or any empty material slots, None is yielded at the end.
'''
yielded_materials: Set[Material] = set()
has_none_material = False
for mesh_object in mesh_objects:
evaluated_mesh_object = mesh_object.evaluated_get(depsgraph)
for i, material_slot in enumerate(evaluated_mesh_object.material_slots):
# Check if mesh has no material slots or any empty material slots
if len(evaluated_mesh_object.material_slots) == 0:
has_none_material = True
else:
for material_slot in evaluated_mesh_object.material_slots:
material = material_slot.material
if material is None:
raise RuntimeError(f'Material slots cannot be empty. ({mesh_object.name}, index {i})')
has_none_material = True
else:
if material not in yielded_materials:
yielded_materials.add(material)
yield material
# Yield None at the end if any mesh had no material slots or empty material slots
if has_none_material:
yield None
def get_mesh_objects_for_collection(collection: Collection) -> Iterable[DfsObject]: