Added material name mapping to collection exporter

Also moved a bunch of things around
This commit is contained in:
Colin Basnett
2025-09-15 02:04:50 -07:00
parent 429e9b4030
commit f3a2eb10ad
5 changed files with 316 additions and 199 deletions

View File

@@ -3,6 +3,7 @@ if 'bpy' in locals():
if 'ase' in locals(): importlib.reload(ase) if 'ase' in locals(): importlib.reload(ase)
if 'builder' in locals(): importlib.reload(builder) if 'builder' in locals(): importlib.reload(builder)
if 'writer' in locals(): importlib.reload(writer) if 'writer' in locals(): importlib.reload(writer)
if 'properties' in locals(): importlib.reload(properties)
if 'exporter' in locals(): importlib.reload(exporter) if 'exporter' in locals(): importlib.reload(exporter)
if 'dfs' in locals(): importlib.reload(dfs) if 'dfs' in locals(): importlib.reload(dfs)
@@ -11,10 +12,11 @@ import bpy.utils.previews
from . import ase from . import ase
from . import builder from . import builder
from . import writer from . import writer
from . import properties
from . import exporter from . import exporter
from . import dfs from . import dfs
classes = exporter.classes classes = properties.classes + exporter.classes
def menu_func_export(self, context): def menu_func_export(self, context):
@@ -25,7 +27,8 @@ def register():
for cls in classes: for cls in classes:
bpy.utils.register_class(cls) bpy.utils.register_class(cls)
bpy.types.Scene.ase_export = bpy.props.PointerProperty(type=exporter.ASE_PG_export) bpy.types.Scene.ase_settings = bpy.props.PointerProperty(type=properties.ASE_PG_scene_settings, options={'HIDDEN'})
bpy.types.Scene.ase_export = bpy.props.PointerProperty(type=properties.ASE_PG_export, options={'HIDDEN'})
bpy.types.TOPBAR_MT_file_export.append(menu_func_export) bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
@@ -33,6 +36,7 @@ def register():
def unregister(): def unregister():
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
del bpy.types.Scene.ase_settings
del bpy.types.Scene.ase_export del bpy.types.Scene.ase_export
for cls in classes: for cls in classes:

View File

@@ -1,6 +1,8 @@
from typing import Iterable, Optional, List, Tuple, cast from typing import Iterable, Optional, List, Dict, cast
from collections import OrderedDict
from bpy.types import Object, Context, Material, Mesh
from bpy.types import Context, Material, Mesh
from .ase import ASE, ASEGeometryObject, ASEFace, ASEFaceNormal, ASEVertexNormal, ASEUVLayer, is_collision_name from .ase import ASE, ASEGeometryObject, ASEFace, ASEFaceNormal, ASEVertexNormal, ASEUVLayer, is_collision_name
import bpy import bpy
@@ -20,13 +22,13 @@ class ASEBuildOptions(object):
def __init__(self): def __init__(self):
self.object_eval_state = 'EVALUATED' self.object_eval_state = 'EVALUATED'
self.materials: Optional[List[Material]] = None self.materials: Optional[List[Material]] = None
self.material_mapping: Dict[str, str] = OrderedDict()
self.transform = Matrix.Identity(4) self.transform = Matrix.Identity(4)
self.should_export_vertex_colors = True self.should_export_vertex_colors = True
self.vertex_color_mode = 'ACTIVE' self.vertex_color_mode = 'ACTIVE'
self.has_vertex_colors = False self.has_vertex_colors = False
self.vertex_color_attribute = '' self.vertex_color_attribute = ''
self.should_invert_normals = False self.should_invert_normals = False
self.should_export_visible_only = True
self.scale = 1.0 self.scale = 1.0
self.forward_axis = 'X' self.forward_axis = 'X'
self.up_axis = 'Z' self.up_axis = 'Z'
@@ -62,7 +64,7 @@ def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z')
def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[DfsObject]) -> ASE: def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[DfsObject]) -> ASE:
ase = ASE() ase = ASE()
ase.materials = [x.name for x in options.materials] ase.materials = [x.name if x is not None else 'None' for x in options.materials]
# If no materials are assigned to the object, add an empty material. # If no materials are assigned to the object, add an empty material.
# This is necessary for the ASE format to be compatible with the UT2K4 importer. # This is necessary for the ASE format to be compatible with the UT2K4 importer.
@@ -146,14 +148,14 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[
options.transform @ options.transform @
matrix_world) matrix_world)
for vertex_index, vertex in enumerate(mesh_data.vertices): for _, vertex in enumerate(mesh_data.vertices):
vertex = vertex_transform @ vertex.co vertex = vertex_transform @ vertex.co
vertex = coordinate_system_transform @ vertex vertex = coordinate_system_transform @ vertex
geometry_object.vertices.append(vertex) geometry_object.vertices.append(vertex)
material_indices = [] material_indices = []
if not geometry_object.is_collision: if not geometry_object.is_collision:
for mesh_material_index, material in enumerate(obj.data.materials): for mesh_material_index, material in enumerate(obj.data.materials): # TODO: this needs to use the evaluated object, doesn't it?
if material is None: if material is None:
raise ASEBuildError(f'Material slot {mesh_material_index + 1} for mesh \'{obj.name}\' cannot be empty') raise ASEBuildError(f'Material slot {mesh_material_index + 1} for mesh \'{obj.name}\' cannot be empty')
material_indices.append(ase.materials.index(material.name)) material_indices.append(ase.materials.index(material.name))
@@ -202,7 +204,7 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[
# smoothing groups for the current mesh. This should work the majority of the time. # smoothing groups for the current mesh. This should work the majority of the time.
# Faces # Faces
for face_index, loop_triangle in enumerate(mesh_data.loop_triangles): for _, loop_triangle in enumerate(mesh_data.loop_triangles):
face = ASEFace() face = ASEFace()
face.a, face.b, face.c = map(lambda j: geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[j]].vertex_index, loop_triangle_index_order) face.a, face.b, face.c = map(lambda j: geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[j]].vertex_index, loop_triangle_index_order)
if not geometry_object.is_collision: if not geometry_object.is_collision:
@@ -219,7 +221,7 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[
if not geometry_object.is_collision: if not geometry_object.is_collision:
# Normals # Normals
for face_index, loop_triangle in enumerate(mesh_data.loop_triangles): for _, loop_triangle in enumerate(mesh_data.loop_triangles):
face_normal = ASEFaceNormal() face_normal = ASEFaceNormal()
face_normal.normal = loop_triangle.normal face_normal.normal = loop_triangle.normal
face_normal.vertex_normals = [] face_normal.vertex_normals = []
@@ -279,6 +281,16 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[
context.window_manager.progress_update(dfs_objects_processed) context.window_manager.progress_update(dfs_objects_processed)
ase.geometry_objects.append(geometry_object) ase.geometry_objects.append(geometry_object)
# Apply the material mapping.
material_mapping_items: list[tuple[str, str]] = list(options.material_mapping.items())
material_mapping_keys = list(map(lambda x: x[0], material_mapping_items))
for i in range(len(ase.materials)):
try:
index = material_mapping_keys.index(ase.materials[i])
ase.materials[i] = material_mapping_items[index][1]
except ValueError:
pass
context.window_manager.progress_end() context.window_manager.progress_end()

View File

@@ -71,7 +71,7 @@ def dfs_objects_in_collection(collection: Collection) -> Iterable[Object]:
yield from _dfs_object_children(obj, collection) yield from _dfs_object_children(obj, collection)
def dfs_collection_objects(collection: Collection, visible_only: bool = False) -> Iterable[DfsObject]: def dfs_collection_objects(collection: Collection) -> Iterable[DfsObject]:
''' '''
Depth-first search of objects in a collection, including recursing into instances. Depth-first search of objects in a collection, including recursing into instances.
@param collection: The collection to search in. @param collection: The collection to search in.
@@ -86,15 +86,16 @@ def _dfs_collection_objects_recursive(
matrix_world: Matrix = Matrix.Identity(4), matrix_world: Matrix = Matrix.Identity(4),
visited: Optional[Set[Object]]=None visited: Optional[Set[Object]]=None
) -> Iterable[DfsObject]: ) -> Iterable[DfsObject]:
''' """
Depth-first search of objects in a collection, including recursing into instances. Depth-first search of objects in a collection, including recursing into instances.
This is a recursive function. This is a recursive function.
@param collection: The collection to search in. @param collection: The collection to search in.
@param instance_objects: The running hierarchy of instance objects. @param instance_objects: The running hierarchy of instance objects.
@param matrix_world: The world matrix of the current object. @param matrix_world: The world matrix of the current object.
@param visited: A set of visited object-instance pairs. @param visited: A set of visited object-instance pairs.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix. @return: An iterable of tuples containing the object, the instance objects, and the world matrix.
''' """
# We want to also yield the top-level instance object so that callers can inspect the selection status etc. # We want to also yield the top-level instance object so that callers can inspect the selection status etc.
if visited is None: if visited is None:

View File

@@ -2,69 +2,31 @@ 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, IntProperty, EnumProperty, BoolProperty
FloatProperty from bpy.types import Operator, Material, UIList, Object, FileHandler, Event, Context, SpaceProperties, \
from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler, Event, Context, SpaceProperties, \ Collection, Panel, Depsgraph
Collection
from mathutils import Matrix, Vector from mathutils import Matrix, Vector
from .builder import ASEBuildOptions, ASEBuildError, build_ase from .builder import ASEBuildOptions, ASEBuildError, build_ase
from .writer import ASEWriter from .writer import ASEWriter
from .properties import TransformMixin, TransformSourceMixin, MaterialModeMixin, ASE_PG_key_value, get_vertex_color_attributes_from_objects
class ASE_PG_material(PropertyGroup): def get_unique_materials(depsgraph: Depsgraph, mesh_objects: Iterable[Object]) -> List[Material]:
material: PointerProperty(type=Material)
class ASE_PG_string(PropertyGroup):
string: StringProperty()
def get_vertex_color_attributes_from_objects(objects: Iterable[Object]) -> Set[str]:
'''
Get the unique vertex color attributes from all the selected objects.
:param objects: The objects to search for vertex color attributes.
:return: A set of unique vertex color attributes.
'''
items = set()
for obj in filter(lambda x: x.type == 'MESH', objects):
for layer in filter(lambda x: x.domain == 'CORNER', obj.data.color_attributes):
items.add(layer.name)
return items
def vertex_color_attribute_items(self, context):
# Get the unique color attributes from all the selected objects.
return [(x, x, '') for x in sorted(get_vertex_color_attributes_from_objects(context.selected_objects))]
class ASE_PG_export(PropertyGroup):
material_list: CollectionProperty(name='Materials', type=ASE_PG_material)
material_list_index: IntProperty(name='Index', default=0)
should_export_vertex_colors: BoolProperty(name='Export Vertex Colors', default=True)
vertex_color_mode: EnumProperty(name='Vertex Color Mode', items=(
('ACTIVE', 'Active', 'Use the active vertex color attribute'),
('EXPLICIT', 'Explicit', 'Use the vertex color attribute specified below'),
))
has_vertex_colors: BoolProperty(name='Has Vertex Colors', default=False, options={'HIDDEN'})
vertex_color_attribute: EnumProperty(name='Attribute', items=vertex_color_attribute_items)
should_invert_normals: BoolProperty(name='Invert Normals', default=False, description='Invert the normals of the exported geometry. This should be used if the software you are exporting to uses a different winding order than Blender')
def get_unique_materials(mesh_objects: Iterable[Object]) -> List[Material]:
materials = [] materials = []
for mesh_object in mesh_objects: for mesh_object in mesh_objects:
for i, material_slot in enumerate(mesh_object.material_slots): eo = mesh_object.evaluated_get(depsgraph)
for i, material_slot in enumerate(eo.material_slots):
material = material_slot.material material = material_slot.material
if material is None: # if material is None:
raise RuntimeError(f'Material slots cannot be empty ({mesh_object.name}, material slot index {i})') # raise RuntimeError(f'Material slots cannot be empty ({mesh_object.name}, material slot index {i})')
if material not in materials: if material not in materials:
materials.append(material) materials.append(material)
return materials return materials
def populate_material_list(mesh_objects: Iterable[Object], material_list): def populate_material_list(depsgraph: Depsgraph, mesh_objects: Iterable[Object], material_list):
materials = get_unique_materials(mesh_objects) materials = get_unique_materials(depsgraph, mesh_objects)
material_list.clear() material_list.clear()
for index, material in enumerate(materials): for index, material in enumerate(materials):
m = material_list.add() m = material_list.add()
@@ -95,10 +57,10 @@ def get_collection_export_operator_from_context(context: Context) -> Optional['A
return exporter.export_properties return exporter.export_properties
class ASE_OT_material_order_add(Operator): class ASE_OT_material_mapping_add(Operator):
bl_idname = 'ase_export.material_order_add' bl_idname = 'ase_export.material_mapping_add'
bl_label = 'Add' bl_label = 'Add'
bl_description = 'Add a material to the list' bl_description = 'Add a material mapping to the list'
def invoke(self, context: Context, event: Event) -> Union[Set[str], Set[int]]: 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. # TODO: get the region that this was invoked from and set the collection to the collection of the region.
@@ -112,23 +74,23 @@ class ASE_OT_material_order_add(Operator):
if operator is None: if operator is None:
return {'INVALID_CONTEXT'} return {'INVALID_CONTEXT'}
material_string = operator.material_order.add() material_mapping = operator.material_mapping.add()
material_string.string = 'Material' material_mapping.key = 'Material'
return {'FINISHED'} return {'FINISHED'}
class ASE_OT_material_order_remove(Operator): class ASE_OT_material_mapping_remove(Operator):
bl_idname = 'ase_export.material_order_remove' bl_idname = 'ase_export.material_mapping_remove'
bl_label = 'Remove' bl_label = 'Remove'
bl_description = 'Remove the selected material from the list' bl_description = 'Remove the selected material mapping from the list'
@classmethod @classmethod
def poll(cls, context: Context): def poll(cls, context: Context):
operator = get_collection_export_operator_from_context(context) operator = get_collection_export_operator_from_context(context)
if operator is None: if operator is None:
return False return False
return 0 <= operator.material_order_index < len(operator.material_order) return 0 <= operator.material_mapping_index < len(operator.material_mapping)
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]: def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context) operator = get_collection_export_operator_from_context(context)
@@ -136,22 +98,22 @@ class ASE_OT_material_order_remove(Operator):
if operator is None: if operator is None:
return {'INVALID_CONTEXT'} return {'INVALID_CONTEXT'}
operator.material_order.remove(operator.material_order_index) operator.material_mapping.remove(operator.material_mapping_index)
return {'FINISHED'} return {'FINISHED'}
class ASE_OT_material_order_move_up(Operator): class ASE_OT_material_mapping_move_up(Operator):
bl_idname = 'ase_export.material_order_move_up' bl_idname = 'ase_export.material_mapping_move_up'
bl_label = 'Move Up' bl_label = 'Move Up'
bl_description = 'Move the selected material up one slot' bl_description = 'Move the selected material mapping up one slot'
@classmethod @classmethod
def poll(cls, context: Context): def poll(cls, context: Context):
operator = get_collection_export_operator_from_context(context) operator = get_collection_export_operator_from_context(context)
if operator is None: if operator is None:
return False return False
return operator.material_order_index > 0 return operator.material_mapping_index > 0
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]: def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context) operator = get_collection_export_operator_from_context(context)
@@ -159,23 +121,23 @@ class ASE_OT_material_order_move_up(Operator):
if operator is None: if operator is None:
return {'INVALID_CONTEXT'} return {'INVALID_CONTEXT'}
operator.material_order.move(operator.material_order_index, operator.material_order_index - 1) operator.material_mapping.move(operator.material_mapping_index, operator.material_mapping_index - 1)
operator.material_order_index -= 1 operator.material_mapping_index -= 1
return {'FINISHED'} return {'FINISHED'}
class ASE_OT_material_order_move_down(Operator): class ASE_OT_material_mapping_move_down(Operator):
bl_idname = 'ase_export.material_order_move_down' bl_idname = 'ase_export.material_mapping_move_down'
bl_label = 'Move Down' bl_label = 'Move Down'
bl_description = 'Move the selected material down one slot' bl_description = 'Move the selected material mapping down one slot'
@classmethod @classmethod
def poll(cls, context: Context): def poll(cls, context: Context):
operator = get_collection_export_operator_from_context(context) operator = get_collection_export_operator_from_context(context)
if operator is None: if operator is None:
return False return False
return operator.material_order_index < len(operator.material_order) - 1 return operator.material_mapping_index < len(operator.material_mapping) - 1
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]: def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context) operator = get_collection_export_operator_from_context(context)
@@ -183,8 +145,8 @@ class ASE_OT_material_order_move_down(Operator):
if operator is None: if operator is None:
return {'INVALID_CONTEXT'} return {'INVALID_CONTEXT'}
operator.material_order.move(operator.material_order_index, operator.material_order_index + 1) operator.material_mapping.move(operator.material_mapping_index, operator.material_mapping_index + 1)
operator.material_order_index += 1 operator.material_mapping_index += 1
return {'FINISHED'} return {'FINISHED'}
@@ -226,34 +188,31 @@ class ASE_OT_material_list_move_down(Operator):
class ASE_UL_materials(UIList): class ASE_UL_materials(UIList):
bl_idname = 'ASE_UL_materials'
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row() row = layout.row()
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_material_names(UIList): class ASE_UL_material_names(UIList):
bl_idname = 'ASE_UL_material_names'
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row() row = layout.row()
material = bpy.data.materials.get(item.string, None) material = bpy.data.materials.get(item.key, None)
row.prop(item, 'string', text='', emboss=False, icon_value=layout.icon(material) if material is not None else 0) col= row.column()
col.enabled = False
col.prop(item, 'key', text='', emboss=False, icon_value=layout.icon(material) if material is not None else 0)
row.label(icon='RIGHTARROW', text='')
material = bpy.data.materials.get(item.value, None)
row.prop(item, 'value', text='', emboss=False, icon_value=layout.icon(material) if material is not None else 0)
class ASE_OT_material_names_populate(Operator):
object_eval_state_items = [ bl_idname = 'ase_export.material_names_populate'
('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'), bl_label = 'Populate Material Names List'
('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'), bl_description = 'Populate the material names with the materials used by objects in the collection'
]
class ASE_OT_populate_material_order_list(Operator):
bl_idname = 'ase_export.populate_material_order_list'
bl_label = 'Populate Material Order List'
bl_description = 'Populate the material order list with the materials used by objects in the collection'
visible_only: BoolProperty(name='Visible Only', default=True, description='Populate the list with only the materials of visible objects')
def invoke(self, context: 'Context', event: 'Event'):
return context.window_manager.invoke_props_dialog(self)
def execute(self, context): def execute(self, context):
collection = get_collection_from_context(context) collection = get_collection_from_context(context)
@@ -263,50 +222,27 @@ class ASE_OT_populate_material_order_list(Operator):
from .dfs import dfs_collection_objects from .dfs import dfs_collection_objects
mesh_objects = list(map(lambda x: x.obj, filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection, True)))) mesh_objects = list(map(lambda x: x.obj, filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection))))
# Exclude objects that are not visible. # Exclude objects that are not visible.
materials = get_unique_materials(mesh_objects) materials = get_unique_materials(context.evaluated_depsgraph_get(), mesh_objects)
operator.material_order.clear() operator.material_mapping.clear()
for material in materials: for material in materials:
m = operator.material_order.add() m = operator.material_mapping.add()
m.string = material.name m.key = material.name
m.value = material.name
return {'FINISHED'} return {'FINISHED'}
empty_set = set() object_eval_state_items = [
axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z') ('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'),
forward_items = ( ('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'),
('X', 'X Forward', ''), ]
('Y', 'Y Forward', ''),
('Z', 'Z Forward', ''),
('-X', '-X Forward', ''),
('-Y', '-Y Forward', ''),
('-Z', '-Z Forward', ''),
)
up_items = (
('X', 'X Up', ''),
('Y', 'Y Up', ''),
('Z', 'Z Up', ''),
('-X', '-X Up', ''),
('-Y', '-Y Up', ''),
('-Z', '-Z Up', ''),
)
def forward_axis_update(self, _context: Context):
if self.forward_axis == self.up_axis:
self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z')
def up_axis_update(self, _context: Context): class ASE_OT_export(Operator, ExportHelper, TransformMixin, TransformSourceMixin):
if self.up_axis == self.forward_axis:
self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X')
class ASE_OT_export(Operator, ExportHelper):
bl_idname = 'io_scene_ase.ase_export' bl_idname = 'io_scene_ase.ase_export'
bl_label = 'Export ASE' bl_label = 'Export ASE'
bl_space_type = 'PROPERTIES' bl_space_type = 'PROPERTIES'
@@ -314,17 +250,11 @@ class ASE_OT_export(Operator, ExportHelper):
bl_description = 'Export selected objects to ASE' bl_description = 'Export selected objects to ASE'
filename_ext = '.ase' filename_ext = '.ase'
filter_glob: StringProperty(default="*.ase", options={'HIDDEN'}, maxlen=255) filter_glob: StringProperty(default="*.ase", options={'HIDDEN'}, maxlen=255)
# TODO: why are these not part of the ASE_PG_export property group?
object_eval_state: EnumProperty( object_eval_state: EnumProperty(
items=object_eval_state_items, items=object_eval_state_items,
name='Data', name='Data',
default='EVALUATED' default='EVALUATED'
) )
should_export_visible_only: BoolProperty(name='Visible Only', default=False, description='Export only visible objects')
scale: FloatProperty(name='Scale', default=1.0, min=0.0001, soft_max=1000.0, description='Scale factor to apply to the exported geometry')
forward_axis: EnumProperty(name='Forward', items=forward_items, default='X', update=forward_axis_update)
up_axis: EnumProperty(name='Up', items=up_items, default='Z', update=up_axis_update)
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
@@ -398,7 +328,7 @@ class ASE_OT_export(Operator, ExportHelper):
pg = getattr(context.scene, 'ase_export') pg = getattr(context.scene, 'ase_export')
try: try:
populate_material_list(mesh_objects, pg.material_list) populate_material_list(context.evaluated_depsgraph_get(), mesh_objects, pg.material_list)
except RuntimeError as e: except RuntimeError as e:
self.report({'ERROR'}, str(e)) self.report({'ERROR'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
@@ -420,10 +350,16 @@ class ASE_OT_export(Operator, ExportHelper):
options.vertex_color_attribute = pg.vertex_color_attribute options.vertex_color_attribute = pg.vertex_color_attribute
options.materials = [x.material for x in pg.material_list] options.materials = [x.material for x in pg.material_list]
options.should_invert_normals = pg.should_invert_normals options.should_invert_normals = pg.should_invert_normals
options.should_export_visible_only = self.should_export_visible_only
options.scale = self.scale match self.transform_source:
options.forward_axis = self.forward_axis case 'SCENE':
options.up_axis = self.up_axis transform_source = getattr(context.scene, 'ase_settings')
case 'OBJECT':
transform_source = self
options.scale = transform_source.scale
options.forward_axis = transform_source.forward_axis
options.up_axis = transform_source.up_axis
from .dfs import dfs_view_layer_objects from .dfs import dfs_view_layer_objects
@@ -447,12 +383,13 @@ class ASE_OT_export(Operator, ExportHelper):
export_space_items = [ export_space_items = [
('WORLD', 'World Space', 'Export the collection in world-space (i.e., as it appears in the 3D view)'), ('WORLD', 'World Space', 'Export the collection in world space'),
('INSTANCE', 'Instance Space', 'Export the collection as an instance (transforms the world-space geometry by the inverse of the instance offset)'), ('INSTANCE', 'Instance Space', 'Export the collection in instance space'),
('OBJECT', 'Object Space', 'Export the collection in the active object\'s local space'),
] ]
class ASE_OT_export_collection(Operator, ExportHelper): class ASE_OT_export_collection(Operator, ExportHelper, TransformSourceMixin, TransformMixin, MaterialModeMixin):
bl_idname = 'io_scene_ase.ase_export_collection' bl_idname = 'io_scene_ase.ase_export_collection'
bl_label = 'Export collection to ASE' bl_label = 'Export collection to ASE'
bl_space_type = 'PROPERTIES' bl_space_type = 'PROPERTIES'
@@ -471,13 +408,9 @@ class ASE_OT_export_collection(Operator, ExportHelper):
) )
collection: StringProperty() collection: StringProperty()
material_order: CollectionProperty(name='Materials', type=ASE_PG_string) material_mapping: CollectionProperty(name='Materials', type=ASE_PG_key_value)
material_order_index: IntProperty(name='Index', default=0) material_mapping_index: IntProperty(name='Index', default=0)
export_space: EnumProperty(name='Export Space', items=export_space_items, default='INSTANCE') export_space: EnumProperty(name='Export Space', items=export_space_items, default='INSTANCE')
should_export_visible_only: BoolProperty(name='Visible Only', default=False, description='Export only visible objects')
scale: FloatProperty(name='Scale', default=1.0, min=0.0001, soft_max=1000.0, description='Scale factor to apply to the exported geometry')
forward_axis: EnumProperty(name='Forward', items=forward_items, default='X', update=forward_axis_update)
up_axis: EnumProperty(name='Up', items=up_items, default='Z', update=up_axis_update)
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
@@ -485,22 +418,23 @@ class ASE_OT_export_collection(Operator, ExportHelper):
flow = layout.grid_flow() flow = layout.grid_flow()
flow.use_property_split = True flow.use_property_split = True
flow.use_property_decorate = False flow.use_property_decorate = False
flow.prop(self, 'should_export_visible_only')
materials_header, materials_panel = layout.panel('Materials', default_closed=True) materials_header, materials_panel = layout.panel('Materials', default_closed=True)
materials_header.label(text='Materials') materials_header.label(text='Materials')
if materials_panel: if materials_panel:
row = materials_panel.row() materials_panel.prop(self, 'material_mode', text='Material Mode')
row.template_list('ASE_UL_material_names', '', self, 'material_order', self, 'material_order_index') if self.material_mode == 'MANUAL':
col = row.column(align=True) row = materials_panel.row()
col.operator(ASE_OT_material_order_add.bl_idname, icon='ADD', text='') row.template_list(ASE_UL_material_names.bl_idname, '', self, 'material_mapping', self, 'material_mapping_index')
col.operator(ASE_OT_material_order_remove.bl_idname, icon='REMOVE', text='') col = row.column(align=True)
col.separator() col.operator(ASE_OT_material_mapping_add.bl_idname, icon='ADD', text='')
col.operator(ASE_OT_material_order_move_up.bl_idname, icon='TRIA_UP', text='') col.operator(ASE_OT_material_mapping_remove.bl_idname, icon='REMOVE', text='')
col.operator(ASE_OT_material_order_move_down.bl_idname, icon='TRIA_DOWN', text='') col.separator()
col.separator() col.operator(ASE_OT_material_mapping_move_up.bl_idname, icon='TRIA_UP', text='')
col.operator(ASE_OT_populate_material_order_list.bl_idname, icon='FILE_REFRESH', text='') col.operator(ASE_OT_material_mapping_move_down.bl_idname, icon='TRIA_DOWN', text='')
col.separator()
col.operator(ASE_OT_material_names_populate.bl_idname, icon='FILE_REFRESH', text='')
transform_header, transform_panel = layout.panel('Transform', default_closed=True) transform_header, transform_panel = layout.panel('Transform', default_closed=True)
transform_header.label(text='Transform') transform_header.label(text='Transform')
@@ -508,9 +442,21 @@ class ASE_OT_export_collection(Operator, ExportHelper):
if transform_panel: if transform_panel:
transform_panel.use_property_split = True transform_panel.use_property_split = True
transform_panel.use_property_decorate = False transform_panel.use_property_decorate = False
transform_panel.prop(self, 'scale') transform_panel.prop(self, 'transform_source')
transform_panel.prop(self, 'forward_axis')
transform_panel.prop(self, 'up_axis') flow = transform_panel.grid_flow()
match self.transform_source:
case 'SCENE':
transform_source = getattr(context.scene, 'ase_settings')
flow.enabled = False
case 'OBJECT':
transform_source = self
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(transform_source, 'scale')
flow.prop(transform_source, 'forward_axis')
flow.prop(transform_source, 'up_axis')
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')
@@ -526,41 +472,55 @@ class ASE_OT_export_collection(Operator, ExportHelper):
options = ASEBuildOptions() options = ASEBuildOptions()
options.object_eval_state = self.object_eval_state options.object_eval_state = self.object_eval_state
options.scale = self.scale
options.forward_axis = self.forward_axis match self.transform_source:
options.up_axis = self.up_axis case 'SCENE':
transform_source = getattr(context.scene, 'ase_settings')
case 'OBJECT':
transform_source = self
options.scale = transform_source.scale
options.forward_axis = transform_source.forward_axis
options.up_axis = transform_source.up_axis
match self.export_space: match self.export_space:
case 'WORLD': case 'WORLD':
options.transform = Matrix.Identity(4) options.transform = Matrix.Identity(4)
case 'INSTANCE': case 'INSTANCE':
options.transform = Matrix.Translation(-Vector(collection.instance_offset)) options.transform = Matrix.Translation(-Vector(collection.instance_offset))
case 'BONE':
options.transform = Matrix
from .dfs import dfs_collection_objects from .dfs import dfs_collection_objects
dfs_objects = list(filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection, options.should_export_visible_only))) dfs_objects = list(filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection)))
mesh_objects = [x.obj for x in dfs_objects] mesh_objects = [x.obj for x in dfs_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(mesh_objects) options.materials = get_unique_materials(context.evaluated_depsgraph_get(), mesh_objects)
# Sort the materials based on the order in the material order list, keeping in mind that the material order list if self.material_mode == 'MANUAL':
# may not contain all the materials used by the objects in the collection. # Build material mapping.
material_order = [x.string for x in self.material_order] for material_mapping in self.material_mapping:
material_order_map = {x: i for i, x in enumerate(material_order)} options.material_mapping[material_mapping.key] = material_mapping.value
# Split the list of materials into two lists: one for materials that appear in the material order list, and one # Sort the materials based on the order in the material order list, keeping in mind that the material order list
# for materials that do not. Then append the two lists together, with the ordered materials first. # may not contain all the materials used by the objects in the collection.
ordered_materials = [] material_names = [x.key for x in self.material_mapping]
unordered_materials = [] material_names_map = {x: i for i, x in enumerate(material_names)}
for material in options.materials:
if material.name in material_order_map:
ordered_materials.append(material)
else:
unordered_materials.append(material)
ordered_materials.sort(key=lambda x: material_order_map.get(x.name, len(material_order))) # Split the list of materials into two lists: one for materials that appear in the material order list, and one
options.materials = ordered_materials + unordered_materials # for materials that do not. Then append the two lists together, with the ordered materials first.
ordered_materials = []
unordered_materials = []
for material in options.materials:
if material.name in material_names_map:
ordered_materials.append(material)
else:
unordered_materials.append(material)
ordered_materials.sort(key=lambda x: material_names_map.get(x.name, len(material_names)))
options.materials = ordered_materials + unordered_materials
try: try:
ase = build_ase(context, options, dfs_objects) ase = build_ase(context, options, dfs_objects)
@@ -577,6 +537,35 @@ class ASE_OT_export_collection(Operator, ExportHelper):
return {'FINISHED'} return {'FINISHED'}
class ASE_PT_export_scene_settings(Panel):
bl_label = 'ASCII Scene Export'
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = 'scene'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context: Context):
return context.space_data.type == 'PROPERTIES' and hasattr(context.scene, 'ase_settings')
def draw(self, context: Context):
layout = self.layout
transform_source = getattr(context.scene, 'ase_settings')
transform_header, transform_panel = layout.panel('Transform', default_closed=True)
transform_header.label(text='Transform')
if transform_panel:
flow = transform_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(transform_source, 'scale')
flow.prop(transform_source, 'forward_axis')
flow.prop(transform_source, 'up_axis')
class ASE_FH_export(FileHandler): class ASE_FH_export(FileHandler):
bl_idname = 'ASE_FH_export' bl_idname = 'ASE_FH_export'
bl_label = 'ASCII Scene Export' bl_label = 'ASCII Scene Export'
@@ -584,21 +573,18 @@ class ASE_FH_export(FileHandler):
bl_file_extensions = '.ase' bl_file_extensions = '.ase'
classes = ( classes = (
ASE_PG_material,
ASE_PG_string,
ASE_UL_materials, ASE_UL_materials,
ASE_UL_material_names, ASE_UL_material_names,
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_mapping_add,
ASE_OT_material_order_remove, ASE_OT_material_mapping_remove,
ASE_OT_material_order_move_down, ASE_OT_material_mapping_move_down,
ASE_OT_material_order_move_up, ASE_OT_material_mapping_move_up,
ASE_OT_populate_material_order_list, ASE_OT_material_names_populate,
ASE_PT_export_scene_settings,
ASE_FH_export, ASE_FH_export,
) )

114
io_scene_ase/properties.py Normal file
View File

@@ -0,0 +1,114 @@
from typing import Iterable, Set
from bpy.types import PropertyGroup, Context, Material, Object
from bpy.props import CollectionProperty, IntProperty, BoolProperty, EnumProperty, FloatProperty, StringProperty, PointerProperty
axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z')
forward_items = (
('X', 'X Forward', ''),
('Y', 'Y Forward', ''),
('Z', 'Z Forward', ''),
('-X', '-X Forward', ''),
('-Y', '-Y Forward', ''),
('-Z', '-Z Forward', ''),
)
up_items = (
('X', 'X Up', ''),
('Y', 'Y Up', ''),
('Z', 'Z Up', ''),
('-X', '-X Up', ''),
('-Y', '-Y Up', ''),
('-Z', '-Z Up', ''),
)
def forward_axis_update(self, _context: Context):
if self.forward_axis[-1] == self.up_axis[-1]:
self.up_axis = next((axis for axis in axis_identifiers if axis[-1] != self.forward_axis[-1]), 'Z')
def up_axis_update(self, _context: Context):
if self.up_axis[-1] == self.forward_axis[-1]:
self.forward_axis = next((axis for axis in axis_identifiers if axis[-1] != self.up_axis[-1]), 'X')
transform_source_items = (
('SCENE', 'Scene', ''),
('CUSTOM', 'Custom', ''),
)
class TransformSourceMixin:
transform_source: EnumProperty(name='Transform Source', items=transform_source_items, default='SCENE', description='The source of the transform to apply to the exported geometry')
class TransformMixin:
scale: FloatProperty(name='Scale', default=1.0, min=0.0001, soft_max=1000.0, description='Scale factor to apply to the exported geometry')
forward_axis: EnumProperty(name='Forward', items=forward_items, default='X', update=forward_axis_update)
up_axis: EnumProperty(name='Up', items=up_items, default='Z', update=up_axis_update)
material_mode_items = (
('AUTOMATIC', 'Automatic', ''),
('MANUAL', 'Manual', ''),
)
class MaterialModeMixin:
material_mode: EnumProperty(name='Material Mode', items=material_mode_items, default='AUTOMATIC', description='The material mode to use for the exported geometry')
class ASE_PG_material(PropertyGroup):
material: PointerProperty(type=Material)
class ASE_PG_key_value(PropertyGroup):
key: StringProperty()
value: StringProperty()
def get_vertex_color_attributes_from_objects(objects: Iterable[Object]) -> Set[str]:
'''
Get the unique vertex color attributes from all the selected objects.
:param objects: The objects to search for vertex color attributes.
:return: A set of unique vertex color attributes.
'''
items = set()
for obj in filter(lambda x: x.type == 'MESH', objects):
for layer in filter(lambda x: x.domain == 'CORNER', obj.data.color_attributes):
items.add(layer.name)
return items
def vertex_color_attribute_items(self, context):
# Get the unique color attributes from all the selected objects.
return [(x, x, '') for x in sorted(get_vertex_color_attributes_from_objects(context.selected_objects))]
vertex_color_mode_items = (
('ACTIVE', 'Active', 'Use the active vertex color attribute'),
('EXPLICIT', 'Explicit', 'Use the vertex color attribute specified below'),
)
class ASE_PG_export(PropertyGroup, TransformSourceMixin, TransformMixin):
material_list: CollectionProperty(name='Materials', type=ASE_PG_material)
material_list_index: IntProperty(name='Index', default=0)
should_export_vertex_colors: BoolProperty(name='Export Vertex Colors', default=True)
vertex_color_mode: EnumProperty(name='Vertex Color Mode', items=vertex_color_mode_items)
has_vertex_colors: BoolProperty(name='Has Vertex Colors', default=False, options={'HIDDEN'})
vertex_color_attribute: EnumProperty(name='Attribute', items=vertex_color_attribute_items)
should_invert_normals: BoolProperty(name='Invert Normals', default=False, description='Invert the normals of the exported geometry. This should be used if the software you are exporting to uses a different winding order than Blender')
class ASE_PG_scene_settings(PropertyGroup, TransformMixin):
pass
classes = (
ASE_PG_material,
ASE_PG_key_value,
ASE_PG_export,
ASE_PG_scene_settings,
)