Added the ability to change the forward/up axis of the export.

Useful when you want to change the handedness or orientation of an
export without changing anything in the scene.

Also fixed some issues with the normal contextual export operator such
as not respecting the selection status of the objects and doing
double-exports on objects.
This commit is contained in:
Colin Basnett
2024-12-16 17:02:33 -08:00
parent 0bf35f6157
commit 324481d8d2
4 changed files with 113 additions and 24 deletions

View File

@@ -4,6 +4,7 @@ if 'bpy' in locals():
if 'builder' in locals(): importlib.reload(builder) if 'builder' in locals(): importlib.reload(builder)
if 'writer' in locals(): importlib.reload(writer) if 'writer' in locals(): importlib.reload(writer)
if 'exporter' in locals(): importlib.reload(exporter) if 'exporter' in locals(): importlib.reload(exporter)
if 'dfs' in locals(): importlib.reload(dfs)
import bpy import bpy
import bpy.utils.previews import bpy.utils.previews
@@ -11,6 +12,7 @@ from . import ase
from . import builder from . import builder
from . import writer from . import writer
from . import exporter from . import exporter
from . import dfs
classes = exporter.classes classes = exporter.classes

View File

@@ -28,6 +28,36 @@ class ASEBuildOptions(object):
self.should_invert_normals = False self.should_invert_normals = False
self.should_export_visible_only = True self.should_export_visible_only = True
self.scale = 1.0 self.scale = 1.0
self.forward_axis = 'X'
self.up_axis = 'Z'
def get_vector_from_axis_identifier(axis_identifier: str) -> Vector:
match axis_identifier:
case 'X':
return Vector((1.0, 0.0, 0.0))
case 'Y':
return Vector((0.0, 1.0, 0.0))
case 'Z':
return Vector((0.0, 0.0, 1.0))
case '-X':
return Vector((-1.0, 0.0, 0.0))
case '-Y':
return Vector((0.0, -1.0, 0.0))
case '-Z':
return Vector((0.0, 0.0, -1.0))
def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z') -> Matrix:
forward = get_vector_from_axis_identifier(forward_axis)
up = get_vector_from_axis_identifier(up_axis)
left = up.cross(forward)
return Matrix((
(forward.x, forward.y, forward.z, 0.0),
(left.x, left.y, left.z, 0.0),
(up.x, up.y, up.z, 0.0),
(0.0, 0.0, 0.0, 1.0)
))
def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[DfsObject]) -> ASE: def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[DfsObject]) -> ASE:
@@ -46,6 +76,8 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[
mesh_data = cast(Mesh, dfs_object.obj.data) mesh_data = cast(Mesh, dfs_object.obj.data)
max_uv_layers = max(max_uv_layers, len(mesh_data.uv_layers)) max_uv_layers = max(max_uv_layers, len(mesh_data.uv_layers))
coordinate_system_transform = get_coordinate_system_transform(options.forward_axis, options.up_axis)
for object_index, dfs_object in enumerate(dfs_objects): for object_index, dfs_object in enumerate(dfs_objects):
obj = dfs_object.obj obj = dfs_object.obj
matrix_world = dfs_object.matrix_world matrix_world = dfs_object.matrix_world
@@ -92,7 +124,9 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[
vertex_transform = Matrix.Rotation(math.pi, 4, 'Z') @ Matrix.Scale(options.scale, 4) @ 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) vertex = vertex_transform @ vertex.co
vertex = coordinate_system_transform @ vertex
geometry_object.vertices.append(vertex)
material_indices = [] material_indices = []
if not geometry_object.is_collision: if not geometry_object.is_collision:

View File

@@ -7,7 +7,7 @@ instances. This is useful for exporters that need to traverse the object hierarc
from typing import Optional, Set, Iterable, List from typing import Optional, Set, Iterable, List
from bpy.types import Collection, Object, ViewLayer, LayerCollection from bpy.types import Collection, Object, ViewLayer, LayerCollection, Context
from mathutils import Matrix from mathutils import Matrix
@@ -133,22 +133,12 @@ def dfs_view_layer_objects(view_layer: ViewLayer) -> Iterable[DfsObject]:
@param view_layer: The view layer to inspect. @param view_layer: The view layer to inspect.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix. @return: An iterable of tuples containing the object, the instance objects, and the world matrix.
''' '''
def layer_collection_objects_recursive(layer_collection: LayerCollection): def layer_collection_objects_recursive(layer_collection: LayerCollection, visited: Set[Object]=None):
if visited is None:
visited = set()
for child in layer_collection.children: for child in layer_collection.children:
yield from layer_collection_objects_recursive(child) yield from layer_collection_objects_recursive(child, visited=visited)
# Iterate only the top-level objects in this collection first. # Iterate only the top-level objects in this collection first.
yield from _dfs_collection_objects_recursive(layer_collection.collection) yield from _dfs_collection_objects_recursive(layer_collection.collection, visited=visited)
yield from layer_collection_objects_recursive(view_layer.layer_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

@@ -275,6 +275,36 @@ class ASE_OT_populate_material_order_list(Operator):
return {'FINISHED'} return {'FINISHED'}
empty_set = set()
axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z')
forward_items = (
('X', 'X Forward', ''),
('Y', 'Y Forward', ''),
('Z', 'Z Forward', ''),
('-X', '-X Forward', ''),
('-Y', '-Y Forward', ''),
('-Z', '-Z Forward', ''),
)
up_items = (
('X', 'X Up', ''),
('Y', 'Y Up', ''),
('Z', 'Z Up', ''),
('-X', '-X Up', ''),
('-Y', '-Y Up', ''),
('-Z', '-Z Up', ''),
)
def forward_axis_update(self, _context: Context):
if self.forward_axis == self.up_axis:
self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z')
def up_axis_update(self, _context: Context):
if self.up_axis == self.forward_axis:
self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X')
class ASE_OT_export(Operator, ExportHelper): class ASE_OT_export(Operator, ExportHelper):
bl_idname = 'io_scene_ase.ase_export' bl_idname = 'io_scene_ase.ase_export'
bl_label = 'Export ASE' bl_label = 'Export ASE'
@@ -283,6 +313,8 @@ class ASE_OT_export(Operator, ExportHelper):
bl_description = 'Export selected objects to ASE' bl_description = 'Export selected objects to ASE'
filename_ext = '.ase' filename_ext = '.ase'
filter_glob: StringProperty(default="*.ase", options={'HIDDEN'}, maxlen=255) filter_glob: StringProperty(default="*.ase", options={'HIDDEN'}, maxlen=255)
# TODO: why are these not part of the ASE_PG_export property group?
object_eval_state: EnumProperty( object_eval_state: EnumProperty(
items=object_eval_state_items, items=object_eval_state_items,
name='Data', name='Data',
@@ -290,11 +322,13 @@ class ASE_OT_export(Operator, ExportHelper):
) )
should_export_visible_only: BoolProperty(name='Visible Only', default=False, description='Export only visible objects') 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') scale: FloatProperty(name='Scale', default=1.0, min=0.0001, soft_max=1000.0, description='Scale factor to apply to the exported geometry')
forward_axis: EnumProperty(name='Forward', items=forward_items, default='X', update=forward_axis_update)
up_axis: EnumProperty(name='Up', items=up_items, default='Z', update=up_axis_update)
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
if not any(x.type == 'MESH' for x in context.selected_objects): if not any(x.type == 'MESH' or (x.type == 'EMPTY' and x.instance_collection is not None) for x in context.selected_objects):
cls.poll_message_set('At least one mesh must be selected') cls.poll_message_set('At least one mesh or instanced collection must be selected')
return False return False
return True return True
@@ -305,8 +339,6 @@ class ASE_OT_export(Operator, ExportHelper):
flow = layout.grid_flow() flow = layout.grid_flow()
flow.use_property_split = True flow.use_property_split = True
flow.use_property_decorate = False 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')
@@ -334,6 +366,16 @@ class ASE_OT_export(Operator, ExportHelper):
else: else:
vertex_colors_panel.label(text='No vertex color attributes found') vertex_colors_panel.label(text='No vertex color attributes found')
transform_header, transform_panel = layout.panel('Transform', default_closed=True)
transform_header.label(text='Transform')
if transform_panel:
transform_panel.use_property_split = True
transform_panel.use_property_decorate = False
transform_panel.prop(self, 'scale')
transform_panel.prop(self, 'forward_axis')
transform_panel.prop(self, 'up_axis')
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')
@@ -344,7 +386,13 @@ class ASE_OT_export(Operator, ExportHelper):
advanced_panel.prop(pg, 'should_invert_normals') 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)] from .dfs import dfs_view_layer_objects
mesh_objects = list(map(lambda x: x.obj, filter(lambda x: x.is_selected and x.obj.type == 'MESH', dfs_view_layer_objects(context.view_layer))))
if len(mesh_objects) == 0:
self.report({'ERROR'}, 'No mesh objects selected')
return {'CANCELLED'}
pg = getattr(context.scene, 'ase_export') pg = getattr(context.scene, 'ase_export')
@@ -373,10 +421,12 @@ class ASE_OT_export(Operator, ExportHelper):
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.should_export_visible_only = self.should_export_visible_only
options.scale = self.scale options.scale = self.scale
options.forward_axis = self.forward_axis
options.up_axis = self.up_axis
from .dfs import dfs_view_layer_objects from .dfs import dfs_view_layer_objects
dfs_objects = list(filter(lambda x: x.obj.type == 'MESH', dfs_view_layer_objects(context.view_layer))) dfs_objects = list(filter(lambda x: x.is_selected and x.obj.type == 'MESH', dfs_view_layer_objects(context.view_layer)))
try: try:
ase = build_ase(context, options, dfs_objects) ase = build_ase(context, options, dfs_objects)
@@ -425,6 +475,8 @@ class ASE_OT_export_collection(Operator, ExportHelper):
export_space: EnumProperty(name='Export Space', items=export_space_items, default='INSTANCE') export_space: EnumProperty(name='Export Space', items=export_space_items, default='INSTANCE')
should_export_visible_only: BoolProperty(name='Visible Only', default=False, description='Export only visible objects') 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') scale: FloatProperty(name='Scale', default=1.0, min=0.0001, soft_max=1000.0, description='Scale factor to apply to the exported geometry')
forward_axis: EnumProperty(name='Forward', items=forward_items, default='X', update=forward_axis_update)
up_axis: EnumProperty(name='Up', items=up_items, default='Z', update=up_axis_update)
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
@@ -433,7 +485,6 @@ class ASE_OT_export_collection(Operator, ExportHelper):
flow.use_property_split = True flow.use_property_split = True
flow.use_property_decorate = False flow.use_property_decorate = False
flow.prop(self, 'should_export_visible_only') 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')
@@ -450,6 +501,16 @@ class ASE_OT_export_collection(Operator, ExportHelper):
col.separator() col.separator()
col.operator(ASE_OT_populate_material_order_list.bl_idname, icon='FILE_REFRESH', text='') col.operator(ASE_OT_populate_material_order_list.bl_idname, icon='FILE_REFRESH', text='')
transform_header, transform_panel = layout.panel('Transform', default_closed=True)
transform_header.label(text='Transform')
if transform_panel:
transform_panel.use_property_split = True
transform_panel.use_property_decorate = False
transform_panel.prop(self, 'scale')
transform_panel.prop(self, 'forward_axis')
transform_panel.prop(self, 'up_axis')
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')
@@ -465,6 +526,8 @@ 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 options.scale = self.scale
options.forward_axis = self.forward_axis
options.up_axis = self.up_axis
match self.export_space: match self.export_space:
case 'WORLD': case 'WORLD':