6 Commits
2.2.0 ... main

Author SHA1 Message Date
Colin Basnett
f3a2eb10ad Added material name mapping to collection exporter
Also moved a bunch of things around
2025-09-15 02:04:50 -07:00
Colin Basnett
429e9b4030 Merge remote-tracking branch 'origin/main' 2025-08-01 03:10:49 -07:00
Colin Basnett
86c7ca5639 Added comment about bad normals caused by overlapping vertices 2025-08-01 03:10:41 -07:00
Colin Basnett
e41b154d94 Update README.md 2025-04-28 14:38:10 -07:00
Colin Basnett
e1a18799ba Incremented version to 2.2.1 2025-04-15 03:11:58 -07:00
Colin Basnett
24e982a0ad UT2K4 can now do automatic texture lookups from material names 2025-04-15 03:10:29 -07:00
8 changed files with 334 additions and 202 deletions

View File

@@ -9,8 +9,8 @@ Legacy versions are available on the [releases](https://github.com/DarklightGame
# Features
* Selection and [collection exporters](https://docs.blender.org/manual/en/latest/scene_layout/collections/collections.html#exporters).
* Fully support for handling of [Collection Instances](https://docs.blender.org/manual/en/latest/scene_layout/object/properties/instancing/collection.html).
* Full support for exporting multiple UV layers.
* Full support for handling of [Collection Instances](https://docs.blender.org/manual/en/latest/scene_layout/object/properties/instancing/collection.html).
* Full support for multiple UV layers.
* Easily reorder materials when using the collection exporter.
* Support for scaling and coordinate system conversion on export.

View File

@@ -3,6 +3,7 @@ if 'bpy' in locals():
if 'ase' in locals(): importlib.reload(ase)
if 'builder' in locals(): importlib.reload(builder)
if 'writer' in locals(): importlib.reload(writer)
if 'properties' in locals(): importlib.reload(properties)
if 'exporter' in locals(): importlib.reload(exporter)
if 'dfs' in locals(): importlib.reload(dfs)
@@ -11,10 +12,11 @@ import bpy.utils.previews
from . import ase
from . import builder
from . import writer
from . import properties
from . import exporter
from . import dfs
classes = exporter.classes
classes = properties.classes + exporter.classes
def menu_func_export(self, context):
@@ -25,7 +27,8 @@ def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.ase_export = bpy.props.PointerProperty(type=exporter.ASE_PG_export)
bpy.types.Scene.ase_settings = bpy.props.PointerProperty(type=properties.ASE_PG_scene_settings, options={'HIDDEN'})
bpy.types.Scene.ase_export = bpy.props.PointerProperty(type=properties.ASE_PG_export, options={'HIDDEN'})
bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
@@ -33,6 +36,7 @@ def register():
def unregister():
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
del bpy.types.Scene.ase_settings
del bpy.types.Scene.ase_export
for cls in classes:

View File

@@ -1,6 +1,6 @@
schema_version = "1.0.0"
id = "io_scene_ase"
version = "2.2.0"
version = "2.2.1"
name = "ASCII Scene Export (.ase)"
tagline = "Export .ase files used in Unreal Engine 1 & 2"
maintainer = "Colin Basnett <cmbasnett@gmail.com>"

View File

@@ -1,6 +1,8 @@
from typing import Iterable, Optional, List, Tuple, cast
from typing import Iterable, Optional, List, Dict, cast
from collections import OrderedDict
from bpy.types import Object, Context, Material, Mesh
from bpy.types import Context, Material, Mesh
from .ase import ASE, ASEGeometryObject, ASEFace, ASEFaceNormal, ASEVertexNormal, ASEUVLayer, is_collision_name
import bpy
@@ -20,13 +22,13 @@ class ASEBuildOptions(object):
def __init__(self):
self.object_eval_state = 'EVALUATED'
self.materials: Optional[List[Material]] = None
self.material_mapping: Dict[str, str] = OrderedDict()
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
self.should_export_visible_only = True
self.scale = 1.0
self.forward_axis = 'X'
self.up_axis = 'Z'
@@ -62,7 +64,7 @@ def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z')
def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[DfsObject]) -> ASE:
ase = ASE()
ase.materials = [x.name for x in options.materials]
ase.materials = [x.name if x is not None else 'None' for x in options.materials]
# If no materials are assigned to the object, add an empty material.
# This is necessary for the ASE format to be compatible with the UT2K4 importer.
@@ -146,14 +148,14 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[
options.transform @
matrix_world)
for vertex_index, vertex in enumerate(mesh_data.vertices):
for _, vertex in enumerate(mesh_data.vertices):
vertex = vertex_transform @ vertex.co
vertex = coordinate_system_transform @ vertex
geometry_object.vertices.append(vertex)
material_indices = []
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): # TODO: this needs to use the evaluated object, doesn't it?
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.name))
@@ -192,8 +194,17 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[
del face_material_indices
# TODO: There is an edge case here where if two different meshes have identical or nearly identical
# vertices and also matching smoothing groups, the engine's importer will incorrectly calculate the
# normal of any faces that have the shared vertices.
# The get around this, we could detect the overlapping vertices and display a warning, though checking
# for unique vertices can be quite expensive (use a KD-tree!)
# Another thing we can do is; when we find an overlapping vertex, we find out the range of smoothing
# groups that were used for that mesh. Then we can simply dodge the smoothing group by offseting the
# smoothing groups for the current mesh. This should work the majority of the time.
# Faces
for face_index, loop_triangle in enumerate(mesh_data.loop_triangles):
for _, 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:
@@ -210,7 +221,7 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[
if not geometry_object.is_collision:
# Normals
for face_index, loop_triangle in enumerate(mesh_data.loop_triangles):
for _, loop_triangle in enumerate(mesh_data.loop_triangles):
face_normal = ASEFaceNormal()
face_normal.normal = loop_triangle.normal
face_normal.vertex_normals = []
@@ -271,6 +282,16 @@ def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[
ase.geometry_objects.append(geometry_object)
# Apply the material mapping.
material_mapping_items: list[tuple[str, str]] = list(options.material_mapping.items())
material_mapping_keys = list(map(lambda x: x[0], material_mapping_items))
for i in range(len(ase.materials)):
try:
index = material_mapping_keys.index(ase.materials[i])
ase.materials[i] = material_mapping_items[index][1]
except ValueError:
pass
context.window_manager.progress_end()
if len(ase.geometry_objects) == 0:

View File

@@ -71,7 +71,7 @@ def dfs_objects_in_collection(collection: Collection) -> Iterable[Object]:
yield from _dfs_object_children(obj, collection)
def dfs_collection_objects(collection: Collection, visible_only: bool = False) -> Iterable[DfsObject]:
def dfs_collection_objects(collection: Collection) -> Iterable[DfsObject]:
'''
Depth-first search of objects in a collection, including recursing into instances.
@param collection: The collection to search in.
@@ -86,15 +86,16 @@ def _dfs_collection_objects_recursive(
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:

View File

@@ -2,69 +2,31 @@ from typing import Iterable, List, Set, Union, cast, Optional
import bpy
from bpy_extras.io_utils import ExportHelper
from bpy.props import StringProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty, BoolProperty, \
FloatProperty
from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler, Event, Context, SpaceProperties, \
Collection
from bpy.props import StringProperty, CollectionProperty, IntProperty, EnumProperty, BoolProperty
from bpy.types import Operator, Material, UIList, Object, FileHandler, Event, Context, SpaceProperties, \
Collection, Panel, Depsgraph
from mathutils import Matrix, Vector
from .builder import ASEBuildOptions, ASEBuildError, build_ase
from .writer import ASEWriter
from .properties import TransformMixin, TransformSourceMixin, MaterialModeMixin, ASE_PG_key_value, get_vertex_color_attributes_from_objects
class ASE_PG_material(PropertyGroup):
material: PointerProperty(type=Material)
class ASE_PG_string(PropertyGroup):
string: StringProperty()
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]:
def get_unique_materials(depsgraph: Depsgraph, mesh_objects: Iterable[Object]) -> List[Material]:
materials = []
for mesh_object in mesh_objects:
for i, material_slot in enumerate(mesh_object.material_slots):
eo = mesh_object.evaluated_get(depsgraph)
for i, material_slot in enumerate(eo.material_slots):
material = material_slot.material
if material is None:
raise RuntimeError(f'Material slots cannot be empty ({mesh_object.name}, material slot index {i})')
# if material is None:
# raise RuntimeError(f'Material slots cannot be empty ({mesh_object.name}, material slot index {i})')
if material not in materials:
materials.append(material)
return materials
def populate_material_list(mesh_objects: Iterable[Object], material_list):
materials = get_unique_materials(mesh_objects)
def populate_material_list(depsgraph: Depsgraph, mesh_objects: Iterable[Object], material_list):
materials = get_unique_materials(depsgraph, mesh_objects)
material_list.clear()
for index, material in enumerate(materials):
m = material_list.add()
@@ -95,10 +57,10 @@ def get_collection_export_operator_from_context(context: Context) -> Optional['A
return exporter.export_properties
class ASE_OT_material_order_add(Operator):
bl_idname = 'ase_export.material_order_add'
class ASE_OT_material_mapping_add(Operator):
bl_idname = 'ase_export.material_mapping_add'
bl_label = 'Add'
bl_description = 'Add a material to the list'
bl_description = 'Add a material mapping to the list'
def invoke(self, context: Context, event: Event) -> Union[Set[str], Set[int]]:
# TODO: get the region that this was invoked from and set the collection to the collection of the region.
@@ -112,23 +74,23 @@ class ASE_OT_material_order_add(Operator):
if operator is None:
return {'INVALID_CONTEXT'}
material_string = operator.material_order.add()
material_string.string = 'Material'
material_mapping = operator.material_mapping.add()
material_mapping.key = 'Material'
return {'FINISHED'}
class ASE_OT_material_order_remove(Operator):
bl_idname = 'ase_export.material_order_remove'
class ASE_OT_material_mapping_remove(Operator):
bl_idname = 'ase_export.material_mapping_remove'
bl_label = 'Remove'
bl_description = 'Remove the selected material from the list'
bl_description = 'Remove the selected material mapping from the list'
@classmethod
def poll(cls, context: Context):
operator = get_collection_export_operator_from_context(context)
if operator is None:
return False
return 0 <= operator.material_order_index < len(operator.material_order)
return 0 <= operator.material_mapping_index < len(operator.material_mapping)
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context)
@@ -136,22 +98,22 @@ class ASE_OT_material_order_remove(Operator):
if operator is None:
return {'INVALID_CONTEXT'}
operator.material_order.remove(operator.material_order_index)
operator.material_mapping.remove(operator.material_mapping_index)
return {'FINISHED'}
class ASE_OT_material_order_move_up(Operator):
bl_idname = 'ase_export.material_order_move_up'
class ASE_OT_material_mapping_move_up(Operator):
bl_idname = 'ase_export.material_mapping_move_up'
bl_label = 'Move Up'
bl_description = 'Move the selected material up one slot'
bl_description = 'Move the selected material mapping up one slot'
@classmethod
def poll(cls, context: Context):
operator = get_collection_export_operator_from_context(context)
if operator is None:
return False
return operator.material_order_index > 0
return operator.material_mapping_index > 0
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context)
@@ -159,23 +121,23 @@ class ASE_OT_material_order_move_up(Operator):
if operator is None:
return {'INVALID_CONTEXT'}
operator.material_order.move(operator.material_order_index, operator.material_order_index - 1)
operator.material_order_index -= 1
operator.material_mapping.move(operator.material_mapping_index, operator.material_mapping_index - 1)
operator.material_mapping_index -= 1
return {'FINISHED'}
class ASE_OT_material_order_move_down(Operator):
bl_idname = 'ase_export.material_order_move_down'
class ASE_OT_material_mapping_move_down(Operator):
bl_idname = 'ase_export.material_mapping_move_down'
bl_label = 'Move Down'
bl_description = 'Move the selected material down one slot'
bl_description = 'Move the selected material mapping down one slot'
@classmethod
def poll(cls, context: Context):
operator = get_collection_export_operator_from_context(context)
if operator is None:
return False
return operator.material_order_index < len(operator.material_order) - 1
return operator.material_mapping_index < len(operator.material_mapping) - 1
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context)
@@ -183,8 +145,8 @@ class ASE_OT_material_order_move_down(Operator):
if operator is None:
return {'INVALID_CONTEXT'}
operator.material_order.move(operator.material_order_index, operator.material_order_index + 1)
operator.material_order_index += 1
operator.material_mapping.move(operator.material_mapping_index, operator.material_mapping_index + 1)
operator.material_mapping_index += 1
return {'FINISHED'}
@@ -226,34 +188,31 @@ class ASE_OT_material_list_move_down(Operator):
class ASE_UL_materials(UIList):
bl_idname = 'ASE_UL_materials'
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row()
row.prop(item.material, 'name', text='', emboss=False, icon_value=layout.icon(item.material))
class ASE_UL_material_names(UIList):
bl_idname = 'ASE_UL_material_names'
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row()
material = bpy.data.materials.get(item.string, None)
row.prop(item, 'string', text='', emboss=False, icon_value=layout.icon(material) if material is not None else 0)
material = bpy.data.materials.get(item.key, None)
col= row.column()
col.enabled = False
col.prop(item, 'key', text='', emboss=False, icon_value=layout.icon(material) if material is not None else 0)
row.label(icon='RIGHTARROW', text='')
material = bpy.data.materials.get(item.value, None)
row.prop(item, 'value', text='', emboss=False, icon_value=layout.icon(material) if material is not None else 0)
object_eval_state_items = [
('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'),
('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)
class ASE_OT_material_names_populate(Operator):
bl_idname = 'ase_export.material_names_populate'
bl_label = 'Populate Material Names List'
bl_description = 'Populate the material names with the materials used by objects in the collection'
def execute(self, context):
collection = get_collection_from_context(context)
@@ -263,50 +222,27 @@ class ASE_OT_populate_material_order_list(Operator):
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))))
mesh_objects = list(map(lambda x: x.obj, filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection))))
# Exclude objects that are not visible.
materials = get_unique_materials(mesh_objects)
materials = get_unique_materials(context.evaluated_depsgraph_get(), mesh_objects)
operator.material_order.clear()
operator.material_mapping.clear()
for material in materials:
m = operator.material_order.add()
m.string = material.name
m = operator.material_mapping.add()
m.key = material.name
m.value = material.name
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')
object_eval_state_items = [
('EVALUATED', 'Evaluated', 'Use data from fully evaluated object'),
('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'),
]
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, TransformMixin, TransformSourceMixin):
bl_idname = 'io_scene_ase.ase_export'
bl_label = 'Export ASE'
bl_space_type = 'PROPERTIES'
@@ -314,17 +250,11 @@ 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',
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')
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):
@@ -398,7 +328,7 @@ class ASE_OT_export(Operator, ExportHelper):
pg = getattr(context.scene, 'ase_export')
try:
populate_material_list(mesh_objects, pg.material_list)
populate_material_list(context.evaluated_depsgraph_get(), mesh_objects, pg.material_list)
except RuntimeError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
@@ -420,10 +350,16 @@ class ASE_OT_export(Operator, ExportHelper):
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
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
match self.transform_source:
case 'SCENE':
transform_source = getattr(context.scene, 'ase_settings')
case 'OBJECT':
transform_source = self
options.scale = transform_source.scale
options.forward_axis = transform_source.forward_axis
options.up_axis = transform_source.up_axis
from .dfs import dfs_view_layer_objects
@@ -447,12 +383,13 @@ class ASE_OT_export(Operator, ExportHelper):
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)'),
('WORLD', 'World Space', 'Export the collection in world space'),
('INSTANCE', 'Instance Space', 'Export the collection in instance space'),
('OBJECT', 'Object Space', 'Export the collection in the active object\'s local space'),
]
class ASE_OT_export_collection(Operator, ExportHelper):
class ASE_OT_export_collection(Operator, ExportHelper, TransformSourceMixin, TransformMixin, MaterialModeMixin):
bl_idname = 'io_scene_ase.ase_export_collection'
bl_label = 'Export collection to ASE'
bl_space_type = 'PROPERTIES'
@@ -471,13 +408,9 @@ class ASE_OT_export_collection(Operator, ExportHelper):
)
collection: StringProperty()
material_order: CollectionProperty(name='Materials', type=ASE_PG_string)
material_order_index: IntProperty(name='Index', default=0)
material_mapping: CollectionProperty(name='Materials', type=ASE_PG_key_value)
material_mapping_index: IntProperty(name='Index', default=0)
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
@@ -485,22 +418,23 @@ class ASE_OT_export_collection(Operator, ExportHelper):
flow = layout.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'should_export_visible_only')
materials_header, materials_panel = layout.panel('Materials', default_closed=True)
materials_header.label(text='Materials')
if materials_panel:
row = materials_panel.row()
row.template_list('ASE_UL_material_names', '', self, 'material_order', self, 'material_order_index')
col = row.column(align=True)
col.operator(ASE_OT_material_order_add.bl_idname, icon='ADD', text='')
col.operator(ASE_OT_material_order_remove.bl_idname, icon='REMOVE', text='')
col.separator()
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.separator()
col.operator(ASE_OT_populate_material_order_list.bl_idname, icon='FILE_REFRESH', text='')
materials_panel.prop(self, 'material_mode', text='Material Mode')
if self.material_mode == 'MANUAL':
row = materials_panel.row()
row.template_list(ASE_UL_material_names.bl_idname, '', self, 'material_mapping', self, 'material_mapping_index')
col = row.column(align=True)
col.operator(ASE_OT_material_mapping_add.bl_idname, icon='ADD', text='')
col.operator(ASE_OT_material_mapping_remove.bl_idname, icon='REMOVE', text='')
col.separator()
col.operator(ASE_OT_material_mapping_move_up.bl_idname, icon='TRIA_UP', text='')
col.operator(ASE_OT_material_mapping_move_down.bl_idname, icon='TRIA_DOWN', text='')
col.separator()
col.operator(ASE_OT_material_names_populate.bl_idname, icon='FILE_REFRESH', text='')
transform_header, transform_panel = layout.panel('Transform', default_closed=True)
transform_header.label(text='Transform')
@@ -508,9 +442,21 @@ class ASE_OT_export_collection(Operator, ExportHelper):
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')
transform_panel.prop(self, 'transform_source')
flow = transform_panel.grid_flow()
match self.transform_source:
case 'SCENE':
transform_source = getattr(context.scene, 'ase_settings')
flow.enabled = False
case 'OBJECT':
transform_source = self
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(transform_source, 'scale')
flow.prop(transform_source, 'forward_axis')
flow.prop(transform_source, 'up_axis')
advanced_header, advanced_panel = layout.panel('Advanced', default_closed=True)
advanced_header.label(text='Advanced')
@@ -526,41 +472,55 @@ 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.transform_source:
case 'SCENE':
transform_source = getattr(context.scene, 'ase_settings')
case 'OBJECT':
transform_source = self
options.scale = transform_source.scale
options.forward_axis = transform_source.forward_axis
options.up_axis = transform_source.up_axis
match self.export_space:
case 'WORLD':
options.transform = Matrix.Identity(4)
case 'INSTANCE':
options.transform = Matrix.Translation(-Vector(collection.instance_offset))
case 'BONE':
options.transform = Matrix
from .dfs import dfs_collection_objects
dfs_objects = list(filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection, options.should_export_visible_only)))
dfs_objects = list(filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection)))
mesh_objects = [x.obj for x in dfs_objects]
# Get all the materials used by the objects in the collection.
options.materials = get_unique_materials(mesh_objects)
options.materials = get_unique_materials(context.evaluated_depsgraph_get(), mesh_objects)
# 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.
material_order = [x.string for x in self.material_order]
material_order_map = {x: i for i, x in enumerate(material_order)}
if self.material_mode == 'MANUAL':
# Build material mapping.
for material_mapping in self.material_mapping:
options.material_mapping[material_mapping.key] = material_mapping.value
# 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)
# 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.
material_names = [x.key for x in self.material_mapping]
material_names_map = {x: i for i, x in enumerate(material_names)}
ordered_materials.sort(key=lambda x: material_order_map.get(x.name, len(material_order)))
options.materials = ordered_materials + unordered_materials
# 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_names_map:
ordered_materials.append(material)
else:
unordered_materials.append(material)
ordered_materials.sort(key=lambda x: material_names_map.get(x.name, len(material_names)))
options.materials = ordered_materials + unordered_materials
try:
ase = build_ase(context, options, dfs_objects)
@@ -577,6 +537,35 @@ class ASE_OT_export_collection(Operator, ExportHelper):
return {'FINISHED'}
class ASE_PT_export_scene_settings(Panel):
bl_label = 'ASCII Scene Export'
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = 'scene'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context: Context):
return context.space_data.type == 'PROPERTIES' and hasattr(context.scene, 'ase_settings')
def draw(self, context: Context):
layout = self.layout
transform_source = getattr(context.scene, 'ase_settings')
transform_header, transform_panel = layout.panel('Transform', default_closed=True)
transform_header.label(text='Transform')
if transform_panel:
flow = transform_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(transform_source, 'scale')
flow.prop(transform_source, 'forward_axis')
flow.prop(transform_source, 'up_axis')
class ASE_FH_export(FileHandler):
bl_idname = 'ASE_FH_export'
bl_label = 'ASCII Scene Export'
@@ -584,21 +573,18 @@ class ASE_FH_export(FileHandler):
bl_file_extensions = '.ase'
classes = (
ASE_PG_material,
ASE_PG_string,
ASE_UL_materials,
ASE_UL_material_names,
ASE_PG_export,
ASE_OT_export,
ASE_OT_export_collection,
ASE_OT_material_list_move_down,
ASE_OT_material_list_move_up,
ASE_OT_material_order_add,
ASE_OT_material_order_remove,
ASE_OT_material_order_move_down,
ASE_OT_material_order_move_up,
ASE_OT_populate_material_order_list,
ASE_OT_material_mapping_add,
ASE_OT_material_mapping_remove,
ASE_OT_material_mapping_move_down,
ASE_OT_material_mapping_move_up,
ASE_OT_material_names_populate,
ASE_PT_export_scene_settings,
ASE_FH_export,
)

114
io_scene_ase/properties.py Normal file
View File

@@ -0,0 +1,114 @@
from typing import Iterable, Set
from bpy.types import PropertyGroup, Context, Material, Object
from bpy.props import CollectionProperty, IntProperty, BoolProperty, EnumProperty, FloatProperty, StringProperty, PointerProperty
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[-1] == self.up_axis[-1]:
self.up_axis = next((axis for axis in axis_identifiers if axis[-1] != self.forward_axis[-1]), 'Z')
def up_axis_update(self, _context: Context):
if self.up_axis[-1] == self.forward_axis[-1]:
self.forward_axis = next((axis for axis in axis_identifiers if axis[-1] != self.up_axis[-1]), 'X')
transform_source_items = (
('SCENE', 'Scene', ''),
('CUSTOM', 'Custom', ''),
)
class TransformSourceMixin:
transform_source: EnumProperty(name='Transform Source', items=transform_source_items, default='SCENE', description='The source of the transform to apply to the exported geometry')
class TransformMixin:
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)
material_mode_items = (
('AUTOMATIC', 'Automatic', ''),
('MANUAL', 'Manual', ''),
)
class MaterialModeMixin:
material_mode: EnumProperty(name='Material Mode', items=material_mode_items, default='AUTOMATIC', description='The material mode to use for the exported geometry')
class ASE_PG_material(PropertyGroup):
material: PointerProperty(type=Material)
class ASE_PG_key_value(PropertyGroup):
key: StringProperty()
value: StringProperty()
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))]
vertex_color_mode_items = (
('ACTIVE', 'Active', 'Use the active vertex color attribute'),
('EXPLICIT', 'Explicit', 'Use the vertex color attribute specified below'),
)
class ASE_PG_export(PropertyGroup, TransformSourceMixin, TransformMixin):
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=vertex_color_mode_items)
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')
class ASE_PG_scene_settings(PropertyGroup, TransformMixin):
pass
classes = (
ASE_PG_material,
ASE_PG_key_value,
ASE_PG_export,
ASE_PG_scene_settings,
)

View File

@@ -118,6 +118,12 @@ class ASEWriter(object):
submaterial_node.push_child('MATERIAL_NAME').push_datum(material)
diffuse_node = submaterial_node.push_child('MAP_DIFFUSE')
diffuse_node.push_child('MAP_NAME').push_datum('default')
# For inscrutible reasons, the UT2K4 ASE importer uses the BITMAP value
# when doing material lookups on import. It also has a hard-coded bit of logic to
# strip off the extension, so we must oblige and tack on the .bmp extension.
# In addition, it must have a leading backslash in order to find the beginning
# of the path.
diffuse_node.push_child('BITMAP').push_datum(f'\\{material}.bmp')
diffuse_node.push_child('UVW_U_OFFSET').push_datum(0.0)
diffuse_node.push_child('UVW_V_OFFSET').push_datum(0.0)
diffuse_node.push_child('UVW_U_TILING').push_datum(1.0)