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,9 +44,7 @@ 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:
class ASEBuilder(object):
def build(self, context: Context, options: ASEBuilderOptions, objects: Iterable[Object]):
ase = ASE() ase = ASE()
main_geometry_object = None main_geometry_object = None
@@ -54,12 +58,15 @@ class ASEBuilder(object):
matrix_world = get_object_matrix(obj, asset_instance) matrix_world = get_object_matrix(obj, asset_instance)
# Evaluate the mesh after modifiers are applied # Save the active color name for vertex color export.
active_color_name = obj.data.color_attributes.active_color_name
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 = bmesh.new()
bm.from_object(obj, depsgraph) bm.from_object(obj, depsgraph)
@@ -85,13 +92,10 @@ class ASEBuilder(object):
for edge in bm.edges: for edge in bm.edges:
if not edge.is_manifold: if not edge.is_manifold:
del bm del bm
raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not manifold') raise ASEBuildError(f'Collision mesh \'{obj.name}\' is not manifold')
if not edge.is_convex: if not edge.is_convex:
del bm del bm
raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not convex') raise ASEBuildError(f'Collision mesh \'{obj.name}\' is not convex')
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 vertex_transform = Matrix.Rotation(math.pi, 4, 'Z') @ matrix_world
@@ -102,9 +106,13 @@ class ASEBuilder(object):
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):
if material is None: if material is None:
raise ASEBuilderError(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)) material_indices.append(ase.materials.index(material))
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() mesh_data.calc_loop_triangles()
# Calculate smoothing groups. # Calculate smoothing groups.
@@ -115,6 +123,8 @@ class ASEBuilder(object):
_, _, scale = vertex_transform.decompose() _, _, scale = vertex_transform.decompose()
negative_scaling_axes = sum([1 for x in scale if x < 0]) negative_scaling_axes = sum([1 for x in scale if x < 0])
should_invert_normals = negative_scaling_axes % 2 == 1 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) loop_triangle_index_order = (2, 1, 0) if should_invert_normals else (0, 1, 2)
@@ -165,10 +175,20 @@ class ASEBuilder(object):
) )
# Vertex Colors # Vertex Colors
if len(mesh_data.vertex_colors) > 0: if options.should_export_vertex_colors and options.has_vertex_colors:
if mesh_data.vertex_colors.active is not None: color_attribute = None
vertex_colors = mesh_data.vertex_colors.active.data match options.vertex_color_mode:
for color in map(lambda x: x.color, vertex_colors): 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)
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}\')')
for color in map(lambda x: x.color, color_attribute.data):
geometry_object.vertex_colors.append(tuple(color[0:3])) geometry_object.vertex_colors.append(tuple(color[0:3]))
# Update data offsets for next iteration # Update data offsets for next iteration
@@ -180,9 +200,9 @@ class ASEBuilder(object):
context.window_manager.progress_end() context.window_manager.progress_end()
if len(ase.geometry_objects) == 0: if len(ase.geometry_objects) == 0:
raise ASEBuilderError('At least one mesh object must be selected') raise ASEBuildError('At least one mesh object must be selected')
if main_geometry_object is None: if main_geometry_object is None:
raise ASEBuilderError('At least one non-collision mesh must be exported') raise ASEBuildError('At least one non-collision mesh must be exported')
return ase 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)