From 324481d8d29db28ad8ccf4a9ae16ff5085245e32 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 16 Dec 2024 17:02:33 -0800 Subject: [PATCH] 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. --- io_scene_ase/__init__.py | 2 ++ io_scene_ase/builder.py | 36 ++++++++++++++++++- io_scene_ase/dfs.py | 22 ++++-------- io_scene_ase/exporter.py | 77 ++++++++++++++++++++++++++++++++++++---- 4 files changed, 113 insertions(+), 24 deletions(-) diff --git a/io_scene_ase/__init__.py b/io_scene_ase/__init__.py index 152323c..1688a56 100644 --- a/io_scene_ase/__init__.py +++ b/io_scene_ase/__init__.py @@ -4,6 +4,7 @@ if 'bpy' in locals(): if 'builder' in locals(): importlib.reload(builder) if 'writer' in locals(): importlib.reload(writer) if 'exporter' in locals(): importlib.reload(exporter) + if 'dfs' in locals(): importlib.reload(dfs) import bpy import bpy.utils.previews @@ -11,6 +12,7 @@ from . import ase from . import builder from . import writer from . import exporter +from . import dfs classes = exporter.classes diff --git a/io_scene_ase/builder.py b/io_scene_ase/builder.py index 7bd3b82..c8ce772 100644 --- a/io_scene_ase/builder.py +++ b/io_scene_ase/builder.py @@ -28,6 +28,36 @@ class ASEBuildOptions(object): self.should_invert_normals = False self.should_export_visible_only = True 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: @@ -46,6 +76,8 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[ mesh_data = cast(Mesh, dfs_object.obj.data) 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): obj = dfs_object.obj 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 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 = [] if not geometry_object.is_collision: diff --git a/io_scene_ase/dfs.py b/io_scene_ase/dfs.py index d3c51ff..d6068bb 100644 --- a/io_scene_ase/dfs.py +++ b/io_scene_ase/dfs.py @@ -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 bpy.types import Collection, Object, ViewLayer, LayerCollection +from bpy.types import Collection, Object, ViewLayer, LayerCollection, Context 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. @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: - 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. - 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) - - -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() diff --git a/io_scene_ase/exporter.py b/io_scene_ase/exporter.py index 1036c6b..aea0ab4 100644 --- a/io_scene_ase/exporter.py +++ b/io_scene_ase/exporter.py @@ -275,6 +275,36 @@ class ASE_OT_populate_material_order_list(Operator): 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): bl_idname = 'io_scene_ase.ase_export' bl_label = 'Export ASE' @@ -283,6 +313,8 @@ class ASE_OT_export(Operator, ExportHelper): bl_description = 'Export selected objects to ASE' filename_ext = '.ase' 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( items=object_eval_state_items, 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') 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 def poll(cls, context): - if not any(x.type == 'MESH' for x in context.selected_objects): - cls.poll_message_set('At least one mesh must be selected') + 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 or instanced collection must be selected') return False return True @@ -305,8 +339,6 @@ class ASE_OT_export(Operator, ExportHelper): 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.label(text='Materials') @@ -334,6 +366,16 @@ class ASE_OT_export(Operator, ExportHelper): else: 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.label(text='Advanced') @@ -344,7 +386,13 @@ class ASE_OT_export(Operator, ExportHelper): 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)] + 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') @@ -373,10 +421,12 @@ class ASE_OT_export(Operator, ExportHelper): options.should_invert_normals = pg.should_invert_normals options.should_export_visible_only = self.should_export_visible_only options.scale = self.scale + options.forward_axis = self.forward_axis + options.up_axis = self.up_axis 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: 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') 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') + 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): layout = self.layout @@ -433,7 +485,6 @@ class ASE_OT_export_collection(Operator, ExportHelper): 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.label(text='Materials') @@ -450,6 +501,16 @@ class ASE_OT_export_collection(Operator, ExportHelper): col.separator() 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.label(text='Advanced') @@ -465,6 +526,8 @@ class ASE_OT_export_collection(Operator, ExportHelper): options = ASEBuildOptions() options.object_eval_state = self.object_eval_state options.scale = self.scale + options.forward_axis = self.forward_axis + options.up_axis = self.up_axis match self.export_space: case 'WORLD':