25 Commits
2.1.1 ... 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
Colin Basnett
922b8eac24 Fix for collision manifold and convexity tests being run on non-collision meshes 2024-12-28 16:23:40 -08:00
Colin Basnett
24adafe039 Now applying the export space transform
* Now adding a dummy material when none is provided.
2024-12-28 15:06:52 -08:00
Colin Basnett
0c0c5fcf2d Updated the README 2024-12-22 18:49:38 -08:00
Colin Basnett
7644980a43 Fixed typo 2024-12-22 18:26:07 -08:00
Colin Basnett
3afb04c01b Fix for automation dependencies 2024-12-22 18:22:27 -08:00
Colin Basnett
34714099ce Fixed a bug where secondary UV layers would not be exported correctly if not all objects shared the same number of UV layers 2024-12-22 18:14:22 -08:00
Colin Basnett
324481d8d2 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.
2024-12-16 17:02:33 -08:00
Colin Basnett
0bf35f6157 Incremented version to 2.2.0 2024-12-09 00:05:59 -08:00
Colin Basnett
4b73bf4cd0 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)
2024-12-09 00:05:38 -08:00
Colin Basnett
d6c0186031 get_unique_materials now preserves the order that the materials were encountered 2024-09-29 12:01:57 -07:00
Colin Basnett
a2a4720cb4 Added "Export Space" for collection exporters
This allows the instance offset to be negated.
2024-09-20 16:13:50 -07:00
Colin Basnett
57a4cb5170 Fixed error that could occur when exporting a mesh with vertex colors but no UV layer 2024-09-20 16:12:49 -07:00
Colin Basnett
fa51768e65 ASE export success message now displays some stats about the ASE file (material count, face count etc.) 2024-09-20 11:42:47 -07:00
Colin Basnett
638042202b Added better error handling when material slots are empty 2024-09-20 11:37:38 -07:00
Colin Basnett
37c255f270 Incremented version to 2.1.3 2024-09-20 11:26:12 -07:00
Colin Basnett
3ad6a18f21 Added the ability to provide explicit ordering in collection exporter
Operators cannot reference ID datablocks for some reason still, but
I just made it so you can type in the name of the materials manually.
This allows stable and predictable material ordering in batch
operations.
2024-09-20 11:25:25 -07:00
Colin Basnett
d2230fb975 Minor formatting fix 2024-09-20 11:24:11 -07:00
Colin Basnett
2740568c17 Added better error handling when faces reference materials that do not exist 2024-09-20 11:23:18 -07:00
Colin Basnett
cc3a9c39fc Fixed vertex color lookup 2024-09-14 02:39:46 -07:00
10 changed files with 905 additions and 228 deletions

View File

@@ -30,7 +30,8 @@ jobs:
sudo apt-get install libxfixes3 -y sudo apt-get install libxfixes3 -y
sudo apt-get install libxi-dev -y sudo apt-get install libxi-dev -y
sudo apt-get install libxkbcommon-x11-0 -y sudo apt-get install libxkbcommon-x11-0 -y
sudo apt-get install libgl1-mesa-glx -y sudo apt-get install libgl1 -y
sudo apt-get install libglx-mesa0 -y
- name: Download & Extract Blender - name: Download & Extract Blender
run: | run: |
wget -q $BLENDER_URL wget -q $BLENDER_URL

View File

@@ -1,8 +1,18 @@
[![Build Extension](https://github.com/DarklightGames/io_scene_ase/actions/workflows/main.yml/badge.svg)](https://github.com/DarklightGames/io_scene_ase/actions/workflows/main.yml) [![Build Extension](https://github.com/DarklightGames/io_scene_ase/actions/workflows/main.yml/badge.svg)](https://github.com/DarklightGames/io_scene_ase/actions/workflows/main.yml)
This is a Blender addon allowing you to export static meshes to the now-defunct ASE (ASCII Scene Export) format still in use in legacy programs like Unreal Tournament 2004. This is a Blender addon allowing you to export static meshes to the now-defunct ASE (ASCII Scene Export) format used in legacy programs like Unreal Tournament 2004.
Check out [this video](https://www.youtube.com/watch?v=gpmBxCGHQjU) on how to install and use the addon. # Installation
Install the latest version from [Blender Extensions](https://extensions.blender.org/add-ons/io-scene-ase/).
Resources: Legacy versions are available on the [releases](https://github.com/DarklightGames/io_scene_ase/releases) page, but are not recommended for use.
# Features
* Selection and [collection exporters](https://docs.blender.org/manual/en/latest/scene_layout/collections/collections.html#exporters).
* 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.
# Resources:
* https://wiki.beyondunreal.com/Legacy:ASE_File_Format * https://wiki.beyondunreal.com/Legacy:ASE_File_Format

View File

@@ -3,16 +3,20 @@ if 'bpy' in locals():
if 'ase' in locals(): importlib.reload(ase) if 'ase' in locals(): importlib.reload(ase)
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 'properties' in locals(): importlib.reload(properties)
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
from . import ase from . import ase
from . import builder from . import builder
from . import writer from . import writer
from . import properties
from . import exporter from . import exporter
from . import dfs
classes = exporter.classes classes = properties.classes + exporter.classes
def menu_func_export(self, context): def menu_func_export(self, context):
@@ -23,7 +27,8 @@ def register():
for cls in classes: for cls in classes:
bpy.utils.register_class(cls) 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) bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
@@ -31,6 +36,7 @@ def register():
def unregister(): def unregister():
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
del bpy.types.Scene.ase_settings
del bpy.types.Scene.ase_export del bpy.types.Scene.ase_export
for cls in classes: for cls in classes:

View File

@@ -55,5 +55,5 @@ class ASEGeometryObject(object):
class ASE(object): class ASE(object):
def __init__(self): def __init__(self):
self.materials: List[Optional[Material]] = [] self.materials: List[str] = []
self.geometry_objects = [] self.geometry_objects = []

View File

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

View File

@@ -1,6 +1,8 @@
from typing import Iterable, Optional, List, Tuple 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 from .ase import ASE, ASEGeometryObject, ASEFace, ASEFaceNormal, ASEVertexNormal, ASEUVLayer, is_collision_name
import bpy import bpy
@@ -8,6 +10,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):
@@ -18,45 +22,108 @@ 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.material_mapping: Dict[str, str] = OrderedDict()
self.transform = Matrix.Identity(4) self.transform = Matrix.Identity(4)
self.should_export_vertex_colors = True self.should_export_vertex_colors = True
self.vertex_color_mode = 'ACTIVE' self.vertex_color_mode = 'ACTIVE'
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.scale = 1.0
self.forward_axis = 'X'
self.up_axis = 'Z'
def get_object_matrix(obj: Object, asset_instance: Optional[Object] = None) -> Matrix: def get_vector_from_axis_identifier(axis_identifier: str) -> Vector:
if asset_instance is not None: match axis_identifier:
return asset_instance.matrix_world @ Matrix().Translation(asset_instance.instance_collection.instance_offset) @ obj.matrix_local case 'X':
return obj.matrix_world 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_mesh_objects(objects: Iterable[Object]) -> List[Tuple[Object, Optional[Object]]]: def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z') -> Matrix:
mesh_objects = [] forward = get_vector_from_axis_identifier(forward_axis)
for obj in objects: up = get_vector_from_axis_identifier(up_axis)
if obj.type == 'MESH': left = up.cross(forward)
mesh_objects.append((obj, None)) return Matrix((
elif obj.instance_collection: (forward.x, forward.y, forward.z, 0.0),
for instance_object in obj.instance_collection.all_objects: (left.x, left.y, left.z, 0.0),
if instance_object.type == 'MESH': (up.x, up.y, up.z, 0.0),
mesh_objects.append((instance_object, obj)) (0.0, 0.0, 0.0, 1.0)
return mesh_objects ))
def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Object]) -> ASE: def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[DfsObject]) -> ASE:
ase = ASE() ase = ASE()
ase.materials = [x.name if x is not None else 'None' for x in options.materials]
main_geometry_object = None # If no materials are assigned to the object, add an empty material.
mesh_objects = get_mesh_objects(objects) # This is necessary for the ASE format to be compatible with the UT2K4 importer.
if len(ase.materials) == 0:
ase.materials.append('')
context.window_manager.progress_begin(0, len(mesh_objects)) dfs_objects = list(dfs_objects)
dfs_objects_processed = 0
ase.materials = options.materials context.window_manager.progress_begin(0, len(dfs_objects))
for object_index, (obj, asset_instance) in enumerate(mesh_objects): class GeometryObjectInfo:
def __init__(self, name: str):
self.name = name
self.dfs_objects = []
matrix_world = get_object_matrix(obj, asset_instance) main_geometry_object_info = GeometryObjectInfo('io_scene_ase')
geometry_object_infos: List[GeometryObjectInfo] = [
main_geometry_object_info,
]
for object_index, dfs_object in enumerate(dfs_objects):
if is_collision_name(dfs_object.obj.name):
geometry_object_info = GeometryObjectInfo(dfs_object.obj.name)
geometry_object_info.dfs_objects.append(dfs_object)
geometry_object_infos.append(geometry_object_info)
else:
main_geometry_object_info.dfs_objects.append(dfs_object)
# Sort the DFS objects into collision and non-collision objects.
coordinate_system_transform = get_coordinate_system_transform(options.forward_axis, options.up_axis)
for geometry_object_info in geometry_object_infos:
geometry_object = ASEGeometryObject()
geometry_object.name = geometry_object_info.name
max_uv_layers = 0
for dfs_object in geometry_object_info.dfs_objects:
mesh_data = cast(Mesh, dfs_object.obj.data)
max_uv_layers = max(max_uv_layers, len(mesh_data.uv_layers))
geometry_object.uv_layers = [ASEUVLayer() for _ in range(max_uv_layers)]
for dfs_object in geometry_object_info.dfs_objects:
obj = dfs_object.obj
if geometry_object.is_collision:
# Test that collision meshes are manifold and convex.
bm = bmesh.new()
bm.from_mesh(obj.data)
for edge in bm.edges:
if not edge.is_manifold:
del bm
raise ASEBuildError(f'Collision mesh \'{obj.name}\' is not manifold')
if not edge.is_convex:
del bm
raise ASEBuildError(f'Collision mesh \'{obj.name}\' is not convex')
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
@@ -76,38 +143,22 @@ def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Obje
mesh_object = bpy.data.objects.new('', mesh_data) mesh_object = bpy.data.objects.new('', mesh_data)
mesh_object.matrix_world = matrix_world mesh_object.matrix_world = matrix_world
if not is_collision_name(obj.name) and main_geometry_object is not None: vertex_transform = (Matrix.Rotation(math.pi, 4, 'Z') @
geometry_object = main_geometry_object Matrix.Scale(options.scale, 4) @
else: options.transform @
geometry_object = ASEGeometryObject() matrix_world)
geometry_object.name = obj.name
if not geometry_object.is_collision:
main_geometry_object = geometry_object
ase.geometry_objects.append(geometry_object)
if geometry_object.is_collision: for _, vertex in enumerate(mesh_data.vertices):
# Test that collision meshes are manifold and convex. vertex = vertex_transform @ vertex.co
bm = bmesh.new() vertex = coordinate_system_transform @ vertex
bm.from_mesh(mesh_object.data) geometry_object.vertices.append(vertex)
for edge in bm.edges:
if not edge.is_manifold:
del bm
raise ASEBuildError(f'Collision mesh \'{obj.name}\' is not manifold')
if not edge.is_convex:
del bm
raise ASEBuildError(f'Collision mesh \'{obj.name}\' is not convex')
vertex_transform = Matrix.Rotation(math.pi, 4, 'Z') @ matrix_world
for vertex_index, vertex in enumerate(mesh_data.vertices):
geometry_object.vertices.append(vertex_transform @ vertex.co)
material_indices = [] material_indices = []
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): # TODO: this needs to use the evaluated object, doesn't it?
if material is None: if material is None:
raise ASEBuildError(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.name))
if len(material_indices) == 0: if len(material_indices) == 0:
# If no materials are assigned to the mesh, just have a single empty material. # If no materials are assigned to the mesh, just have a single empty material.
@@ -128,8 +179,32 @@ def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Obje
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)
# Gather the list of unique material indices in the loop triangles.
face_material_indices = {loop_triangle.material_index for loop_triangle in mesh_data.loop_triangles}
# Make sure that each material index is within the bounds of the material indices list.
for material_index in face_material_indices:
if material_index >= len(material_indices):
raise ASEBuildError(f'Material index {material_index} for mesh \'{obj.name}\' is out of bounds.\n'
f'This means that one or more faces are assigned to a material slot that does '
f'not exist.\n'
f'The referenced material indices in the faces are: {sorted(list(face_material_indices))}.\n'
f'Either add enough materials to the object or assign faces to existing material slots.'
)
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 # Faces
for face_index, loop_triangle in enumerate(mesh_data.loop_triangles): for _, loop_triangle in enumerate(mesh_data.loop_triangles):
face = ASEFace() 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) 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: if not geometry_object.is_collision:
@@ -146,7 +221,7 @@ def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Obje
if not geometry_object.is_collision: if not geometry_object.is_collision:
# Normals # 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 = ASEFaceNormal()
face_normal.normal = loop_triangle.normal face_normal.normal = loop_triangle.normal
face_normal.vertex_normals = [] face_normal.vertex_normals = []
@@ -161,13 +236,17 @@ def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Obje
# Texture Coordinates # Texture Coordinates
for i, uv_layer_data in enumerate([x.data for x in mesh_data.uv_layers]): for i, uv_layer_data in enumerate([x.data for x in mesh_data.uv_layers]):
if i >= len(geometry_object.uv_layers):
geometry_object.uv_layers.append(ASEUVLayer())
uv_layer = geometry_object.uv_layers[i] uv_layer = geometry_object.uv_layers[i]
for loop_index, loop in enumerate(mesh_data.loops): for loop_index, loop in enumerate(mesh_data.loops):
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(mesh_data.uv_layers), max_uv_layers):
uv_layer = geometry_object.uv_layers[i]
for _ in mesh_data.loops:
uv_layer.texture_vertices.append((0.0, 0.0, 0.0))
# 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(
@@ -176,12 +255,15 @@ def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Obje
# Vertex Colors # Vertex Colors
if options.should_export_vertex_colors and options.has_vertex_colors: if options.should_export_vertex_colors and options.has_vertex_colors:
color_attribute = None
match options.vertex_color_mode: match options.vertex_color_mode:
case 'ACTIVE': case 'ACTIVE':
color_attribute = mesh_data.color_attributes[active_color_name] color_attribute_name = active_color_name
case 'EXPLICIT': case 'EXPLICIT':
color_attribute = mesh_data.color_attributes.get(options.vertex_color_attribute, None) color_attribute_name = options.vertex_color_attribute
case _:
raise ASEBuildError('Invalid vertex color mode')
color_attribute = mesh_data.color_attributes.get(color_attribute_name, None)
if color_attribute is not None: if color_attribute is not None:
# Make sure that the selected color attribute is on the CORNER domain. # Make sure that the selected color attribute is on the CORNER domain.
@@ -195,14 +277,24 @@ def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Obje
geometry_object.texture_vertex_offset += len(mesh_data.loops) geometry_object.texture_vertex_offset += len(mesh_data.loops)
geometry_object.vertex_offset = len(geometry_object.vertices) geometry_object.vertex_offset = len(geometry_object.vertices)
context.window_manager.progress_update(object_index) dfs_objects_processed += 1
context.window_manager.progress_update(dfs_objects_processed)
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() context.window_manager.progress_end()
if len(ase.geometry_objects) == 0: if len(ase.geometry_objects) == 0:
raise ASEBuildError('At least one mesh object must be selected') raise ASEBuildError('At least one mesh object must be selected')
if main_geometry_object is None:
raise ASEBuildError('At least one non-collision mesh must be exported')
return ase return ase

145
io_scene_ase/dfs.py Normal file
View File

@@ -0,0 +1,145 @@
'''
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, Context
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) -> 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, visited: Set[Object]=None):
if visited is None:
visited = set()
for child in layer_collection.children:
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, visited=visited)
yield from layer_collection_objects_recursive(view_layer.layer_collection)

View File

@@ -1,64 +1,32 @@
import os.path from typing import Iterable, List, Set, Union, cast, Optional
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, CollectionProperty, PointerProperty, IntProperty, EnumProperty, BoolProperty from bpy.props import StringProperty, CollectionProperty, IntProperty, EnumProperty, BoolProperty
from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler from bpy.types import Operator, Material, UIList, Object, FileHandler, Event, Context, SpaceProperties, \
Collection, Panel, Depsgraph
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
from .properties import TransformMixin, TransformSourceMixin, MaterialModeMixin, ASE_PG_key_value, get_vertex_color_attributes_from_objects
class ASE_PG_material(PropertyGroup): def get_unique_materials(depsgraph: Depsgraph, mesh_objects: Iterable[Object]) -> List[Material]:
material: PointerProperty(type=Material) materials = []
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]:
materials = set()
for mesh_object in mesh_objects: 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 material = material_slot.material
if material is None: # if material is None:
raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')') # raise RuntimeError(f'Material slots cannot be empty ({mesh_object.name}, material slot index {i})')
materials.add(material) if material not in materials:
return list(materials) materials.append(material)
return materials
def populate_material_list(mesh_objects: Iterable[Object], material_list): def populate_material_list(depsgraph: Depsgraph, mesh_objects: Iterable[Object], material_list):
materials = get_unique_materials(mesh_objects) materials = get_unique_materials(depsgraph, mesh_objects)
material_list.clear() material_list.clear()
for index, material in enumerate(materials): for index, material in enumerate(materials):
m = material_list.add() m = material_list.add()
@@ -66,6 +34,123 @@ def populate_material_list(mesh_objects: Iterable[Object], material_list):
m.index = index m.index = index
def get_collection_from_context(context: Context) -> Optional[Collection]:
if context.space_data.type != 'PROPERTIES':
return None
space_data = cast(SpaceProperties, context.space_data)
if space_data.use_pin_id:
return cast(Collection, space_data.pin_id)
else:
return context.collection
def get_collection_export_operator_from_context(context: Context) -> Optional['ASE_OT_export_collection']:
collection = get_collection_from_context(context)
if collection is None:
return None
if 0 > collection.active_exporter_index >= len(collection.exporters):
return None
exporter = collection.exporters[collection.active_exporter_index]
# TODO: make sure this is actually an ASE exporter.
return exporter.export_properties
class ASE_OT_material_mapping_add(Operator):
bl_idname = 'ase_export.material_mapping_add'
bl_label = 'Add'
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.
print(event)
return self.execute(context)
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
# Make sure this is being invoked from the properties region.
operator = get_collection_export_operator_from_context(context)
if operator is None:
return {'INVALID_CONTEXT'}
material_mapping = operator.material_mapping.add()
material_mapping.key = 'Material'
return {'FINISHED'}
class ASE_OT_material_mapping_remove(Operator):
bl_idname = 'ase_export.material_mapping_remove'
bl_label = 'Remove'
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_mapping_index < len(operator.material_mapping)
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context)
if operator is None:
return {'INVALID_CONTEXT'}
operator.material_mapping.remove(operator.material_mapping_index)
return {'FINISHED'}
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 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_mapping_index > 0
def execute(self, context: 'Context') -> Union[Set[str], Set[int]]:
operator = get_collection_export_operator_from_context(context)
if operator is None:
return {'INVALID_CONTEXT'}
operator.material_mapping.move(operator.material_mapping_index, operator.material_mapping_index - 1)
operator.material_mapping_index -= 1
return {'FINISHED'}
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 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_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)
if operator is None:
return {'INVALID_CONTEXT'}
operator.material_mapping.move(operator.material_mapping_index, operator.material_mapping_index + 1)
operator.material_mapping_index += 1
return {'FINISHED'}
class ASE_OT_material_list_move_up(Operator): class ASE_OT_material_list_move_up(Operator):
bl_idname = 'ase_export.material_list_item_move_up' bl_idname = 'ase_export.material_list_item_move_up'
bl_label = 'Move Up' bl_label = 'Move Up'
@@ -103,18 +188,61 @@ class ASE_OT_material_list_move_down(Operator):
class ASE_UL_materials(UIList): class ASE_UL_materials(UIList):
bl_idname = 'ASE_UL_materials'
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row() row = layout.row()
row.prop(item.material, 'name', text='', emboss=False, icon_value=layout.icon(item.material)) row.prop(item.material, 'name', text='', emboss=False, icon_value=layout.icon(item.material))
object_eval_state_items = ( 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.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)
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)
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))))
# Exclude objects that are not visible.
materials = get_unique_materials(context.evaluated_depsgraph_get(), mesh_objects)
operator.material_mapping.clear()
for material in materials:
m = operator.material_mapping.add()
m.key = material.name
m.value = material.name
return {'FINISHED'}
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_export(Operator, ExportHelper): class ASE_OT_export(Operator, ExportHelper, TransformMixin, TransformSourceMixin):
bl_idname = 'io_scene_ase.ase_export' bl_idname = 'io_scene_ase.ase_export'
bl_label = 'Export ASE' bl_label = 'Export ASE'
bl_space_type = 'PROPERTIES' bl_space_type = 'PROPERTIES'
@@ -130,8 +258,8 @@ class ASE_OT_export(Operator, ExportHelper):
@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
@@ -139,6 +267,10 @@ 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
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')
@@ -149,7 +281,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()
@@ -166,6 +297,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')
@@ -176,10 +317,21 @@ 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')
populate_material_list(mesh_objects, pg.material_list)
try:
populate_material_list(context.evaluated_depsgraph_get(), mesh_objects, pg.material_list)
except RuntimeError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
self.filepath = f'{context.active_object.name}.ase' self.filepath = f'{context.active_object.name}.ase'
@@ -198,17 +350,46 @@ 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
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
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, context.selected_objects) ase = build_ase(context, options, dfs_objects)
# Calculate some statistics about the ASE file to display in the console.
object_count = len(ase.geometry_objects)
material_count = len(ase.materials)
face_count = sum(len(x.faces) for x in ase.geometry_objects)
vertex_count = sum(len(x.vertices) for x in ase.geometry_objects)
ASEWriter().write(self.filepath, ase) ASEWriter().write(self.filepath, ase)
self.report({'INFO'}, 'ASE exported successfully') self.report({'INFO'}, f'ASE exported successfully ({object_count} objects, {material_count} materials, {face_count} faces, {vertex_count} vertices)')
return {'FINISHED'} return {'FINISHED'}
except ASEBuildError as e: except ASEBuildError as e:
self.report({'ERROR'}, str(e)) self.report({'ERROR'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
class ASE_OT_export_collection(Operator, ExportHelper): export_space_items = [
('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, TransformSourceMixin, TransformMixin, MaterialModeMixin):
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'
bl_space_type = 'PROPERTIES' bl_space_type = 'PROPERTIES'
@@ -227,11 +408,56 @@ class ASE_OT_export_collection(Operator, ExportHelper):
) )
collection: StringProperty() collection: StringProperty()
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')
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
materials_header, materials_panel = layout.panel('Materials', default_closed=True)
materials_header.label(text='Materials')
if materials_panel:
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')
if transform_panel:
transform_panel.use_property_split = True
transform_panel.use_property_decorate = False
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, advanced_panel = layout.panel('Advanced', default_closed=True)
advanced_header.label(text='Advanced') advanced_header.label(text='Advanced')
@@ -239,21 +465,65 @@ class ASE_OT_export_collection(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(self, 'export_space')
def execute(self, context): def execute(self, context):
collection = bpy.data.collections.get(self.collection) collection = bpy.data.collections.get(self.collection)
options = ASEBuildOptions() 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. match self.transform_source:
mesh_objects = get_mesh_objects(collection.all_objects) 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)))
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(context.evaluated_depsgraph_get(), mesh_objects)
if self.material_mode == 'MANUAL':
# Build material mapping.
for material_mapping in self.material_mapping:
options.material_mapping[material_mapping.key] = material_mapping.value
# 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)}
# 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: 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'}
@@ -267,6 +537,35 @@ class ASE_OT_export_collection(Operator, ExportHelper):
return {'FINISHED'} 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): class ASE_FH_export(FileHandler):
bl_idname = 'ASE_FH_export' bl_idname = 'ASE_FH_export'
bl_label = 'ASCII Scene Export' bl_label = 'ASCII Scene Export'
@@ -274,14 +573,18 @@ class ASE_FH_export(FileHandler):
bl_file_extensions = '.ase' bl_file_extensions = '.ase'
classes = ( classes = (
ASE_PG_material,
ASE_UL_materials, ASE_UL_materials,
ASE_PG_export, ASE_UL_material_names,
ASE_OT_export, ASE_OT_export,
ASE_OT_export_collection, ASE_OT_export_collection,
ASE_OT_material_list_move_down, ASE_OT_material_list_move_down,
ASE_OT_material_list_move_up, ASE_OT_material_list_move_up,
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, 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) submaterial_node.push_child('MATERIAL_NAME').push_datum(material)
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')
# 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_U_OFFSET').push_datum(0.0)
diffuse_node.push_child('UVW_V_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) diffuse_node.push_child('UVW_U_TILING').push_datum(1.0)
@@ -182,8 +188,8 @@ class ASEWriter(object):
cvert_list = mesh_node.push_child('MESH_CVERTLIST') cvert_list = mesh_node.push_child('MESH_CVERTLIST')
for i, vertex_color in enumerate(geometry_object.vertex_colors): for i, vertex_color in enumerate(geometry_object.vertex_colors):
cvert_list.push_child('MESH_VERTCOL').push_datum(i).push_data(vertex_color) cvert_list.push_child('MESH_VERTCOL').push_datum(i).push_data(vertex_color)
parent_node.push_child('MESH_NUMCVFACES').push_datum(len(geometry_object.texture_vertex_faces)) mesh_node.push_child('MESH_NUMCVFACES').push_datum(len(geometry_object.texture_vertex_faces))
texture_faces_node = parent_node.push_child('MESH_CFACELIST') texture_faces_node = mesh_node.push_child('MESH_CFACELIST')
for texture_face_index, texture_face in enumerate(geometry_object.texture_vertex_faces): for texture_face_index, texture_face in enumerate(geometry_object.texture_vertex_faces):
texture_face_node = texture_faces_node.push_child('MESH_CFACE') texture_face_node = texture_faces_node.push_child('MESH_CFACE')
texture_face_node.push_data([texture_face_index] + list(texture_face)) texture_face_node.push_data([texture_face_index] + list(texture_face))