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):
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 = []

View File

@@ -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

View File

@@ -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'}

View File

@@ -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)