diff --git a/io_scene_ase/ase.py b/io_scene_ase/ase.py index b769991..1445b7f 100644 --- a/io_scene_ase/ase.py +++ b/io_scene_ase/ase.py @@ -1,3 +1,8 @@ +from typing import Optional, List + +from bpy.types import Material + + class ASEFace(object): def __init__(self): self.a = 0 @@ -50,6 +55,5 @@ class ASEGeometryObject(object): class ASE(object): def __init__(self): - self.materials = [] + self.materials: List[Optional[Material]] = [] self.geometry_objects = [] - diff --git a/io_scene_ase/builder.py b/io_scene_ase/builder.py index bba0b8c..903471d 100644 --- a/io_scene_ase/builder.py +++ b/io_scene_ase/builder.py @@ -1,6 +1,6 @@ from typing import Iterable, Optional, List, Tuple -from bpy.types import Object, Context, Material +from bpy.types import Object, Context, Material, Mesh from .ase import ASE, ASEGeometryObject, ASEFace, ASEFaceNormal, ASEVertexNormal, ASEUVLayer, is_collision_name import bpy @@ -10,14 +10,20 @@ from mathutils import Matrix, Vector SMOOTHING_GROUP_MAX = 32 -class ASEBuilderError(Exception): +class ASEBuildError(Exception): pass -class ASEBuilderOptions(object): +class ASEBuildOptions(object): def __init__(self): self.object_eval_state = 'EVALUATED' self.materials: Optional[List[Material]] = None + self.transform = Matrix.Identity(4) + self.should_export_vertex_colors = True + self.vertex_color_mode = 'ACTIVE' + self.has_vertex_colors = False + self.vertex_color_attribute = '' + self.should_invert_normals = False def get_object_matrix(obj: Object, asset_instance: Optional[Object] = None) -> Matrix: @@ -38,151 +44,165 @@ def get_mesh_objects(objects: Iterable[Object]) -> List[Tuple[Object, Optional[O return mesh_objects +def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Object]) -> ASE: + ase = ASE() -class ASEBuilder(object): - def build(self, context: Context, options: ASEBuilderOptions, objects: Iterable[Object]): - ase = ASE() + main_geometry_object = None + mesh_objects = get_mesh_objects(objects) - main_geometry_object = None - mesh_objects = get_mesh_objects(objects) + context.window_manager.progress_begin(0, len(mesh_objects)) - context.window_manager.progress_begin(0, len(mesh_objects)) + ase.materials = options.materials - ase.materials = options.materials + for object_index, (obj, asset_instance) in enumerate(mesh_objects): - for object_index, (obj, asset_instance) in enumerate(mesh_objects): + matrix_world = get_object_matrix(obj, asset_instance) - matrix_world = get_object_matrix(obj, asset_instance) + # Save the active color name for vertex color export. + active_color_name = obj.data.color_attributes.active_color_name - # Evaluate the mesh after modifiers are applied - match options.object_eval_state: - case 'ORIGINAL': - mesh_object = obj - mesh_data = mesh_object.data - case 'EVALUATED': - depsgraph = context.evaluated_depsgraph_get() - bm = bmesh.new() - bm.from_object(obj, depsgraph) - mesh_data = bpy.data.meshes.new('') - bm.to_mesh(mesh_data) - del bm - mesh_object = bpy.data.objects.new('', mesh_data) - mesh_object.matrix_world = matrix_world - - if not is_collision_name(obj.name) and main_geometry_object is not None: - geometry_object = main_geometry_object - else: - geometry_object = ASEGeometryObject() - geometry_object.name = obj.name - if not geometry_object.is_collision: - main_geometry_object = geometry_object - ase.geometry_objects.append(geometry_object) - - if geometry_object.is_collision: - # Test that collision meshes are manifold and convex. + match options.object_eval_state: + case 'ORIGINAL': + mesh_object = obj + mesh_data = mesh_object.data + case 'EVALUATED': + # Evaluate the mesh after modifiers are applied + depsgraph = context.evaluated_depsgraph_get() bm = bmesh.new() - bm.from_mesh(mesh_object.data) - for edge in bm.edges: - if not edge.is_manifold: - del bm - raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not manifold') - if not edge.is_convex: - del bm - raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not convex') + bm.from_object(obj, depsgraph) + mesh_data = bpy.data.meshes.new('') + bm.to_mesh(mesh_data) + del bm + mesh_object = bpy.data.objects.new('', mesh_data) + mesh_object.matrix_world = matrix_world - if not geometry_object.is_collision and len(obj.data.materials) == 0: - raise ASEBuilderError(f'Mesh \'{obj.name}\' must have at least one material') - - vertex_transform = Matrix.Rotation(math.pi, 4, 'Z') @ matrix_world - - for vertex_index, vertex in enumerate(mesh_data.vertices): - geometry_object.vertices.append(vertex_transform @ vertex.co) - - material_indices = [] + if not is_collision_name(obj.name) and main_geometry_object is not None: + geometry_object = main_geometry_object + else: + geometry_object = ASEGeometryObject() + geometry_object.name = obj.name if not geometry_object.is_collision: - for mesh_material_index, material in enumerate(obj.data.materials): - if material is None: - raise ASEBuilderError(f'Material slot {mesh_material_index + 1} for mesh \'{obj.name}\' cannot be empty') - material_indices.append(ase.materials.index(material)) + main_geometry_object = geometry_object + ase.geometry_objects.append(geometry_object) - mesh_data.calc_loop_triangles() + if geometry_object.is_collision: + # Test that collision meshes are manifold and convex. + bm = bmesh.new() + bm.from_mesh(mesh_object.data) + for edge in bm.edges: + if not edge.is_manifold: + del bm + raise ASEBuildError(f'Collision mesh \'{obj.name}\' is not manifold') + if not edge.is_convex: + del bm + raise ASEBuildError(f'Collision mesh \'{obj.name}\' is not convex') - # Calculate smoothing groups. - poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=False) + vertex_transform = Matrix.Rotation(math.pi, 4, 'Z') @ matrix_world - # Figure out how many scaling axes are negative. - # This is important for calculating the normals of the mesh. - _, _, scale = vertex_transform.decompose() - negative_scaling_axes = sum([1 for x in scale if x < 0]) - should_invert_normals = negative_scaling_axes % 2 == 1 + for vertex_index, vertex in enumerate(mesh_data.vertices): + geometry_object.vertices.append(vertex_transform @ vertex.co) - loop_triangle_index_order = (2, 1, 0) if should_invert_normals else (0, 1, 2) + material_indices = [] + if not geometry_object.is_collision: + for mesh_material_index, material in enumerate(obj.data.materials): + if material is None: + raise ASEBuildError(f'Material slot {mesh_material_index + 1} for mesh \'{obj.name}\' cannot be empty') + material_indices.append(ase.materials.index(material)) - # Faces + if len(material_indices) == 0: + # If no materials are assigned to the mesh, just have a single empty material. + material_indices.append(0) + + mesh_data.calc_loop_triangles() + + # Calculate smoothing groups. + poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=False) + + # Figure out how many scaling axes are negative. + # This is important for calculating the normals of the mesh. + _, _, scale = vertex_transform.decompose() + negative_scaling_axes = sum([1 for x in scale if x < 0]) + should_invert_normals = negative_scaling_axes % 2 == 1 + if options.should_invert_normals: + should_invert_normals = not should_invert_normals + + loop_triangle_index_order = (2, 1, 0) if should_invert_normals else (0, 1, 2) + + # Faces + for face_index, loop_triangle in enumerate(mesh_data.loop_triangles): + 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) + if not geometry_object.is_collision: + face.material_index = material_indices[loop_triangle.material_index] + # The UT2K4 importer only accepts 32 smoothing groups. Anything past this completely mangles the + # smoothing groups and effectively makes the whole model use sharp-edge rendering. + # The fix is to constrain the smoothing group between 0 and 31 by applying a modulo of 32 to the actual + # smoothing group index. + # This may result in bad calculated normals on export in rare cases. For example, if a face with a + # smoothing group of 3 is adjacent to a face with a smoothing group of 35 (35 % 32 == 3), those faces + # will be treated as part of the same smoothing group. + face.smoothing = (poly_groups[loop_triangle.polygon_index] - 1) % SMOOTHING_GROUP_MAX + geometry_object.faces.append(face) + + if not geometry_object.is_collision: + # Normals for face_index, loop_triangle in enumerate(mesh_data.loop_triangles): - 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) - if not geometry_object.is_collision: - face.material_index = material_indices[loop_triangle.material_index] - # The UT2K4 importer only accepts 32 smoothing groups. Anything past this completely mangles the - # smoothing groups and effectively makes the whole model use sharp-edge rendering. - # The fix is to constrain the smoothing group between 0 and 31 by applying a modulo of 32 to the actual - # smoothing group index. - # This may result in bad calculated normals on export in rare cases. For example, if a face with a - # smoothing group of 3 is adjacent to a face with a smoothing group of 35 (35 % 32 == 3), those faces - # will be treated as part of the same smoothing group. - face.smoothing = (poly_groups[loop_triangle.polygon_index] - 1) % SMOOTHING_GROUP_MAX - geometry_object.faces.append(face) + face_normal = ASEFaceNormal() + face_normal.normal = loop_triangle.normal + face_normal.vertex_normals = [] + for i in loop_triangle_index_order: + vertex_normal = ASEVertexNormal() + vertex_normal.vertex_index = geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[i]].vertex_index + vertex_normal.normal = loop_triangle.split_normals[i] + if should_invert_normals: + vertex_normal.normal = (-Vector(vertex_normal.normal)).to_tuple() + face_normal.vertex_normals.append(vertex_normal) + geometry_object.face_normals.append(face_normal) - if not geometry_object.is_collision: - # Normals - for face_index, loop_triangle in enumerate(mesh_data.loop_triangles): - face_normal = ASEFaceNormal() - face_normal.normal = loop_triangle.normal - face_normal.vertex_normals = [] - for i in loop_triangle_index_order: - vertex_normal = ASEVertexNormal() - vertex_normal.vertex_index = geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[i]].vertex_index - vertex_normal.normal = loop_triangle.split_normals[i] - if should_invert_normals: - vertex_normal.normal = (-Vector(vertex_normal.normal)).to_tuple() - face_normal.vertex_normals.append(vertex_normal) - geometry_object.face_normals.append(face_normal) + # Texture Coordinates + for i, uv_layer_data in enumerate([x.data for x in mesh_data.uv_layers]): + if i >= len(geometry_object.uv_layers): + geometry_object.uv_layers.append(ASEUVLayer()) + uv_layer = geometry_object.uv_layers[i] + for loop_index, loop in enumerate(mesh_data.loops): + u, v = uv_layer_data[loop_index].uv + uv_layer.texture_vertices.append((u, v, 0.0)) - # Texture Coordinates - for i, uv_layer_data in enumerate([x.data for x in mesh_data.uv_layers]): - if i >= len(geometry_object.uv_layers): - geometry_object.uv_layers.append(ASEUVLayer()) - uv_layer = geometry_object.uv_layers[i] - for loop_index, loop in enumerate(mesh_data.loops): - u, v = uv_layer_data[loop_index].uv - uv_layer.texture_vertices.append((u, v, 0.0)) + # Texture Faces + for loop_triangle in mesh_data.loop_triangles: + geometry_object.texture_vertex_faces.append( + tuple(map(lambda l: geometry_object.texture_vertex_offset + loop_triangle.loops[l], loop_triangle_index_order)) + ) - # Texture Faces - for loop_triangle in mesh_data.loop_triangles: - geometry_object.texture_vertex_faces.append( - tuple(map(lambda l: geometry_object.texture_vertex_offset + loop_triangle.loops[l], loop_triangle_index_order)) - ) + # Vertex Colors + if options.should_export_vertex_colors and options.has_vertex_colors: + color_attribute = None + match options.vertex_color_mode: + case 'ACTIVE': + color_attribute = mesh_data.color_attributes[active_color_name] + case 'EXPLICIT': + color_attribute = mesh_data.color_attributes.get(options.vertex_color_attribute, None) - # Vertex Colors - if len(mesh_data.vertex_colors) > 0: - if mesh_data.vertex_colors.active is not None: - vertex_colors = mesh_data.vertex_colors.active.data - for color in map(lambda x: x.color, vertex_colors): - geometry_object.vertex_colors.append(tuple(color[0:3])) + if color_attribute is not None: + # Make sure that the selected color attribute is on the CORNER domain. + if color_attribute.domain != 'CORNER': + raise ASEBuildError(f'Color attribute \'{color_attribute.name}\' for object \'{obj.name}\' must have domain of \'CORNER\' (found \'{color_attribute.domain}\')') - # Update data offsets for next iteration - geometry_object.texture_vertex_offset += len(mesh_data.loops) - geometry_object.vertex_offset = len(geometry_object.vertices) + for color in map(lambda x: x.color, color_attribute.data): + geometry_object.vertex_colors.append(tuple(color[0:3])) - context.window_manager.progress_update(object_index) + # Update data offsets for next iteration + geometry_object.texture_vertex_offset += len(mesh_data.loops) + geometry_object.vertex_offset = len(geometry_object.vertices) - context.window_manager.progress_end() + context.window_manager.progress_update(object_index) - if len(ase.geometry_objects) == 0: - raise ASEBuilderError('At least one mesh object must be selected') + context.window_manager.progress_end() - if main_geometry_object is None: - raise ASEBuilderError('At least one non-collision mesh must be exported') + if len(ase.geometry_objects) == 0: + raise ASEBuildError('At least one mesh object must be selected') - return ase + if main_geometry_object is None: + raise ASEBuildError('At least one non-collision mesh must be exported') + + return ase diff --git a/io_scene_ase/exporter.py b/io_scene_ase/exporter.py index 5d54def..ee30bee 100644 --- a/io_scene_ase/exporter.py +++ b/io_scene_ase/exporter.py @@ -3,9 +3,11 @@ from typing import Iterable, List, Set, Union import bpy from bpy_extras.io_utils import ExportHelper -from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty -from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler, Collection -from .builder import ASEBuilder, ASEBuilderOptions, ASEBuilderError, get_mesh_objects +from bpy.props import StringProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty, BoolProperty +from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler +from mathutils import Matrix, Vector + +from .builder import ASEBuildOptions, ASEBuildError, get_mesh_objects, build_ase from .writer import ASEWriter @@ -13,9 +15,35 @@ class ASE_PG_material(PropertyGroup): material: PointerProperty(type=Material) +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]: @@ -109,17 +137,35 @@ class ASE_OT_export(Operator, ExportHelper): def draw(self, context): layout = self.layout + pg = context.scene.ase_export 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_materials', '', context.scene.ase_export, 'material_list', context.scene.ase_export, 'material_list_index') + row.template_list('ASE_UL_materials', '', pg, 'material_list', pg, 'material_list_index') col = row.column(align=True) col.operator(ASE_OT_material_list_move_up.bl_idname, icon='TRIA_UP', text='') col.operator(ASE_OT_material_list_move_down.bl_idname, icon='TRIA_DOWN', text='') + + has_vertex_colors = len(get_vertex_color_attributes_from_objects(context.selected_objects)) > 0 + vertex_colors_header, vertex_colors_panel = layout.panel_prop(pg, 'should_export_vertex_colors') + row = vertex_colors_header.row() + row.enabled = has_vertex_colors + row.prop(pg, 'should_export_vertex_colors', text='Vertex Colors') + + if vertex_colors_panel: + vertex_colors_panel.use_property_split = True + vertex_colors_panel.use_property_decorate = False + if has_vertex_colors: + vertex_colors_panel.prop(pg, 'vertex_color_mode', text='Mode') + if pg.vertex_color_mode == 'EXPLICIT': + vertex_colors_panel.prop(pg, 'vertex_color_attribute', icon='GROUP_VCOL') + else: + vertex_colors_panel.label(text='No vertex color attributes found') + advanced_header, advanced_panel = layout.panel('Advanced', default_closed=True) advanced_header.label(text='Advanced') @@ -127,6 +173,7 @@ class ASE_OT_export(Operator, ExportHelper): advanced_panel.use_property_split = True advanced_panel.use_property_decorate = False advanced_panel.prop(self, 'object_eval_state') + advanced_panel.prop(pg, 'should_invert_normals') def invoke(self, context: 'Context', event: 'Event' ) -> Union[Set[str], Set[int]]: mesh_objects = [x[0] for x in get_mesh_objects(context.selected_objects)] @@ -141,16 +188,22 @@ class ASE_OT_export(Operator, ExportHelper): return {'RUNNING_MODAL'} def execute(self, context): - options = ASEBuilderOptions() - options.object_eval_state = self.object_eval_state pg = getattr(context.scene, 'ase_export') + + options = ASEBuildOptions() + options.object_eval_state = self.object_eval_state + options.should_export_vertex_colors = pg.should_export_vertex_colors + options.vertex_color_mode = pg.vertex_color_mode + options.has_vertex_colors = len(get_vertex_color_attributes_from_objects(context.selected_objects)) > 0 + options.vertex_color_attribute = pg.vertex_color_attribute options.materials = [x.material for x in pg.material_list] + options.should_invert_normals = pg.should_invert_normals try: - ase = ASEBuilder().build(context, options, context.selected_objects) + ase = build_ase(context, options, context.selected_objects) ASEWriter().write(self.filepath, ase) self.report({'INFO'}, 'ASE exported successfully') return {'FINISHED'} - except ASEBuilderError as e: + except ASEBuildError as e: self.report({'ERROR'}, str(e)) return {'CANCELLED'} @@ -190,8 +243,9 @@ class ASE_OT_export_collection(Operator, ExportHelper): def execute(self, context): collection = bpy.data.collections.get(self.collection) - options = ASEBuilderOptions() + options = ASEBuildOptions() options.object_eval_state = self.object_eval_state + options.transform = Matrix.Translation(-Vector(collection.instance_offset)) # Iterate over all the objects in the collection. mesh_objects = get_mesh_objects(collection.all_objects) @@ -199,8 +253,8 @@ class ASE_OT_export_collection(Operator, ExportHelper): options.materials = get_unique_materials([x[0] for x in mesh_objects]) try: - ase = ASEBuilder().build(context, options, collection.all_objects) - except ASEBuilderError as e: + ase = build_ase(context, options, collection.all_objects) + except ASEBuildError as e: self.report({'ERROR'}, str(e)) return {'CANCELLED'} diff --git a/io_scene_ase/writer.py b/io_scene_ase/writer.py index 1d91d4f..bef1562 100644 --- a/io_scene_ase/writer.py +++ b/io_scene_ase/writer.py @@ -1,3 +1,6 @@ +from .ase import ASE + + class ASEFile(object): def __init__(self): self.commands = [] @@ -99,7 +102,7 @@ class ASEWriter(object): self.write_command(command) @staticmethod - def build_ase_tree(ase) -> ASEFile: + def build_ase_tree(ase: ASE) -> ASEFile: root = ASEFile() root.add_command('3DSMAX_ASCIIEXPORT').push_datum(200)