18 Commits
2.1.2 ... 2.2.0

Author SHA1 Message Date
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
9 changed files with 723 additions and 178 deletions

View File

@@ -30,7 +30,8 @@ jobs:
sudo apt-get install libxfixes3 -y
sudo apt-get install libxi-dev -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
run: |
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)
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).
* 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.
* 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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
from typing import Iterable, Optional, List, Tuple
from typing import Iterable, Optional, List, Tuple, cast
from bpy.types import Object, Context, Material, Mesh
@@ -8,6 +8,8 @@ import bmesh
import math
from mathutils import Matrix, Vector
from .dfs import DfsObject
SMOOTHING_GROUP_MAX = 32
class ASEBuildError(Exception):
@@ -24,185 +26,254 @@ class ASEBuildOptions(object):
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'
def get_object_matrix(obj: Object, asset_instance: Optional[Object] = None) -> Matrix:
if asset_instance is not None:
return asset_instance.matrix_world @ Matrix().Translation(asset_instance.instance_collection.instance_offset) @ obj.matrix_local
return obj.matrix_world
def get_vector_from_axis_identifier(axis_identifier: str) -> Vector:
match axis_identifier:
case 'X':
return Vector((1.0, 0.0, 0.0))
case 'Y':
return Vector((0.0, 1.0, 0.0))
case 'Z':
return Vector((0.0, 0.0, 1.0))
case '-X':
return Vector((-1.0, 0.0, 0.0))
case '-Y':
return Vector((0.0, -1.0, 0.0))
case '-Z':
return Vector((0.0, 0.0, -1.0))
def get_mesh_objects(objects: Iterable[Object]) -> List[Tuple[Object, Optional[Object]]]:
mesh_objects = []
for obj in objects:
if obj.type == 'MESH':
mesh_objects.append((obj, None))
elif obj.instance_collection:
for instance_object in obj.instance_collection.all_objects:
if instance_object.type == 'MESH':
mesh_objects.append((instance_object, obj))
return mesh_objects
def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z') -> Matrix:
forward = get_vector_from_axis_identifier(forward_axis)
up = get_vector_from_axis_identifier(up_axis)
left = up.cross(forward)
return Matrix((
(forward.x, forward.y, forward.z, 0.0),
(left.x, left.y, left.z, 0.0),
(up.x, up.y, up.z, 0.0),
(0.0, 0.0, 0.0, 1.0)
))
def build_ase(context: Context, options: ASEBuildOptions, objects: Iterable[Object]) -> ASE:
def build_ase(context: Context, options: ASEBuildOptions, dfs_objects: Iterable[DfsObject]) -> ASE:
ase = ASE()
ase.materials = [x.name for x in options.materials]
main_geometry_object = None
mesh_objects = get_mesh_objects(objects)
# 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.
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,
]
# Save the active color name for vertex color export.
active_color_name = obj.data.color_attributes.active_color_name
match options.object_eval_state:
case 'ORIGINAL':
mesh_object = obj
mesh_data = mesh_object.data
case 'EVALUATED':
# Evaluate the mesh after modifiers are applied
depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new()
bm.from_object(obj, depsgraph)
mesh_data = bpy.data.meshes.new('')
bm.to_mesh(mesh_data)
del bm
mesh_object = bpy.data.objects.new('', mesh_data)
mesh_object.matrix_world = matrix_world
if not is_collision_name(obj.name) and main_geometry_object is not None:
geometry_object = main_geometry_object
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:
geometry_object = ASEGeometryObject()
geometry_object.name = obj.name
if not geometry_object.is_collision:
main_geometry_object = geometry_object
ase.geometry_objects.append(geometry_object)
main_geometry_object_info.dfs_objects.append(dfs_object)
if geometry_object.is_collision:
# Test that collision meshes are manifold and convex.
bm = bmesh.new()
bm.from_mesh(mesh_object.data)
for edge in bm.edges:
if not edge.is_manifold:
# 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.
active_color_name = obj.data.color_attributes.active_color_name
match options.object_eval_state:
case 'ORIGINAL':
mesh_object = obj
mesh_data = mesh_object.data
case 'EVALUATED':
# Evaluate the mesh after modifiers are applied
depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new()
bm.from_object(obj, depsgraph)
mesh_data = bpy.data.meshes.new('')
bm.to_mesh(mesh_data)
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')
mesh_object = bpy.data.objects.new('', mesh_data)
mesh_object.matrix_world = matrix_world
vertex_transform = Matrix.Rotation(math.pi, 4, 'Z') @ matrix_world
vertex_transform = (Matrix.Rotation(math.pi, 4, 'Z') @
Matrix.Scale(options.scale, 4) @
options.transform @
matrix_world)
for vertex_index, vertex in enumerate(mesh_data.vertices):
geometry_object.vertices.append(vertex_transform @ vertex.co)
for vertex_index, 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):
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))
if len(material_indices) == 0:
# If no materials are assigned to the mesh, just have a single empty material.
material_indices.append(0)
mesh_data.calc_loop_triangles()
# Calculate smoothing groups.
poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=False)
# Figure out how many scaling axes are negative.
# This is important for calculating the normals of the mesh.
_, _, scale = vertex_transform.decompose()
negative_scaling_axes = sum([1 for x in scale if x < 0])
should_invert_normals = negative_scaling_axes % 2 == 1
if options.should_invert_normals:
should_invert_normals = not should_invert_normals
loop_triangle_index_order = (2, 1, 0) if should_invert_normals else (0, 1, 2)
# Faces
for face_index, 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)
material_indices = []
if not geometry_object.is_collision:
face.material_index = material_indices[loop_triangle.material_index]
# The UT2K4 importer only accepts 32 smoothing groups. Anything past this completely mangles the
# smoothing groups and effectively makes the whole model use sharp-edge rendering.
# The fix is to constrain the smoothing group between 0 and 31 by applying a modulo of 32 to the actual
# smoothing group index.
# This may result in bad calculated normals on export in rare cases. For example, if a face with a
# smoothing group of 3 is adjacent to a face with a smoothing group of 35 (35 % 32 == 3), those faces
# will be treated as part of the same smoothing group.
face.smoothing = (poly_groups[loop_triangle.polygon_index] - 1) % SMOOTHING_GROUP_MAX
geometry_object.faces.append(face)
for mesh_material_index, material in enumerate(obj.data.materials):
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))
if not geometry_object.is_collision:
# Normals
if len(material_indices) == 0:
# If no materials are assigned to the mesh, just have a single empty material.
material_indices.append(0)
mesh_data.calc_loop_triangles()
# Calculate smoothing groups.
poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=False)
# Figure out how many scaling axes are negative.
# This is important for calculating the normals of the mesh.
_, _, scale = vertex_transform.decompose()
negative_scaling_axes = sum([1 for x in scale if x < 0])
should_invert_normals = negative_scaling_axes % 2 == 1
if options.should_invert_normals:
should_invert_normals = not should_invert_normals
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
# Faces
for face_index, loop_triangle in enumerate(mesh_data.loop_triangles):
face_normal = ASEFaceNormal()
face_normal.normal = loop_triangle.normal
face_normal.vertex_normals = []
for i in loop_triangle_index_order:
vertex_normal = ASEVertexNormal()
vertex_normal.vertex_index = geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[i]].vertex_index
vertex_normal.normal = loop_triangle.split_normals[i]
if should_invert_normals:
vertex_normal.normal = (-Vector(vertex_normal.normal)).to_tuple()
face_normal.vertex_normals.append(vertex_normal)
geometry_object.face_normals.append(face_normal)
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:
face.material_index = material_indices[loop_triangle.material_index]
# The UT2K4 importer only accepts 32 smoothing groups. Anything past this completely mangles the
# smoothing groups and effectively makes the whole model use sharp-edge rendering.
# The fix is to constrain the smoothing group between 0 and 31 by applying a modulo of 32 to the actual
# smoothing group index.
# This may result in bad calculated normals on export in rare cases. For example, if a face with a
# smoothing group of 3 is adjacent to a face with a smoothing group of 35 (35 % 32 == 3), those faces
# will be treated as part of the same smoothing group.
face.smoothing = (poly_groups[loop_triangle.polygon_index] - 1) % SMOOTHING_GROUP_MAX
geometry_object.faces.append(face)
# Texture Coordinates
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]
for loop_index, loop in enumerate(mesh_data.loops):
u, v = uv_layer_data[loop_index].uv
uv_layer.texture_vertices.append((u, v, 0.0))
if not geometry_object.is_collision:
# Normals
for face_index, loop_triangle in enumerate(mesh_data.loop_triangles):
face_normal = ASEFaceNormal()
face_normal.normal = loop_triangle.normal
face_normal.vertex_normals = []
for i in loop_triangle_index_order:
vertex_normal = ASEVertexNormal()
vertex_normal.vertex_index = geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[i]].vertex_index
vertex_normal.normal = loop_triangle.split_normals[i]
if should_invert_normals:
vertex_normal.normal = (-Vector(vertex_normal.normal)).to_tuple()
face_normal.vertex_normals.append(vertex_normal)
geometry_object.face_normals.append(face_normal)
# Texture Faces
for loop_triangle in mesh_data.loop_triangles:
geometry_object.texture_vertex_faces.append(
tuple(map(lambda l: geometry_object.texture_vertex_offset + loop_triangle.loops[l], loop_triangle_index_order))
)
# Texture Coordinates
for i, uv_layer_data in enumerate([x.data for x in mesh_data.uv_layers]):
uv_layer = geometry_object.uv_layers[i]
for loop_index, loop in enumerate(mesh_data.loops):
u, v = uv_layer_data[loop_index].uv
uv_layer.texture_vertices.append((u, v, 0.0))
# Vertex Colors
if options.should_export_vertex_colors and options.has_vertex_colors:
color_attribute = None
match options.vertex_color_mode:
case 'ACTIVE':
color_attribute = mesh_data.color_attributes.get(active_color_name, None)
case 'EXPLICIT':
color_attribute = mesh_data.color_attributes.get(options.vertex_color_attribute, None)
# 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))
if color_attribute is not None:
# Make sure that the selected color attribute is on the CORNER domain.
if color_attribute.domain != 'CORNER':
raise ASEBuildError(f'Color attribute \'{color_attribute.name}\' for object \'{obj.name}\' must have domain of \'CORNER\' (found \'{color_attribute.domain}\')')
# Texture Faces
for loop_triangle in mesh_data.loop_triangles:
geometry_object.texture_vertex_faces.append(
tuple(map(lambda l: geometry_object.texture_vertex_offset + loop_triangle.loops[l], loop_triangle_index_order))
)
for color in map(lambda x: x.color, color_attribute.data):
geometry_object.vertex_colors.append(tuple(color[0:3]))
# Vertex Colors
if options.should_export_vertex_colors and options.has_vertex_colors:
match options.vertex_color_mode:
case 'ACTIVE':
color_attribute_name = active_color_name
case 'EXPLICIT':
color_attribute_name = options.vertex_color_attribute
case _:
raise ASEBuildError('Invalid vertex color mode')
# Update data offsets for next iteration
geometry_object.texture_vertex_offset += len(mesh_data.loops)
geometry_object.vertex_offset = len(geometry_object.vertices)
color_attribute = mesh_data.color_attributes.get(color_attribute_name, None)
context.window_manager.progress_update(object_index)
if color_attribute is not None:
# Make sure that the selected color attribute is on the CORNER domain.
if color_attribute.domain != 'CORNER':
raise ASEBuildError(f'Color attribute \'{color_attribute.name}\' for object \'{obj.name}\' must have domain of \'CORNER\' (found \'{color_attribute.domain}\')')
for color in map(lambda x: x.color, color_attribute.data):
geometry_object.vertex_colors.append(tuple(color[0:3]))
# Update data offsets for next iteration
geometry_object.texture_vertex_offset += len(mesh_data.loops)
geometry_object.vertex_offset = len(geometry_object.vertices)
dfs_objects_processed += 1
context.window_manager.progress_update(dfs_objects_processed)
ase.geometry_objects.append(geometry_object)
context.window_manager.progress_end()
if len(ase.geometry_objects) == 0:
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

144
io_scene_ase/dfs.py Normal file
View File

@@ -0,0 +1,144 @@
'''
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, visible_only: bool = False) -> Iterable[DfsObject]:
'''
Depth-first search of objects in a collection, including recursing into instances.
@param collection: The collection to search in.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix.
'''
yield from _dfs_collection_objects_recursive(collection)
def _dfs_collection_objects_recursive(
collection: Collection,
instance_objects: Optional[List[Object]] = None,
matrix_world: Matrix = Matrix.Identity(4),
visited: Optional[Set[Object]]=None
) -> Iterable[DfsObject]:
'''
Depth-first search of objects in a collection, including recursing into instances.
This is a recursive function.
@param collection: The collection to search in.
@param instance_objects: The running hierarchy of instance objects.
@param matrix_world: The world matrix of the current object.
@param visited: A set of visited object-instance pairs.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix.
'''
# We want to also yield the top-level instance object so that callers can inspect the selection status etc.
if visited is None:
visited = set()
if instance_objects is None:
instance_objects = list()
# First, yield all objects in child collections.
for child in collection.children:
yield from _dfs_collection_objects_recursive(child, instance_objects, matrix_world.copy(), visited)
# Then, evaluate all objects in this collection.
for obj in dfs_objects_in_collection(collection):
visited_pair = (obj, instance_objects[-1] if instance_objects else None)
if visited_pair in visited:
continue
# If this an instance, we need to recurse into it.
if obj.instance_collection is not None:
# Calculate the instance transform.
instance_offset_matrix = Matrix.Translation(-obj.instance_collection.instance_offset)
# Recurse into the instance collection.
yield from _dfs_collection_objects_recursive(obj.instance_collection,
instance_objects + [obj],
matrix_world @ (obj.matrix_world @ instance_offset_matrix),
visited)
else:
# Object is not an instance, yield it.
yield DfsObject(obj, instance_objects, matrix_world @ obj.matrix_world)
visited.add(visited_pair)
def dfs_view_layer_objects(view_layer: ViewLayer) -> Iterable[DfsObject]:
'''
Depth-first iterator over all objects in a view layer, including recursing into instances.
@param view_layer: The view layer to inspect.
@return: An iterable of tuples containing the object, the instance objects, and the world matrix.
'''
def layer_collection_objects_recursive(layer_collection: LayerCollection, 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,13 +1,14 @@
import os.path
from typing import Iterable, List, Set, Union
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
from bpy.types import Operator, Material, PropertyGroup, UIList, Object, FileHandler
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 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
@@ -15,6 +16,10 @@ 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.
@@ -47,14 +52,15 @@ class ASE_PG_export(PropertyGroup):
def get_unique_materials(mesh_objects: Iterable[Object]) -> List[Material]:
materials = set()
materials = []
for mesh_object in mesh_objects:
for i, material_slot in enumerate(mesh_object.material_slots):
material = material_slot.material
if material is None:
raise RuntimeError('Material slot cannot be empty (index ' + str(i) + ')')
materials.add(material)
return list(materials)
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):
@@ -66,6 +72,123 @@ def populate_material_list(mesh_objects: Iterable[Object], material_list):
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_order_add(Operator):
bl_idname = 'ase_export.material_order_add'
bl_label = 'Add'
bl_description = 'Add a material 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_string = operator.material_order.add()
material_string.string = 'Material'
return {'FINISHED'}
class ASE_OT_material_order_remove(Operator):
bl_idname = 'ase_export.material_order_remove'
bl_label = 'Remove'
bl_description = 'Remove the selected material 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)
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_order.remove(operator.material_order_index)
return {'FINISHED'}
class ASE_OT_material_order_move_up(Operator):
bl_idname = 'ase_export.material_order_move_up'
bl_label = 'Move Up'
bl_description = 'Move the selected material 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
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_order.move(operator.material_order_index, operator.material_order_index - 1)
operator.material_order_index -= 1
return {'FINISHED'}
class ASE_OT_material_order_move_down(Operator):
bl_idname = 'ase_export.material_order_move_down'
bl_label = 'Move Down'
bl_description = 'Move the selected material 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
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_order.move(operator.material_order_index, operator.material_order_index + 1)
operator.material_order_index += 1
return {'FINISHED'}
class ASE_OT_material_list_move_up(Operator):
bl_idname = 'ase_export.material_list_item_move_up'
bl_label = 'Move Up'
@@ -108,11 +231,80 @@ class ASE_UL_materials(UIList):
row.prop(item.material, 'name', text='', emboss=False, icon_value=layout.icon(item.material))
object_eval_state_items = (
class ASE_UL_material_names(UIList):
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)
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)
def execute(self, context):
collection = get_collection_from_context(context)
operator = get_collection_export_operator_from_context(context)
if operator is None:
return {'CANCELLED'}
from .dfs import dfs_collection_objects
mesh_objects = list(map(lambda x: x.obj, filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection, True))))
# Exclude objects that are not visible.
materials = get_unique_materials(mesh_objects)
operator.material_order.clear()
for material in materials:
m = operator.material_order.add()
m.string = material.name
return {'FINISHED'}
empty_set = set()
axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z')
forward_items = (
('X', 'X Forward', ''),
('Y', 'Y Forward', ''),
('Z', 'Z Forward', ''),
('-X', '-X Forward', ''),
('-Y', '-Y Forward', ''),
('-Z', '-Z Forward', ''),
)
up_items = (
('X', 'X Up', ''),
('Y', 'Y Up', ''),
('Z', 'Z Up', ''),
('-X', '-X Up', ''),
('-Y', '-Y Up', ''),
('-Z', '-Z Up', ''),
)
def forward_axis_update(self, _context: Context):
if self.forward_axis == self.up_axis:
self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z')
def up_axis_update(self, _context: Context):
if self.up_axis == self.forward_axis:
self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X')
class ASE_OT_export(Operator, ExportHelper):
bl_idname = 'io_scene_ase.ase_export'
@@ -122,16 +314,22 @@ 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):
if not any(x.type == 'MESH' for x in context.selected_objects):
cls.poll_message_set('At least one mesh must be selected')
if not any(x.type == 'MESH' or (x.type == 'EMPTY' and x.instance_collection is not None) for x in context.selected_objects):
cls.poll_message_set('At least one mesh or instanced collection must be selected')
return False
return True
@@ -139,6 +337,10 @@ class ASE_OT_export(Operator, ExportHelper):
layout = self.layout
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.label(text='Materials')
@@ -149,7 +351,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_down.bl_idname, icon='TRIA_DOWN', text='')
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')
row = vertex_colors_header.row()
@@ -166,6 +367,16 @@ class ASE_OT_export(Operator, ExportHelper):
else:
vertex_colors_panel.label(text='No vertex color attributes found')
transform_header, transform_panel = layout.panel('Transform', default_closed=True)
transform_header.label(text='Transform')
if transform_panel:
transform_panel.use_property_split = True
transform_panel.use_property_decorate = False
transform_panel.prop(self, 'scale')
transform_panel.prop(self, 'forward_axis')
transform_panel.prop(self, 'up_axis')
advanced_header, advanced_panel = layout.panel('Advanced', default_closed=True)
advanced_header.label(text='Advanced')
@@ -176,10 +387,21 @@ class ASE_OT_export(Operator, ExportHelper):
advanced_panel.prop(pg, 'should_invert_normals')
def invoke(self, context: 'Context', event: 'Event' ) -> Union[Set[str], Set[int]]:
mesh_objects = [x[0] for x in get_mesh_objects(context.selected_objects)]
from .dfs import dfs_view_layer_objects
mesh_objects = list(map(lambda x: x.obj, filter(lambda x: x.is_selected and x.obj.type == 'MESH', dfs_view_layer_objects(context.view_layer))))
if len(mesh_objects) == 0:
self.report({'ERROR'}, 'No mesh objects selected')
return {'CANCELLED'}
pg = getattr(context.scene, 'ase_export')
populate_material_list(mesh_objects, pg.material_list)
try:
populate_material_list(mesh_objects, pg.material_list)
except RuntimeError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
self.filepath = f'{context.active_object.name}.ase'
@@ -198,16 +420,38 @@ 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
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:
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)
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'}
except ASEBuildError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
export_space_items = [
('WORLD', 'World Space', 'Export the collection in world-space (i.e., as it appears in the 3D view)'),
('INSTANCE', 'Instance Space', 'Export the collection as an instance (transforms the world-space geometry by the inverse of the instance offset)'),
]
class ASE_OT_export_collection(Operator, ExportHelper):
bl_idname = 'io_scene_ase.ase_export_collection'
bl_label = 'Export collection to ASE'
@@ -227,11 +471,47 @@ 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)
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
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='')
transform_header, transform_panel = layout.panel('Transform', default_closed=True)
transform_header.label(text='Transform')
if transform_panel:
transform_panel.use_property_split = True
transform_panel.use_property_decorate = False
transform_panel.prop(self, 'scale')
transform_panel.prop(self, 'forward_axis')
transform_panel.prop(self, 'up_axis')
advanced_header, advanced_panel = layout.panel('Advanced', default_closed=True)
advanced_header.label(text='Advanced')
@@ -239,21 +519,51 @@ class ASE_OT_export_collection(Operator, ExportHelper):
advanced_panel.use_property_split = True
advanced_panel.use_property_decorate = False
advanced_panel.prop(self, 'object_eval_state')
advanced_panel.prop(self, 'export_space')
def execute(self, context):
collection = bpy.data.collections.get(self.collection)
options = ASEBuildOptions()
options.object_eval_state = self.object_eval_state
options.transform = Matrix.Translation(-Vector(collection.instance_offset))
options.scale = self.scale
options.forward_axis = self.forward_axis
options.up_axis = self.up_axis
match self.export_space:
case 'WORLD':
options.transform = Matrix.Identity(4)
case 'INSTANCE':
options.transform = Matrix.Translation(-Vector(collection.instance_offset))
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)))
mesh_objects = [x.obj for x in dfs_objects]
# Iterate over all the objects in the collection.
mesh_objects = get_mesh_objects(collection.all_objects)
# Get all the materials used by the objects in the collection.
options.materials = get_unique_materials([x[0] for x in mesh_objects])
options.materials = get_unique_materials(mesh_objects)
# Sort the materials based on the order in the material order list, keeping in mind that the material order list
# 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)}
# Split the list of materials into two lists: one for materials that appear in the material order list, and one
# for materials that do not. Then append the two lists together, with the ordered materials first.
ordered_materials = []
unordered_materials = []
for material in options.materials:
if material.name in material_order_map:
ordered_materials.append(material)
else:
unordered_materials.append(material)
ordered_materials.sort(key=lambda x: material_order_map.get(x.name, len(material_order)))
options.materials = ordered_materials + unordered_materials
try:
ase = build_ase(context, options, collection.all_objects)
ase = build_ase(context, options, dfs_objects)
except ASEBuildError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
@@ -277,11 +587,18 @@ class ASE_FH_export(FileHandler):
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_FH_export,
)

View File

@@ -182,8 +182,8 @@ class ASEWriter(object):
cvert_list = mesh_node.push_child('MESH_CVERTLIST')
for i, vertex_color in enumerate(geometry_object.vertex_colors):
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))
texture_faces_node = parent_node.push_child('MESH_CFACELIST')
mesh_node.push_child('MESH_NUMCVFACES').push_datum(len(geometry_object.texture_vertex_faces))
texture_faces_node = mesh_node.push_child('MESH_CFACELIST')
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.push_data([texture_face_index] + list(texture_face))