7 Commits
2.1.1 ... 2.1.3

Author SHA1 Message Date
Colin Basnett
fa51768e65 ASE export success message now displays some stats about the ASE file (material count, face count etc.) 2024-09-20 11:42:47 -07:00
Colin Basnett
638042202b Added better error handling when material slots are empty 2024-09-20 11:37:38 -07:00
Colin Basnett
37c255f270 Incremented version to 2.1.3 2024-09-20 11:26:12 -07:00
Colin Basnett
3ad6a18f21 Added the ability to provide explicit ordering in collection exporter
Operators cannot reference ID datablocks for some reason still, but
I just made it so you can type in the name of the materials manually.
This allows stable and predictable material ordering in batch
operations.
2024-09-20 11:25:25 -07:00
Colin Basnett
d2230fb975 Minor formatting fix 2024-09-20 11:24:11 -07:00
Colin Basnett
2740568c17 Added better error handling when faces reference materials that do not exist 2024-09-20 11:23:18 -07:00
Colin Basnett
cc3a9c39fc Fixed vertex color lookup 2024-09-14 02:39:46 -07:00
3 changed files with 196 additions and 9 deletions

View File

@@ -1,6 +1,6 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "io_scene_ase" id = "io_scene_ase"
version = "2.1.1" version = "2.1.3"
name = "ASCII Scene Export (.ase)" name = "ASCII Scene Export (.ase)"
tagline = "Export .ase files used in Unreal Engine 1 & 2" tagline = "Export .ase files used in Unreal Engine 1 & 2"
maintainer = "Colin Basnett <cmbasnett@gmail.com>" maintainer = "Colin Basnett <cmbasnett@gmail.com>"

View File

@@ -128,6 +128,21 @@ def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Obje
loop_triangle_index_order = (2, 1, 0) if should_invert_normals else (0, 1, 2) loop_triangle_index_order = (2, 1, 0) if should_invert_normals else (0, 1, 2)
# Gather the list of unique material indices in the loop triangles.
face_material_indices = {loop_triangle.material_index for loop_triangle in mesh_data.loop_triangles}
# Make sure that each material index is within the bounds of the material indices list.
for material_index in face_material_indices:
if material_index >= len(material_indices):
raise ASEBuildError(f'Material index {material_index} for mesh \'{obj.name}\' is out of bounds.\n'
f'This means that one or more faces are assigned to a material slot that does '
f'not exist.\n'
f'The referenced material indices in the faces are: {sorted(list(face_material_indices))}.\n'
f'Either add enough materials to the object or assign faces to existing material slots.'
)
del face_material_indices
# Faces # Faces
for face_index, loop_triangle in enumerate(mesh_data.loop_triangles): for face_index, loop_triangle in enumerate(mesh_data.loop_triangles):
face = ASEFace() face = ASEFace()
@@ -176,12 +191,15 @@ def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Obje
# Vertex Colors # Vertex Colors
if options.should_export_vertex_colors and options.has_vertex_colors: if options.should_export_vertex_colors and options.has_vertex_colors:
color_attribute = None
match options.vertex_color_mode: match options.vertex_color_mode:
case 'ACTIVE': case 'ACTIVE':
color_attribute = mesh_data.color_attributes[active_color_name] color_attribute_name = active_color_name
case 'EXPLICIT': case 'EXPLICIT':
color_attribute = mesh_data.color_attributes.get(options.vertex_color_attribute, None) color_attribute_name = options.vertex_color_attribute
case _:
raise ASEBuildError('Invalid vertex color mode')
color_attribute = mesh_data.color_attributes.get(color_attribute_name, None)
if color_attribute is not None: if color_attribute is not None:
# Make sure that the selected color attribute is on the CORNER domain. # Make sure that the selected color attribute is on the CORNER domain.

View File

@@ -1,10 +1,11 @@
import os.path import os.path
from typing import Iterable, List, Set, Union from typing import Iterable, List, Set, Union, cast, Optional
import bpy import bpy
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from bpy.props import StringProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty, BoolProperty from bpy.props import StringProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty, BoolProperty
from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler, Event, Context, SpaceProperties, \
Collection
from mathutils import Matrix, Vector from mathutils import Matrix, Vector
from .builder import ASEBuildOptions, ASEBuildError, get_mesh_objects, build_ase from .builder import ASEBuildOptions, ASEBuildError, get_mesh_objects, build_ase
@@ -15,6 +16,10 @@ class ASE_PG_material(PropertyGroup):
material: PointerProperty(type=Material) material: PointerProperty(type=Material)
class ASE_PG_string(PropertyGroup):
string: StringProperty()
def get_vertex_color_attributes_from_objects(objects: Iterable[Object]) -> Set[str]: def get_vertex_color_attributes_from_objects(objects: Iterable[Object]) -> Set[str]:
''' '''
Get the unique vertex color attributes from all the selected objects. Get the unique vertex color attributes from all the selected objects.
@@ -52,7 +57,7 @@ def get_unique_materials(mesh_objects: Iterable[Object]) -> List[Material]:
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
if material is None: if material is None:
raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')') raise RuntimeError(f'Material slots cannot be empty ({mesh_object.name}, material slot index {i})')
materials.add(material) materials.add(material)
return list(materials) return list(materials)
@@ -66,6 +71,123 @@ def populate_material_list(mesh_objects: Iterable[Object], material_list):
m.index = index m.index = index
def get_collection_from_context(context: Context) -> Optional[Collection]:
if context.space_data.type != 'PROPERTIES':
return None
space_data = cast(SpaceProperties, context.space_data)
if space_data.use_pin_id:
return cast(Collection, space_data.pin_id)
else:
return context.collection
def get_collection_export_operator_from_context(context: Context) -> Optional['ASE_OT_export_collection']:
collection = get_collection_from_context(context)
if collection is None:
return None
if 0 > collection.active_exporter_index >= len(collection.exporters):
return None
exporter = collection.exporters[collection.active_exporter_index]
# TODO: make sure this is actually an ASE exporter.
return exporter.export_properties
class ASE_OT_material_order_add(Operator):
bl_idname = 'ase_export.material_order_add'
bl_label = 'Add'
bl_description = 'Add a material to the list'
def invoke(self, context: Context, event: Event) -> Union[Set[str], Set[int]]:
# TODO: get the region that this was invoked from and set the collection to the collection of the region.
print(event)
return self.execute(context)
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
# Make sure this is being invoked from the properties region.
operator = get_collection_export_operator_from_context(context)
if operator is None:
return {'INVALID_CONTEXT'}
material_string = operator.material_order.add()
material_string.string = 'Material'
return {'FINISHED'}
class ASE_OT_material_order_remove(Operator):
bl_idname = 'ase_export.material_order_remove'
bl_label = 'Remove'
bl_description = 'Remove the selected material from the list'
@classmethod
def poll(cls, context: Context):
operator = get_collection_export_operator_from_context(context)
if operator is None:
return False
return 0 <= operator.material_order_index < len(operator.material_order)
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context)
if operator is None:
return {'INVALID_CONTEXT'}
operator.material_order.remove(operator.material_order_index)
return {'FINISHED'}
class ASE_OT_material_order_move_up(Operator):
bl_idname = 'ase_export.material_order_move_up'
bl_label = 'Move Up'
bl_description = 'Move the selected material up one slot'
@classmethod
def poll(cls, context: Context):
operator = get_collection_export_operator_from_context(context)
if operator is None:
return False
return operator.material_order_index > 0
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context)
if operator is None:
return {'INVALID_CONTEXT'}
operator.material_order.move(operator.material_order_index, operator.material_order_index - 1)
operator.material_order_index -= 1
return {'FINISHED'}
class ASE_OT_material_order_move_down(Operator):
bl_idname = 'ase_export.material_order_move_down'
bl_label = 'Move Down'
bl_description = 'Move the selected material down one slot'
@classmethod
def poll(cls, context: Context):
operator = get_collection_export_operator_from_context(context)
if operator is None:
return False
return operator.material_order_index < len(operator.material_order) - 1
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context)
if operator is None:
return {'INVALID_CONTEXT'}
operator.material_order.move(operator.material_order_index, operator.material_order_index + 1)
operator.material_order_index += 1
return {'FINISHED'}
class ASE_OT_material_list_move_up(Operator): class ASE_OT_material_list_move_up(Operator):
bl_idname = 'ase_export.material_list_item_move_up' bl_idname = 'ase_export.material_list_item_move_up'
bl_label = 'Move Up' bl_label = 'Move Up'
@@ -108,6 +230,13 @@ class ASE_UL_materials(UIList):
row.prop(item.material, 'name', text='', emboss=False, icon_value=layout.icon(item.material)) row.prop(item.material, 'name', text='', emboss=False, icon_value=layout.icon(item.material))
class ASE_UL_strings(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row()
row.prop(item, 'string', text='', emboss=False)
object_eval_state_items = ( object_eval_state_items = (
('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'), ('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'),
('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'), ('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'),
@@ -179,7 +308,12 @@ class ASE_OT_export(Operator, ExportHelper):
mesh_objects = [x[0] for x in get_mesh_objects(context.selected_objects)] mesh_objects = [x[0] for x in get_mesh_objects(context.selected_objects)]
pg = getattr(context.scene, 'ase_export') pg = getattr(context.scene, 'ase_export')
populate_material_list(mesh_objects, pg.material_list)
try:
populate_material_list(mesh_objects, pg.material_list)
except RuntimeError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
self.filepath = f'{context.active_object.name}.ase' self.filepath = f'{context.active_object.name}.ase'
@@ -200,8 +334,15 @@ class ASE_OT_export(Operator, ExportHelper):
options.should_invert_normals = pg.should_invert_normals options.should_invert_normals = pg.should_invert_normals
try: try:
ase = build_ase(context, options, context.selected_objects) ase = build_ase(context, options, context.selected_objects)
# Calculate some statistics about the ASE file to display in the console.
object_count = len(ase.geometry_objects)
material_count = len(ase.materials)
vertex_count = sum(len(x.vertices) for x in ase.geometry_objects)
face_count = sum(len(x.faces) for x in ase.geometry_objects)
ASEWriter().write(self.filepath, ase) ASEWriter().write(self.filepath, ase)
self.report({'INFO'}, 'ASE exported successfully') self.report({'INFO'}, f'ASE exported successfully ({object_count} objects, {material_count} materials, {face_count} faces, {vertex_count} vertices)')
return {'FINISHED'} return {'FINISHED'}
except ASEBuildError as e: except ASEBuildError as e:
self.report({'ERROR'}, str(e)) self.report({'ERROR'}, str(e))
@@ -227,11 +368,26 @@ class ASE_OT_export_collection(Operator, ExportHelper):
) )
collection: StringProperty() collection: StringProperty()
material_order: CollectionProperty(name='Materials', type=ASE_PG_string)
material_order_index: IntProperty(name='Index', default=0)
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials')
if materials_panel:
row = materials_panel.row()
row.template_list('ASE_UL_strings', '', self, 'material_order', self, 'material_order_index')
col = row.column(align=True)
col.operator(ASE_OT_material_order_add.bl_idname, icon='ADD', text='')
col.operator(ASE_OT_material_order_remove.bl_idname, icon='REMOVE', text='')
col.separator()
col.operator(ASE_OT_material_order_move_up.bl_idname, icon='TRIA_UP', text='')
col.operator(ASE_OT_material_order_move_down.bl_idname, icon='TRIA_DOWN', text='')
advanced_header, advanced_panel = layout.panel('Advanced', default_closed=True) advanced_header, advanced_panel = layout.panel('Advanced', default_closed=True)
advanced_header.label(text='Advanced') advanced_header.label(text='Advanced')
@@ -249,9 +405,16 @@ class ASE_OT_export_collection(Operator, ExportHelper):
# Iterate over all the objects in the collection. # Iterate over all the objects in the collection.
mesh_objects = get_mesh_objects(collection.all_objects) mesh_objects = get_mesh_objects(collection.all_objects)
# Get all the materials used by the objects in the collection. # Get all the materials used by the objects in the collection.
options.materials = get_unique_materials([x[0] for x in mesh_objects]) options.materials = get_unique_materials([x[0] for x in mesh_objects])
# Sort the materials based on the order in the material order list, keeping in mind that the material order list
# may not contain all the materials used by the objects in the collection.
material_order = [x.string for x in self.material_order]
material_order_map = {x: i for i, x in enumerate(material_order)}
options.materials.sort(key=lambda x: material_order_map.get(x.name, len(material_order)))
try: try:
ase = build_ase(context, options, collection.all_objects) ase = build_ase(context, options, collection.all_objects)
except ASEBuildError as e: except ASEBuildError as e:
@@ -277,11 +440,17 @@ class ASE_FH_export(FileHandler):
classes = ( classes = (
ASE_PG_material, ASE_PG_material,
ASE_PG_string,
ASE_UL_materials, ASE_UL_materials,
ASE_UL_strings,
ASE_PG_export, ASE_PG_export,
ASE_OT_export, ASE_OT_export,
ASE_OT_export_collection, ASE_OT_export_collection,
ASE_OT_material_list_move_down, ASE_OT_material_list_move_down,
ASE_OT_material_list_move_up, ASE_OT_material_list_move_up,
ASE_OT_material_order_add,
ASE_OT_material_order_remove,
ASE_OT_material_order_move_down,
ASE_OT_material_order_move_up,
ASE_FH_export, ASE_FH_export,
) )