Fixed vertex color export & added "invert normals" option

This commit is contained in:
Colin Basnett
2024-09-13 17:29:33 -07:00
parent 438e332c36
commit 17679273a3
4 changed files with 220 additions and 139 deletions

View File

@@ -1,3 +1,8 @@
from typing import Optional, List
from bpy.types import Material
class ASEFace(object): class ASEFace(object):
def __init__(self): def __init__(self):
self.a = 0 self.a = 0
@@ -50,6 +55,5 @@ class ASEGeometryObject(object):
class ASE(object): class ASE(object):
def __init__(self): def __init__(self):
self.materials = [] self.materials: List[Optional[Material]] = []
self.geometry_objects = [] self.geometry_objects = []

View File

@@ -1,6 +1,6 @@
from typing import Iterable, Optional, List, Tuple 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 from .ase import ASE, ASEGeometryObject, ASEFace, ASEFaceNormal, ASEVertexNormal, ASEUVLayer, is_collision_name
import bpy import bpy
@@ -10,14 +10,20 @@ from mathutils import Matrix, Vector
SMOOTHING_GROUP_MAX = 32 SMOOTHING_GROUP_MAX = 32
class ASEBuilderError(Exception): class ASEBuildError(Exception):
pass pass
class ASEBuilderOptions(object): 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.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: 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 return mesh_objects
def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Object]) -> ASE:
ase = ASE()
class ASEBuilder(object): main_geometry_object = None
def build(self, context: Context, options: ASEBuilderOptions, objects: Iterable[Object]): mesh_objects = get_mesh_objects(objects)
ase = ASE()
main_geometry_object = None context.window_manager.progress_begin(0, len(mesh_objects))
mesh_objects = get_mesh_objects(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:
match options.object_eval_state: case 'ORIGINAL':
case 'ORIGINAL': mesh_object = obj
mesh_object = obj mesh_data = mesh_object.data
mesh_data = mesh_object.data case 'EVALUATED':
case 'EVALUATED': # Evaluate the mesh after modifiers are applied
depsgraph = context.evaluated_depsgraph_get() 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.
bm = bmesh.new() bm = bmesh.new()
bm.from_mesh(mesh_object.data) bm.from_object(obj, depsgraph)
for edge in bm.edges: mesh_data = bpy.data.meshes.new('')
if not edge.is_manifold: bm.to_mesh(mesh_data)
del bm del bm
raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not manifold') mesh_object = bpy.data.objects.new('', mesh_data)
if not edge.is_convex: mesh_object.matrix_world = matrix_world
del bm
raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not convex')
if not geometry_object.is_collision and len(obj.data.materials) == 0: if not is_collision_name(obj.name) and main_geometry_object is not None:
raise ASEBuilderError(f'Mesh \'{obj.name}\' must have at least one material') geometry_object = main_geometry_object
else:
vertex_transform = Matrix.Rotation(math.pi, 4, 'Z') @ matrix_world geometry_object = ASEGeometryObject()
geometry_object.name = obj.name
for vertex_index, vertex in enumerate(mesh_data.vertices):
geometry_object.vertices.append(vertex_transform @ vertex.co)
material_indices = []
if not geometry_object.is_collision: if not geometry_object.is_collision:
for mesh_material_index, material in enumerate(obj.data.materials): main_geometry_object = geometry_object
if material is None: ase.geometry_objects.append(geometry_object)
raise ASEBuilderError(f'Material slot {mesh_material_index + 1} for mesh \'{obj.name}\' cannot be empty')
material_indices.append(ase.materials.index(material))
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. vertex_transform = Matrix.Rotation(math.pi, 4, 'Z') @ matrix_world
poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=False)
# Figure out how many scaling axes are negative. for vertex_index, vertex in enumerate(mesh_data.vertices):
# This is important for calculating the normals of the mesh. geometry_object.vertices.append(vertex_transform @ vertex.co)
_, _, scale = vertex_transform.decompose()
negative_scaling_axes = sum([1 for x in scale if x < 0])
should_invert_normals = negative_scaling_axes % 2 == 1
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): for face_index, loop_triangle in enumerate(mesh_data.loop_triangles):
face = ASEFace() face_normal = ASEFaceNormal()
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_normal.normal = loop_triangle.normal
if not geometry_object.is_collision: face_normal.vertex_normals = []
face.material_index = material_indices[loop_triangle.material_index] for i in loop_triangle_index_order:
# The UT2K4 importer only accepts 32 smoothing groups. Anything past this completely mangles the vertex_normal = ASEVertexNormal()
# smoothing groups and effectively makes the whole model use sharp-edge rendering. vertex_normal.vertex_index = geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[i]].vertex_index
# The fix is to constrain the smoothing group between 0 and 31 by applying a modulo of 32 to the actual vertex_normal.normal = loop_triangle.split_normals[i]
# smoothing group index. if should_invert_normals:
# This may result in bad calculated normals on export in rare cases. For example, if a face with a vertex_normal.normal = (-Vector(vertex_normal.normal)).to_tuple()
# smoothing group of 3 is adjacent to a face with a smoothing group of 35 (35 % 32 == 3), those faces face_normal.vertex_normals.append(vertex_normal)
# will be treated as part of the same smoothing group. geometry_object.face_normals.append(face_normal)
face.smoothing = (poly_groups[loop_triangle.polygon_index] - 1) % SMOOTHING_GROUP_MAX
geometry_object.faces.append(face)
if not geometry_object.is_collision: # Texture Coordinates
# Normals for i, uv_layer_data in enumerate([x.data for x in mesh_data.uv_layers]):
for face_index, loop_triangle in enumerate(mesh_data.loop_triangles): if i >= len(geometry_object.uv_layers):
face_normal = ASEFaceNormal() geometry_object.uv_layers.append(ASEUVLayer())
face_normal.normal = loop_triangle.normal uv_layer = geometry_object.uv_layers[i]
face_normal.vertex_normals = [] for loop_index, loop in enumerate(mesh_data.loops):
for i in loop_triangle_index_order: u, v = uv_layer_data[loop_index].uv
vertex_normal = ASEVertexNormal() uv_layer.texture_vertices.append((u, v, 0.0))
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 # Texture Faces
for i, uv_layer_data in enumerate([x.data for x in mesh_data.uv_layers]): for loop_triangle in mesh_data.loop_triangles:
if i >= len(geometry_object.uv_layers): geometry_object.texture_vertex_faces.append(
geometry_object.uv_layers.append(ASEUVLayer()) tuple(map(lambda l: geometry_object.texture_vertex_offset + loop_triangle.loops[l], loop_triangle_index_order))
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 # Vertex Colors
for loop_triangle in mesh_data.loop_triangles: if options.should_export_vertex_colors and options.has_vertex_colors:
geometry_object.texture_vertex_faces.append( color_attribute = None
tuple(map(lambda l: geometry_object.texture_vertex_offset + loop_triangle.loops[l], loop_triangle_index_order)) 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 color_attribute is not None:
if len(mesh_data.vertex_colors) > 0: # Make sure that the selected color attribute is on the CORNER domain.
if mesh_data.vertex_colors.active is not None: if color_attribute.domain != 'CORNER':
vertex_colors = mesh_data.vertex_colors.active.data raise ASEBuildError(f'Color attribute \'{color_attribute.name}\' for object \'{obj.name}\' must have domain of \'CORNER\' (found \'{color_attribute.domain}\')')
for color in map(lambda x: x.color, vertex_colors):
geometry_object.vertex_colors.append(tuple(color[0:3]))
# Update data offsets for next iteration for color in map(lambda x: x.color, color_attribute.data):
geometry_object.texture_vertex_offset += len(mesh_data.loops) geometry_object.vertex_colors.append(tuple(color[0:3]))
geometry_object.vertex_offset = len(geometry_object.vertices)
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: context.window_manager.progress_end()
raise ASEBuilderError('At least one mesh object must be selected')
if main_geometry_object is None: if len(ase.geometry_objects) == 0:
raise ASEBuilderError('At least one non-collision mesh must be exported') 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

View File

@@ -3,9 +3,11 @@ from typing import Iterable, List, Set, Union
import bpy import bpy
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty from bpy.props import StringProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty, BoolProperty
from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler, Collection from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler
from .builder import ASEBuilder, ASEBuilderOptions, ASEBuilderError, get_mesh_objects from mathutils import Matrix, Vector
from .builder import ASEBuildOptions, ASEBuildError, get_mesh_objects, build_ase
from .writer import ASEWriter from .writer import ASEWriter
@@ -13,9 +15,35 @@ class ASE_PG_material(PropertyGroup):
material: PointerProperty(type=Material) 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): class ASE_PG_export(PropertyGroup):
material_list: CollectionProperty(name='Materials', type=ASE_PG_material) material_list: CollectionProperty(name='Materials', type=ASE_PG_material)
material_list_index: IntProperty(name='Index', default=0) 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]: def get_unique_materials(mesh_objects: Iterable[Object]) -> List[Material]:
@@ -109,17 +137,35 @@ class ASE_OT_export(Operator, ExportHelper):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
pg = context.scene.ase_export
materials_header, materials_panel = layout.panel('Materials', default_closed=False) materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials') materials_header.label(text='Materials')
if materials_panel: if materials_panel:
row = materials_panel.row() 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 = 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_up.bl_idname, icon='TRIA_UP', text='')
col.operator(ASE_OT_material_list_move_down.bl_idname, icon='TRIA_DOWN', 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, advanced_panel = layout.panel('Advanced', default_closed=True)
advanced_header.label(text='Advanced') 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_split = True
advanced_panel.use_property_decorate = False advanced_panel.use_property_decorate = False
advanced_panel.prop(self, 'object_eval_state') 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]]: 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)] 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'} return {'RUNNING_MODAL'}
def execute(self, context): def execute(self, context):
options = ASEBuilderOptions()
options.object_eval_state = self.object_eval_state
pg = getattr(context.scene, 'ase_export') 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.materials = [x.material for x in pg.material_list]
options.should_invert_normals = pg.should_invert_normals
try: try:
ase = ASEBuilder().build(context, options, context.selected_objects) ase = build_ase(context, options, context.selected_objects)
ASEWriter().write(self.filepath, ase) ASEWriter().write(self.filepath, ase)
self.report({'INFO'}, 'ASE exported successfully') self.report({'INFO'}, 'ASE exported successfully')
return {'FINISHED'} return {'FINISHED'}
except ASEBuilderError as e: except ASEBuildError as e:
self.report({'ERROR'}, str(e)) self.report({'ERROR'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
@@ -190,8 +243,9 @@ class ASE_OT_export_collection(Operator, ExportHelper):
def execute(self, context): def execute(self, context):
collection = bpy.data.collections.get(self.collection) collection = bpy.data.collections.get(self.collection)
options = ASEBuilderOptions() options = ASEBuildOptions()
options.object_eval_state = self.object_eval_state options.object_eval_state = self.object_eval_state
options.transform = Matrix.Translation(-Vector(collection.instance_offset))
# 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)
@@ -199,8 +253,8 @@ class ASE_OT_export_collection(Operator, ExportHelper):
options.materials = get_unique_materials([x[0] for x in mesh_objects]) options.materials = get_unique_materials([x[0] for x in mesh_objects])
try: try:
ase = ASEBuilder().build(context, options, collection.all_objects) ase = build_ase(context, options, collection.all_objects)
except ASEBuilderError as e: except ASEBuildError as e:
self.report({'ERROR'}, str(e)) self.report({'ERROR'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}

View File

@@ -1,3 +1,6 @@
from .ase import ASE
class ASEFile(object): class ASEFile(object):
def __init__(self): def __init__(self):
self.commands = [] self.commands = []
@@ -99,7 +102,7 @@ class ASEWriter(object):
self.write_command(command) self.write_command(command)
@staticmethod @staticmethod
def build_ase_tree(ase) -> ASEFile: def build_ase_tree(ase: ASE) -> ASEFile:
root = ASEFile() root = ASEFile()
root.add_command('3DSMAX_ASCIIEXPORT').push_datum(200) root.add_command('3DSMAX_ASCIIEXPORT').push_datum(200)