A number of additions to functionality:

* The "Scale" option to export dialog.
* Added "Visible Only" option to export dialog.
* Collection Instances can now be exported (handles recursion etc.)
* Added material re-ordering (available on collection exporter only)
This commit is contained in:
Colin Basnett
2024-12-09 00:05:38 -08:00
parent d6c0186031
commit 4b73bf4cd0
4 changed files with 269 additions and 43 deletions

View File

@@ -1,4 +1,4 @@
from typing import Iterable, Optional, List, Tuple from typing import Iterable, Optional, List, Tuple, cast
from bpy.types import Object, Context, Material, Mesh from bpy.types import Object, Context, Material, Mesh
@@ -8,6 +8,8 @@ import bmesh
import math import math
from mathutils import Matrix, Vector from mathutils import Matrix, Vector
from .dfs import DfsObject
SMOOTHING_GROUP_MAX = 32 SMOOTHING_GROUP_MAX = 32
class ASEBuildError(Exception): class ASEBuildError(Exception):
@@ -24,40 +26,29 @@ class ASEBuildOptions(object):
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
def get_object_matrix(obj: Object, asset_instance: Optional[Object] = None) -> Matrix: def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[DfsObject]) -> ASE:
if asset_instance is not None:
return asset_instance.matrix_world @ Matrix().Translation(asset_instance.instance_collection.instance_offset) @ obj.matrix_local
return obj.matrix_world
def get_mesh_objects(objects: Iterable[Object]) -> List[Tuple[Object, Optional[Object]]]:
mesh_objects = []
for obj in objects:
if obj.type == 'MESH':
mesh_objects.append((obj, None))
elif obj.instance_collection:
for instance_object in obj.instance_collection.all_objects:
if instance_object.type == 'MESH':
mesh_objects.append((instance_object, obj))
return mesh_objects
def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Object]) -> ASE:
ase = ASE() ase = ASE()
main_geometry_object = None main_geometry_object = None
mesh_objects = get_mesh_objects(objects)
context.window_manager.progress_begin(0, len(mesh_objects)) dfs_objects = list(dfs_objects)
context.window_manager.progress_begin(0, len(dfs_objects))
ase.materials = options.materials ase.materials = options.materials
for object_index, (obj, asset_instance) in enumerate(mesh_objects): max_uv_layers = 0
for dfs_object in dfs_objects:
mesh_data = cast(Mesh, dfs_object.obj.data)
max_uv_layers = max(max_uv_layers, len(mesh_data.uv_layers))
matrix_world = get_object_matrix(obj, asset_instance) for object_index, dfs_object in enumerate(dfs_objects):
matrix_world = options.transform @ matrix_world obj = dfs_object.obj
matrix_world = dfs_object.matrix_world
# Save the active color name for vertex color export. # Save the active color name for vertex color export.
active_color_name = obj.data.color_attributes.active_color_name active_color_name = obj.data.color_attributes.active_color_name
@@ -98,7 +89,7 @@ def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Obje
del bm del bm
raise ASEBuildError(f'Collision mesh \'{obj.name}\' is not convex') raise ASEBuildError(f'Collision mesh \'{obj.name}\' is not convex')
vertex_transform = Matrix.Rotation(math.pi, 4, 'Z') @ matrix_world vertex_transform = Matrix.Rotation(math.pi, 4, 'Z') @ Matrix.Scale(options.scale, 4) @ matrix_world
for vertex_index, vertex in enumerate(mesh_data.vertices): for vertex_index, vertex in enumerate(mesh_data.vertices):
geometry_object.vertices.append(vertex_transform @ vertex.co) geometry_object.vertices.append(vertex_transform @ vertex.co)
@@ -184,6 +175,13 @@ def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Obje
u, v = uv_layer_data[loop_index].uv u, v = uv_layer_data[loop_index].uv
uv_layer.texture_vertices.append((u, v, 0.0)) uv_layer.texture_vertices.append((u, v, 0.0))
# Add zeroed texture vertices for any missing UV layers.
for i in range(len(geometry_object.uv_layers), max_uv_layers):
uv_layer = ASEUVLayer()
for _ in mesh_data.loops:
uv_layer.texture_vertices.append((0.0, 0.0, 0.0))
geometry_object.uv_layers.append(uv_layer)
# Texture Faces # Texture Faces
for loop_triangle in mesh_data.loop_triangles: for loop_triangle in mesh_data.loop_triangles:
geometry_object.texture_vertex_faces.append( geometry_object.texture_vertex_faces.append(

154
io_scene_ase/dfs.py Normal file
View File

@@ -0,0 +1,154 @@
'''
Depth-first object iterator functions for Blender collections and view layers.
These functions are used to iterate over objects in a collection or view layer in a depth-first manner, including
instances. This is useful for exporters that need to traverse the object hierarchy in a predictable order.
'''
from typing import Optional, Set, Iterable, List
from bpy.types import Collection, Object, ViewLayer, LayerCollection
from mathutils import Matrix
class DfsObject:
'''
Represents an object in a depth-first search.
'''
def __init__(self, obj: Object, instance_objects: List[Object], matrix_world: Matrix):
self.obj = obj
self.instance_objects = instance_objects
self.matrix_world = matrix_world
@property
def is_visible(self) -> bool:
'''
Check if the object is visible.
@return: True if the object is visible, False otherwise.
'''
if self.instance_objects:
return self.instance_objects[-1].visible_get()
return self.obj.visible_get()
@property
def is_selected(self) -> bool:
'''
Check if the object is selected.
@return: True if the object is selected, False otherwise.
'''
if self.instance_objects:
return self.instance_objects[-1].select_get()
return self.obj.select_get()
def _dfs_object_children(obj: Object, collection: Collection) -> Iterable[Object]:
'''
Construct a list of objects in hierarchy order from `collection.objects`, only keeping those that are in the
collection.
@param obj: The object to start the search from.
@param collection: The collection to search in.
@return: An iterable of objects in hierarchy order.
'''
yield obj
for child in obj.children:
if child.name in collection.objects:
yield from _dfs_object_children(child, collection)
def dfs_objects_in_collection(collection: Collection) -> Iterable[Object]:
'''
Returns a depth-first iterator over all objects in a collection, only keeping those that are directly in the
collection.
@param collection: The collection to search in.
@return: An iterable of objects in hierarchy order.
'''
objects_hierarchy = []
for obj in collection.objects:
if obj.parent is None or obj.parent not in set(collection.objects):
objects_hierarchy.append(obj)
for obj in objects_hierarchy:
yield from _dfs_object_children(obj, collection)
def dfs_collection_objects(collection: Collection, visible_only: bool = False) -> Iterable[DfsObject]:
'''
Depth-first search of objects in a collection, including recursing into instances.
@param collection: The collection to search in.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix.
'''
yield from _dfs_collection_objects_recursive(collection)
def _dfs_collection_objects_recursive(
collection: Collection,
instance_objects: Optional[List[Object]] = None,
matrix_world: Matrix = Matrix.Identity(4),
visited: Optional[Set[Object]]=None
) -> Iterable[DfsObject]:
'''
Depth-first search of objects in a collection, including recursing into instances.
This is a recursive function.
@param collection: The collection to search in.
@param instance_objects: The running hierarchy of instance objects.
@param matrix_world: The world matrix of the current object.
@param visited: A set of visited object-instance pairs.
@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.
if visited is None:
visited = set()
if instance_objects is None:
instance_objects = list()
# First, yield all objects in child collections.
for child in collection.children:
yield from _dfs_collection_objects_recursive(child, instance_objects, matrix_world.copy(), visited)
# Then, evaluate all objects in this collection.
for obj in dfs_objects_in_collection(collection):
visited_pair = (obj, instance_objects[-1] if instance_objects else None)
if visited_pair in visited:
continue
# If this an instance, we need to recurse into it.
if obj.instance_collection is not None:
# Calculate the instance transform.
instance_offset_matrix = Matrix.Translation(-obj.instance_collection.instance_offset)
# Recurse into the instance collection.
yield from _dfs_collection_objects_recursive(obj.instance_collection,
instance_objects + [obj],
matrix_world @ (obj.matrix_world @ instance_offset_matrix),
visited)
else:
# Object is not an instance, yield it.
yield DfsObject(obj, instance_objects, matrix_world @ obj.matrix_world)
visited.add(visited_pair)
def dfs_view_layer_objects(view_layer: ViewLayer) -> Iterable[DfsObject]:
'''
Depth-first iterator over all objects in a view layer, including recursing into instances.
@param view_layer: The view layer to inspect.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix.
'''
def layer_collection_objects_recursive(layer_collection: LayerCollection):
for child in layer_collection.children:
yield from layer_collection_objects_recursive(child)
# Iterate only the top-level objects in this collection first.
yield from _dfs_collection_objects_recursive(layer_collection.collection)
yield from layer_collection_objects_recursive(view_layer.layer_collection)
def _is_dfs_object_visible(obj: Object, instance_objects: List[Object]) -> bool:
'''
Check if a DFS object is visible.
@param obj: The object.
@param instance_objects: The instance objects.
@return: True if the object is visible, False otherwise.
'''
if instance_objects:
return instance_objects[-1].visible_get()
return obj.visible_get()

View File

@@ -1,14 +1,14 @@
import os.path
from typing import Iterable, List, Set, Union, cast, Optional 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, PointerProperty, IntProperty, EnumProperty, BoolProperty, \
FloatProperty
from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler, Event, Context, SpaceProperties, \ from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler, Event, Context, SpaceProperties, \
Collection Collection
from mathutils import Matrix, Vector from mathutils import Matrix, Vector
from .builder import ASEBuildOptions, ASEBuildError, get_mesh_objects, build_ase from .builder import ASEBuildOptions, ASEBuildError, build_ase
from .writer import ASEWriter from .writer import ASEWriter
@@ -238,10 +238,41 @@ class ASE_UL_strings(UIList):
object_eval_state_items = ( object_eval_state_items = [
('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'), ('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'),
('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'), ('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'),
) ]
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):
collection = get_collection_from_context(context)
operator = get_collection_export_operator_from_context(context)
if operator is None:
return {'CANCELLED'}
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))))
# Exclude objects that are not visible.
materials = get_unique_materials(mesh_objects)
operator.material_order.clear()
for material in materials:
m = operator.material_order.add()
m.string = material.name
return {'FINISHED'}
class ASE_OT_export(Operator, ExportHelper): class ASE_OT_export(Operator, ExportHelper):
@@ -257,6 +288,8 @@ class ASE_OT_export(Operator, ExportHelper):
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')
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
@@ -269,6 +302,12 @@ class ASE_OT_export(Operator, ExportHelper):
layout = self.layout layout = self.layout
pg = context.scene.ase_export pg = context.scene.ase_export
flow = layout.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'should_export_visible_only')
flow.prop(self, 'scale')
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')
@@ -279,7 +318,6 @@ class ASE_OT_export(Operator, ExportHelper):
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 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') vertex_colors_header, vertex_colors_panel = layout.panel_prop(pg, 'should_export_vertex_colors')
row = vertex_colors_header.row() row = vertex_colors_header.row()
@@ -333,8 +371,15 @@ 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
from .dfs import dfs_view_layer_objects
dfs_objects = list(filter(lambda x: x.obj.type == 'MESH', dfs_view_layer_objects(context.view_layer)))
try: try:
ase = build_ase(context, options, context.selected_objects) ase = build_ase(context, options, dfs_objects)
# Calculate some statistics about the ASE file to display in the console. # Calculate some statistics about the ASE file to display in the console.
object_count = len(ase.geometry_objects) object_count = len(ase.geometry_objects)
@@ -350,6 +395,12 @@ class ASE_OT_export(Operator, ExportHelper):
return {'CANCELLED'} return {'CANCELLED'}
export_space_items = [
('WORLD', 'World Space', 'Export the collection in world-space (i.e., as it appears in the 3D view)'),
('INSTANCE', 'Instance Space', 'Export the collection as an instance (transforms the world-space geometry by the inverse of the instance offset)'),
]
class ASE_OT_export_collection(Operator, ExportHelper): class ASE_OT_export_collection(Operator, ExportHelper):
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'
@@ -371,14 +422,19 @@ class ASE_OT_export_collection(Operator, ExportHelper):
collection: StringProperty() collection: StringProperty()
material_order: CollectionProperty(name='Materials', type=ASE_PG_string) material_order: CollectionProperty(name='Materials', type=ASE_PG_string)
material_order_index: IntProperty(name='Index', default=0) material_order_index: IntProperty(name='Index', default=0)
export_space: EnumProperty(name='Export Space', items=( export_space: EnumProperty(name='Export Space', items=export_space_items, default='INSTANCE')
('WORLD', 'World Space', 'Export the collection in world-space (i.e., as it appears in the 3D view)'), should_export_visible_only: BoolProperty(name='Visible Only', default=False, description='Export only visible objects')
('INSTANCE', 'Instance Space', 'Export the collection as an instance (transforms the world-space geometry by the inverse of the instance offset)'), scale: FloatProperty(name='Scale', default=1.0, min=0.0001, soft_max=1000.0, description='Scale factor to apply to the exported geometry')
), default='INSTANCE')
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
flow = layout.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'should_export_visible_only')
flow.prop(self, 'scale')
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')
@@ -391,6 +447,8 @@ class ASE_OT_export_collection(Operator, ExportHelper):
col.separator() col.separator()
col.operator(ASE_OT_material_order_move_up.bl_idname, icon='TRIA_UP', text='') col.operator(ASE_OT_material_order_move_up.bl_idname, icon='TRIA_UP', text='')
col.operator(ASE_OT_material_order_move_down.bl_idname, icon='TRIA_DOWN', text='') col.operator(ASE_OT_material_order_move_down.bl_idname, icon='TRIA_DOWN', text='')
col.separator()
col.operator(ASE_OT_populate_material_order_list.bl_idname, icon='FILE_REFRESH', text='')
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')
@@ -406,6 +464,7 @@ 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
match self.export_space: match self.export_space:
case 'WORLD': case 'WORLD':
@@ -413,20 +472,34 @@ class ASE_OT_export_collection(Operator, ExportHelper):
case 'INSTANCE': case 'INSTANCE':
options.transform = Matrix.Translation(-Vector(collection.instance_offset)) options.transform = Matrix.Translation(-Vector(collection.instance_offset))
# Iterate over all the objects in the collection. from .dfs import dfs_collection_objects
mesh_objects = get_mesh_objects(collection.all_objects)
dfs_objects = list(filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection, options.should_export_visible_only)))
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([x[0] for x in mesh_objects]) options.materials = get_unique_materials(mesh_objects)
# Sort the materials based on the order in the material order list, keeping in mind that the material order list # Sort the materials based on the order in the material order list, keeping in mind that the material order list
# may not contain all the materials used by the objects in the collection. # may not contain all the materials used by the objects in the collection.
material_order = [x.string for x in self.material_order] material_order = [x.string for x in self.material_order]
material_order_map = {x: i for i, x in enumerate(material_order)} material_order_map = {x: i for i, x in enumerate(material_order)}
options.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
# 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_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)))
options.materials = ordered_materials + unordered_materials
try: try:
ase = build_ase(context, options, collection.all_objects) ase = build_ase(context, options, dfs_objects)
except ASEBuildError as e: except ASEBuildError as e:
self.report({'ERROR'}, str(e)) self.report({'ERROR'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
@@ -462,5 +535,6 @@ classes = (
ASE_OT_material_order_remove, ASE_OT_material_order_remove,
ASE_OT_material_order_move_down, ASE_OT_material_order_move_down,
ASE_OT_material_order_move_up, ASE_OT_material_order_move_up,
ASE_OT_populate_material_order_list,
ASE_FH_export, ASE_FH_export,
) )

View File

@@ -115,7 +115,7 @@ class ASEWriter(object):
for material_index, material in enumerate(ase.materials): for material_index, material in enumerate(ase.materials):
submaterial_node = material_node.push_child('SUBMATERIAL') submaterial_node = material_node.push_child('SUBMATERIAL')
submaterial_node.push_datum(material_index) submaterial_node.push_datum(material_index)
submaterial_node.push_child('MATERIAL_NAME').push_datum(material) submaterial_node.push_child('MATERIAL_NAME').push_datum(material.name)
diffuse_node = submaterial_node.push_child('MAP_DIFFUSE') diffuse_node = submaterial_node.push_child('MAP_DIFFUSE')
diffuse_node.push_child('MAP_NAME').push_datum('default') diffuse_node.push_child('MAP_NAME').push_datum('default')
diffuse_node.push_child('UVW_U_OFFSET').push_datum(0.0) diffuse_node.push_child('UVW_U_OFFSET').push_datum(0.0)