12 Commits

Author SHA1 Message Date
Colin Basnett
eb5a9ed564 Fixed and added some strings 2024-06-30 15:50:18 -07:00
Colin Basnett
09895e1fa1 Updated manifest 2024-06-30 11:21:20 -07:00
Colin Basnett
ac4d96b396 Added blender_manifest file for compatiblity with Blender Extensions platform 2024-06-17 12:13:45 -07:00
Colin Basnett
3fe4a6af53 A number of changes and additions to the exporter:
* The "Scale"/"Units" option has been removed from the exporter. If users want to work in proper units, they should override their Scene unit scaling.
* Asset instances can now be exported.
* Added operator to batch export top-level collections.
2024-05-28 21:57:15 -07:00
Colin Basnett
972ea5deda Added the ability to re-order materials when exporting multiple objects 2024-05-27 20:51:17 -07:00
Colin Basnett
2ff4a661f2 Fixed an issue with the order of texture vertex indices when one or more objects has negative scaling 2024-05-27 18:44:13 -07:00
Colin Basnett
956de550b2 Removed debug printing 2024-05-26 14:23:00 -07:00
Colin Basnett
bd90088aed Added logic to detect negative object scaling values and accomodate WYSIWYG normals on export
Also added "export collections" operator that exports each viible collection as an ASE
2024-05-26 13:47:26 -07:00
Colin Basnett
ecfd9897b1 Moved files to named subfolder for easier packaging 2024-02-28 18:50:12 -08:00
Colin Basnett
a3e350e96e Fix for rare error if vertex colors were somehow not active 2024-02-28 18:49:00 -08:00
Colin Basnett
7b417ae425 Incremented version to 2.0.0 and the minimum version to 4.0.0 2023-11-10 23:29:45 -08:00
Colin Basnett
c59f16ed5e Removed deprecated call to calc_normals_split 2023-11-10 23:28:30 -08:00
10 changed files with 535 additions and 460 deletions

38
io_scene_ase/__init__.py Normal file
View File

@@ -0,0 +1,38 @@
if 'bpy' in locals():
import importlib
if 'ase' in locals(): importlib.reload(ase)
if 'builder' in locals(): importlib.reload(builder)
if 'writer' in locals(): importlib.reload(writer)
if 'exporter' in locals(): importlib.reload(exporter)
import bpy
import bpy.utils.previews
from . import ase
from . import builder
from . import writer
from . import exporter
classes = exporter.classes
def menu_func_export(self, context):
self.layout.operator(exporter.ASE_OT_export.bl_idname, text='ASCII Scene Export (.ase)')
self.layout.operator(exporter.ASE_OT_export_collections.bl_idname, text='ASCII Scene Export Collections (.ase)')
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.ase_export = bpy.props.PointerProperty(type=exporter.ASE_PG_export)
bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
def unregister():
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
del bpy.types.Scene.ase_export
for cls in classes:
bpy.utils.unregister_class(cls)

55
io_scene_ase/ase.py Normal file
View File

@@ -0,0 +1,55 @@
class ASEFace(object):
def __init__(self):
self.a = 0
self.b = 0
self.c = 0
self.ab = 0
self.bc = 0
self.ca = 0
self.smoothing = 0
self.material_index = 0
class ASEVertexNormal(object):
def __init__(self):
self.vertex_index = 0
self.normal = (0.0, 0.0, 0.0)
class ASEFaceNormal(object):
def __init__(self):
self.normal = (0.0, 0.0, 1.0)
self.vertex_normals = [ASEVertexNormal()] * 3
def is_collision_name(name):
return name.startswith('MCDCX_')
class ASEUVLayer(object):
def __init__(self):
self.texture_vertices = []
class ASEGeometryObject(object):
def __init__(self):
self.name = ''
self.vertices = []
self.uv_layers = []
self.faces = []
self.texture_vertex_faces = []
self.face_normals = []
self.vertex_colors = []
self.vertex_offset = 0
self.texture_vertex_offset = 0
@property
def is_collision(self):
return is_collision_name(self.name)
class ASE(object):
def __init__(self):
self.materials = []
self.geometry_objects = []

View File

@@ -0,0 +1,27 @@
schema_version = "1.0.0"
id = "io_scene_ase"
version = "2.1.0"
name = "ASCII Scene Export (.ase)"
tagline = "Export .ase files used in Unreal Engine 1 & 2"
maintainer = "Colin Basnett <cmbasnett@gmail.com>"
type = "add-on"
website = "https://github.com/DarklightGames/io_scene_ase/"
tags = ["Game Engine", "Import-Export"]
blender_version_min = "4.2.0"
# Optional: maximum supported Blender version
# blender_version_max = "5.1.0"
license = [
"SPDX:GPL-3.0-or-later",
]
[build]
paths_exclude_pattern = [
"/.git/",
"__pycache__/",
"/venv/",
"/.github/",
".gitignore",
]
[permissions]
files = "Export ASE files to disk"

187
io_scene_ase/builder.py Normal file
View File

@@ -0,0 +1,187 @@
from typing import Iterable, Optional, List, Tuple
from bpy.types import Object, Context, Material
from .ase import ASE, ASEGeometryObject, ASEFace, ASEFaceNormal, ASEVertexNormal, ASEUVLayer, is_collision_name
import bpy
import bmesh
import math
from mathutils import Matrix, Vector
SMOOTHING_GROUP_MAX = 32
class ASEBuilderError(Exception):
pass
class ASEBuilderOptions(object):
def __init__(self):
self.use_raw_mesh_data = False
self.materials: Optional[List[Material]] = None
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_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
class ASEBuilder(object):
def build(self, context: Context, options: ASEBuilderOptions, objects: Iterable[Object]):
ase = ASE()
main_geometry_object = None
mesh_objects = get_mesh_objects(objects)
context.window_manager.progress_begin(0, len(mesh_objects))
ase.materials = options.materials
for object_index, (obj, asset_instance) in enumerate(mesh_objects):
matrix_world = get_object_matrix(obj, asset_instance)
# Evaluate the mesh after modifiers are applied
if options.use_raw_mesh_data:
mesh_object = obj
mesh_data = mesh_object.data
else:
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
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)
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:
del bm
raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not manifold')
if not edge.is_convex:
del bm
raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not convex')
if not geometry_object.is_collision and len(obj.data.materials) == 0:
raise ASEBuilderError(f'Mesh \'{obj.name}\' must have at least one material')
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 = []
if not geometry_object.is_collision:
for mesh_material_index, material in enumerate(obj.data.materials):
if material is None:
raise ASEBuilderError(f'Material slot {mesh_material_index + 1} for mesh \'{obj.name}\' cannot be empty')
material_indices.append(ase.materials.index(material))
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
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)
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)
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 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))
# 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))
)
# Vertex Colors
if len(mesh_data.vertex_colors) > 0:
if mesh_data.vertex_colors.active is not None:
vertex_colors = mesh_data.vertex_colors.active.data
for color in map(lambda x: x.color, vertex_colors):
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)
context.window_manager.progress_update(object_index)
context.window_manager.progress_end()
if len(ase.geometry_objects) == 0:
raise ASEBuilderError('At least one mesh object must be selected')
if main_geometry_object is None:
raise ASEBuilderError('At least one non-collision mesh must be exported')
return ase

200
io_scene_ase/exporter.py Normal file
View File

@@ -0,0 +1,200 @@
import os.path
from typing import Iterable, List, Set, Union
from bpy_extras.io_utils import ExportHelper
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty
from bpy.types import Operator, Material, PropertyGroup, UIList, Object
from .builder import ASEBuilder, ASEBuilderOptions, ASEBuilderError, get_mesh_objects
from .writer import ASEWriter
class ASE_PG_material(PropertyGroup):
material: PointerProperty(type=Material)
class ASE_PG_export(PropertyGroup):
material_list: CollectionProperty(name='Materials', type=ASE_PG_material)
material_list_index: IntProperty(name='Index', default=0)
def get_unique_materials(mesh_objects: Iterable[Object]) -> List[Material]:
materials = set()
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)
def populate_material_list(mesh_objects: Iterable[Object], material_list):
materials = get_unique_materials(mesh_objects)
material_list.clear()
for index, material in enumerate(materials):
m = material_list.add()
m.material = material
m.index = index
class ASE_OT_material_list_move_up(Operator):
bl_idname = 'ase_export.material_list_item_move_up'
bl_label = 'Move Up'
bl_options = {'INTERNAL'}
bl_description = 'Move the selected material up one slot'
@classmethod
def poll(cls, context):
pg = getattr(context.scene, 'ase_export')
return pg.material_list_index > 0
def execute(self, context):
pg = getattr(context.scene, 'ase_export')
pg.material_list.move(pg.material_list_index, pg.material_list_index - 1)
pg.material_list_index -= 1
return {'FINISHED'}
class ASE_OT_material_list_move_down(Operator):
bl_idname = 'ase_export.material_list_item_move_down'
bl_label = 'Move Down'
bl_options = {'INTERNAL'}
bl_description = 'Move the selected material down one slot'
@classmethod
def poll(cls, context):
pg = getattr(context.scene, 'ase_export')
return pg.material_list_index < len(pg.material_list) - 1
def execute(self, context):
pg = getattr(context.scene, 'ase_export')
pg.material_list.move(pg.material_list_index, pg.material_list_index + 1)
pg.material_list_index += 1
return {'FINISHED'}
class ASE_UL_materials(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row()
row.prop(item.material, 'name', text='', emboss=False, icon_value=layout.icon(item.material))
class ASE_OT_export(Operator, ExportHelper):
bl_idname = 'io_scene_ase.ase_export'
bl_label = 'Export ASE'
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_description = 'Export selected objects to ASE'
filename_ext = '.ase'
filter_glob: StringProperty(default="*.ase", options={'HIDDEN'}, maxlen=255)
use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh')
def draw(self, context):
layout = self.layout
materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials')
if materials_panel:
row = materials_panel.row()
row.template_list('ASE_UL_materials', '', context.scene.ase_export, 'material_list', context.scene.ase_export, 'material_list_index')
col = row.column(align=True)
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='')
advanced_header, advanced_panel = layout.panel('Advanced', default_closed=True)
advanced_header.label(text='Advanced')
if advanced_panel:
advanced_panel.prop(self, 'use_raw_mesh_data')
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)]
pg = getattr(context.scene, 'ase_export')
populate_material_list(mesh_objects, pg.material_list)
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
options = ASEBuilderOptions()
options.use_raw_mesh_data = self.use_raw_mesh_data
pg = getattr(context.scene, 'ase_export')
options.materials = [x.material for x in pg.material_list]
try:
ase = ASEBuilder().build(context, options, context.selected_objects)
ASEWriter().write(self.filepath, ase)
self.report({'INFO'}, 'ASE exported successfully')
return {'FINISHED'}
except ASEBuilderError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
class ASE_OT_export_collections(Operator, ExportHelper):
bl_idname = 'io_scene_ase.ase_export_collections'
bl_label = 'Export Collections to ASE'
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_description = 'Batch export collections to ASE. The name of the collection will be used as the filename'
filename_ext = '.ase'
filter_glob: StringProperty(
default="*.ase",
options={'HIDDEN'},
maxlen=255, # Max internal buffer length, longer would be hilighted.
)
use_raw_mesh_data: BoolProperty(
default=False,
description='No modifiers will be evaluated as part of the exported mesh',
name='Raw Mesh Data')
def draw(self, context):
layout = self.layout
layout.prop(self, 'use_raw_mesh_data')
def execute(self, context):
options = ASEBuilderOptions()
options.use_raw_mesh_data = self.use_raw_mesh_data
# Iterate over all the visible collections in the scene.
layer_collections = context.view_layer.layer_collection.children
collections = [x.collection for x in layer_collections if not x.hide_viewport and not x.exclude]
context.window_manager.progress_begin(0, len(layer_collections))
for i, collection in enumerate(collections):
# 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])
try:
ase = ASEBuilder().build(context, options, collection.all_objects)
dirname = os.path.dirname(self.filepath)
filepath = os.path.join(dirname, collection.name + '.ase')
ASEWriter().write(filepath, ase)
except ASEBuilderError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
context.window_manager.progress_update(i)
context.window_manager.progress_end()
self.report({'INFO'}, f'{len(collections)} collections exported successfully')
return {'FINISHED'}
classes = (
ASE_PG_material,
ASE_UL_materials,
ASE_PG_export,
ASE_OT_export,
ASE_OT_export_collections,
ASE_OT_material_list_move_down,
ASE_OT_material_list_move_up,
)

View File

@@ -1,22 +1,14 @@
from .ase import Ase class ASEFile(object):
class AseWriterOptions(object):
def __init__(self):
self.should_use_sub_materials = True
class AseFile(object):
def __init__(self): def __init__(self):
self.commands = [] self.commands = []
def add_command(self, name): def add_command(self, name):
command = AseCommand(name) command = ASECommand(name)
self.commands.append(command) self.commands.append(command)
return command return command
class AseCommand(object): class ASECommand(object):
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
self.data = [] self.data = []
@@ -44,17 +36,17 @@ class AseCommand(object):
return self return self
def push_sub_command(self, name): def push_sub_command(self, name):
command = AseCommand(name) command = ASECommand(name)
self.sub_commands.append(command) self.sub_commands.append(command)
return command return command
def push_child(self, name): def push_child(self, name):
child = AseCommand(name) child = ASECommand(name)
self.children.append(child) self.children.append(child)
return child return child
class AseWriter(object): class ASEWriter(object):
def __init__(self): def __init__(self):
self.fp = None self.fp = None
@@ -102,77 +94,47 @@ class AseWriter(object):
else: else:
self.fp.write('\n') self.fp.write('\n')
def write_file(self, file: AseFile): def write_file(self, file: ASEFile):
self.indent = 0
for command in file.commands: for command in file.commands:
self.write_command(command) self.write_command(command)
@staticmethod @staticmethod
def build_ase_file(ase: Ase, options: AseWriterOptions) -> AseFile: def build_ase_tree(ase) -> ASEFile:
root = AseFile() root = ASEFile()
root.add_command('3DSMAX_ASCIIEXPORT').push_datum(200) root.add_command('3DSMAX_ASCIIEXPORT').push_datum(200)
# Materials # Materials
if len(ase.materials) > 0: if len(ase.materials) > 0:
material_list = root.add_command('MATERIAL_LIST') material_list = root.add_command('MATERIAL_LIST')
material_list.push_child('MATERIAL_COUNT').push_datum(len(ase.materials)) material_list.push_child('MATERIAL_COUNT').push_datum(len(ase.materials))
if options.should_use_sub_materials: material_node = material_list.push_child('MATERIAL')
material_node = material_list.push_child('MATERIAL') material_node.push_child('NUMSUBMTLS').push_datum(len(ase.materials))
material_node.push_child('NUMSUBMTLS').push_datum(len(ase.materials)) for material_index, material in enumerate(ase.materials):
for material_index, material in enumerate(ase.materials): submaterial_node = material_node.push_child('SUBMATERIAL')
submaterial_node = material_node.push_child('SUBMATERIAL') submaterial_node.push_datum(material_index)
submaterial_node.push_datum(material_index) 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') 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) diffuse_node.push_child('UVW_V_TILING').push_datum(1.0)
diffuse_node.push_child('UVW_V_TILING').push_datum(1.0)
else:
for material_index, material in enumerate(ase.materials):
material_node = material_list.push_child('MATERIAL').push_datum(material_index)
material_node.push_child('MATERIAL_NAME').push_datum(material)
diffuse_node = material_node.push_child('MAP_DIFFUSE')
diffuse_node.push_child('MAP_NAME').push_datum('default')
diffuse_node.push_child('UVW_U_OFFSET').push_datum(0.0)
diffuse_node.push_child('UVW_V_OFFSET').push_datum(0.0)
diffuse_node.push_child('UVW_U_TILING').push_datum(1.0)
diffuse_node.push_child('UVW_V_TILING').push_datum(1.0)
for geometry_object in ase.geometry_objects: for geometry_object in ase.geometry_objects:
geomobject_node = root.add_command('GEOMOBJECT') geomobject_node = root.add_command('GEOMOBJECT')
geomobject_node.push_child('NODE_NAME').push_datum(geometry_object.name) geomobject_node.push_child('NODE_NAME').push_datum(geometry_object.name)
# TODO: only do this in T3D compatibility mode (or just do it always because it makes no difference?)
transform_node = geomobject_node.push_child('NODE_TM')
transform_node.push_child('NODE_NAME').push_datum(geometry_object.name)
transform_node.push_child('INHERIT_POS').push_data([0, 0, 0])
transform_node.push_child('INHERIT_ROT').push_data([0, 0, 0])
transform_node.push_child('INHERIT_SCL').push_data([0, 0, 0])
transform_node.push_child('TM_ROW0').push_data([1.0, 0.0, 0.0])
transform_node.push_child('TM_ROW1').push_data([0.0, 1.0, 0.0])
transform_node.push_child('TM_ROW2').push_data([0.0, 0.0, 1.0])
transform_node.push_child('TM_ROW3').push_data([0.0, 0.0, 0.0])
transform_node.push_child('TM_POS').push_data([0.0, 0.0, 0.0])
transform_node.push_child('TM_ROTAXIS').push_data([0.0, 0.0, 0.0])
transform_node.push_child('TM_ROTANGLE').push_datum(0.0)
transform_node.push_child('TM_SCALE').push_datum(0.0)
transform_node.push_child('TM_SCALEAXIS').push_data([0.0, 0.0, 0.0])
transform_node.push_child('TM_SCALEAXISANG').push_datum(0.0)
mesh_node = geomobject_node.push_child('MESH') mesh_node = geomobject_node.push_child('MESH')
mesh_node.push_child('MESH_NUMVERTEX').push_datum(len(geometry_object.vertices))
mesh_node.push_child('MESH_NUMFACES').push_datum(len(geometry_object.faces))
# Vertices # Vertices
mesh_node.push_child('MESH_NUMVERTEX').push_datum(len(geometry_object.vertices))
vertex_list_node = mesh_node.push_child('MESH_VERTEX_LIST') vertex_list_node = mesh_node.push_child('MESH_VERTEX_LIST')
for vertex_index, vertex in enumerate(geometry_object.vertices): for vertex_index, vertex in enumerate(geometry_object.vertices):
mesh_vertex = vertex_list_node.push_child('MESH_VERTEX').push_datum(vertex_index) mesh_vertex = vertex_list_node.push_child('MESH_VERTEX').push_datum(vertex_index)
mesh_vertex.push_data([x for x in vertex]) mesh_vertex.push_data([x for x in vertex])
# Faces # Faces
mesh_node.push_child('MESH_NUMFACES').push_datum(len(geometry_object.faces))
faces_node = mesh_node.push_child('MESH_FACE_LIST') faces_node = mesh_node.push_child('MESH_FACE_LIST')
for face_index, face in enumerate(geometry_object.faces): for face_index, face in enumerate(geometry_object.faces):
face_node = faces_node.push_child('MESH_FACE') face_node = faces_node.push_child('MESH_FACE')
@@ -217,20 +179,18 @@ 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)
mesh_node.push_child('MESH_NUMCVFACES').push_datum(len(geometry_object.texture_vertex_faces)) parent_node.push_child('MESH_NUMCVFACES').push_datum(len(geometry_object.texture_vertex_faces))
texture_faces_node = mesh_node.push_child('MESH_CFACELIST') texture_faces_node = parent_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))
geomobject_node.push_child('PROP_MOTIONBLUR').push_datum(0)
geomobject_node.push_child('PROP_CASTSHADOW').push_datum(1)
geomobject_node.push_child('PROP_RECVSHADOW').push_datum(1)
geomobject_node.push_child('MATERIAL_REF').push_datum(0) geomobject_node.push_child('MATERIAL_REF').push_datum(0)
return root return root
def write(self, filepath, ase: Ase, options: AseWriterOptions): def write(self, filepath, ase):
ase_file = self.build_ase_file(ase, options) self.indent = 0
ase_file = self.build_ase_tree(ase)
with open(filepath, 'w') as self.fp: with open(filepath, 'w') as self.fp:
self.write_file(ase_file) self.write_file(ase_file)

View File

@@ -1,51 +0,0 @@
bl_info = {
'name': 'ASCII Scene Export (ASE)',
'description': 'Export ASE (ASCII Scene Export) files',
'author': 'Colin Basnett (Darklight Games)',
'version': (1, 2, 1),
'blender': (2, 90, 0),
'location': 'File > Import-Export',
'warning': 'This add-on is under development.',
'wiki_url': 'https://github.com/DarklightGames/io_scene_ase/wiki',
'tracker_url': 'https://github.com/DarklightGames/io_scene_ase/issues',
'support': 'COMMUNITY',
'category': 'Import-Export'
}
if 'bpy' in locals():
import importlib
if 'ase' in locals(): importlib.reload(ase)
if 'builder' in locals(): importlib.reload(builder)
if 'writer' in locals(): importlib.reload(writer)
if 'exporter' in locals(): importlib.reload(exporter)
import bpy
from bpy.props import PointerProperty
from . import ase
from . import builder
from . import writer
from . import exporter
classes = exporter.__classes__
def menu_func_export(self, context):
self.layout.operator(exporter.ASE_OT_ExportOperator.bl_idname, text='ASCII Scene Export (.ase)')
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
bpy.types.Scene.ase_export = PointerProperty(type=exporter.AseExportPropertyGroup)
def unregister():
del bpy.types.Scene.ase_export
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
for cls in classes:
bpy.utils.unregister_class(cls)

View File

@@ -1,49 +0,0 @@
from typing import List, Tuple
class AseFace(object):
def __init__(self):
self.a = 0
self.b = 0
self.c = 0
self.ab = 0
self.bc = 0
self.ca = 0
self.smoothing = 0
self.material_index = 0
class AseVertexNormal(object):
def __init__(self):
self.vertex_index = 0
self.normal = (0.0, 0.0, 0.0)
class AseFaceNormal(object):
def __init__(self):
self.normal = (0.0, 0.0, 1.0)
self.vertex_normals = [AseVertexNormal()] * 3
class AseUVLayer(object):
def __init__(self):
self.texture_vertices: List[Tuple[float, float, float]] = []
class AseGeometryObject(object):
def __init__(self):
self.name = ''
self.vertices: List[Tuple[float, float, float]] = []
self.uv_layers: List[AseUVLayer] = []
self.faces: List[AseFace] = []
self.texture_vertex_faces = []
self.face_normals: List[AseFaceNormal] = []
self.vertex_colors: List[Tuple[float, float, float]] = []
self.vertex_offset = 0
self.texture_vertex_offset = 0
class Ase(object):
def __init__(self):
self.materials: List[str] = []
self.geometry_objects: List[AseGeometryObject] = []

View File

@@ -1,157 +0,0 @@
from .ase import *
import bpy
import bmesh
import math
from mathutils import Matrix
def is_collision_name(name: str):
return name.startswith('MCDCX_')
def is_collision(geometry_object: AseGeometryObject):
return is_collision_name(geometry_object.name)
class AseBuilderError(Exception):
pass
class AseBuilderOptions(object):
def __init__(self):
self.scale = 1.0
self.use_raw_mesh_data = False
def build_ase(context: bpy.types.Context, options: AseBuilderOptions):
ase = Ase()
main_geometry_object = None
for selected_object in context.view_layer.objects.selected:
if selected_object is None or selected_object.type != 'MESH':
continue
# Evaluate the mesh after modifiers are applied
if options.use_raw_mesh_data:
mesh_object = selected_object
mesh_data = mesh_object.data
else:
depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new()
bm.from_object(selected_object, 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 = selected_object.matrix_world
if not is_collision_name(selected_object.name) and main_geometry_object is not None:
geometry_object = main_geometry_object
else:
geometry_object = AseGeometryObject()
geometry_object.name = selected_object.name
if not is_collision(geometry_object):
main_geometry_object = geometry_object
ase.geometry_objects.append(geometry_object)
if is_collision(geometry_object):
# Test that collision meshes are manifold and convex.
bm = bmesh.new()
bm.from_mesh(mesh_data)
for edge in bm.edges:
if not edge.is_manifold:
del bm
raise AseBuilderError(f'Collision mesh \'{selected_object.name}\' is not manifold')
if not edge.is_convex:
del bm
raise AseBuilderError(f'Collision mesh \'{selected_object.name}\' is not convex')
if not is_collision(geometry_object) and len(selected_object.data.materials) == 0:
raise AseBuilderError(f'Mesh \'{selected_object.name}\' must have at least one material')
vertex_transform = Matrix.Scale(options.scale, 4) @ Matrix.Rotation(math.pi, 4, 'Z') @ mesh_object.matrix_world
for vertex in mesh_data.vertices:
geometry_object.vertices.append(vertex_transform @ vertex.co)
material_indices = []
if not is_collision(geometry_object):
for mesh_material_index, material in enumerate(selected_object.data.materials):
if material is None:
raise AseBuilderError(f'Material slot {mesh_material_index + 1} for mesh \'{selected_object.name}\' cannot be empty')
try:
# Reuse existing material entries for duplicates
material_index = ase.materials.index(material.name)
except ValueError:
material_index = len(ase.materials)
ase.materials.append(material.name)
material_indices.append(material_index)
mesh_data.calc_loop_triangles()
mesh_data.calc_normals_split()
poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=False)
# Faces
for loop_triangle in mesh_data.loop_triangles:
face = AseFace()
face.a = geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[0]].vertex_index
face.b = geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[1]].vertex_index
face.c = geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[2]].vertex_index
if not is_collision(geometry_object):
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) % 32
geometry_object.faces.append(face)
if not is_collision(geometry_object):
# Normals
for loop_triangle in mesh_data.loop_triangles:
face_normal = AseFaceNormal()
face_normal.normal = loop_triangle.normal
face_normal.vertex_normals = []
for i in range(3):
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]
face_normal.vertex_normals.append(vertex_normal)
geometry_object.face_normals.append(face_normal)
# 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))
# Texture Faces
for loop_triangle in mesh_data.loop_triangles:
geometry_object.texture_vertex_faces.append((
geometry_object.texture_vertex_offset + loop_triangle.loops[0],
geometry_object.texture_vertex_offset + loop_triangle.loops[1],
geometry_object.texture_vertex_offset + loop_triangle.loops[2]
))
# Vertex Colors
if len(mesh_data.vertex_colors) > 0:
vertex_colors = mesh_data.vertex_colors.active.data
for color in map(lambda x: x.color, vertex_colors):
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)
if len(ase.geometry_objects) == 0:
raise AseBuilderError('At least one mesh object must be selected')
if main_geometry_object is None:
raise AseBuilderError('At least one non-collision mesh must be exported')
return ase

View File

@@ -1,135 +0,0 @@
import bpy_extras
import typing
from bpy.types import UIList, Context, UILayout, AnyType, Operator
from bpy.props import StringProperty, EnumProperty, BoolProperty, PointerProperty, CollectionProperty, IntProperty
from .builder import *
from .writer import *
class AseExportCollectionPropertyGroup(bpy.types.PropertyGroup):
is_selected: BoolProperty()
name: StringProperty()
collection: PointerProperty(type=bpy.types.Collection)
class AseExportPropertyGroup(bpy.types.PropertyGroup):
collection_list: CollectionProperty(type=AseExportCollectionPropertyGroup)
collection_list_index: IntProperty()
class ASE_UL_CollectionList(UIList):
def draw_item(self, context: Context, layout: UILayout, data: AnyType, item: AnyType, icon: int,
active_data: AnyType, active_property: str, index: int = 0, flt_flag: int = 0):
collection: bpy.types.Collection = getattr(item, 'collection')
row = layout.row()
row.prop(item, 'is_selected', text='')
row.label(text=collection.name, icon='OUTLINER_COLLECTION')
class AseExportCollectionsSelectAll(Operator):
bl_idname = 'ase_export.collections_select_all'
bl_label = 'All'
def execute(self, context: Context) -> typing.Union[typing.Set[str], typing.Set[int]]:
pg = getattr(context.scene, 'ase_export')
for collection in pg.collection_list:
collection.is_selected = True
return {'FINISHED'}
class AseExportCollectionsDeselectAll(Operator):
bl_idname = 'ase_export.collections_deselect_all'
bl_label = 'None'
def execute(self, context: Context) -> typing.Union[typing.Set[str], typing.Set[int]]:
pg = getattr(context.scene, 'ase_export')
for collection in pg.collection_list:
collection.is_selected = False
return {'FINISHED'}
class ASE_OT_ExportOperator(bpy.types.Operator, bpy_extras.io_utils.ExportHelper):
bl_idname = 'io_scene_ase.ase_export' # important since its how bpy.ops.import_test.some_data is constructed
bl_label = 'Export ASE'
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
filename_ext = '.ase'
filter_glob: StringProperty(
default="*.ase",
options={'HIDDEN'},
maxlen=255, # Max internal buffer length, longer would be hilighted.
)
units: EnumProperty(
default='U',
items=(('M', 'Meters', ''),
('U', 'Unreal', '')),
name='Units'
)
use_raw_mesh_data: BoolProperty(
default=False,
description='No modifiers will be evaluated as part of the exported mesh',
name='Raw Mesh Data')
units_scale = {
'M': 60.352,
'U': 1.0
}
should_use_sub_materials: BoolProperty(
default=True,
description='Material format', # fill this in with a more human-friendly name/description
name='Use Sub-materials'
)
def draw(self, context):
pg = getattr(context.scene, 'ase_export')
layout = self.layout
rows = max(3, min(len(pg.collection_list), 10))
layout.prop(self, 'units', expand=False)
layout.prop(self, 'use_raw_mesh_data')
layout.prop(self, 'should_use_sub_materials')
# # SELECT ALL/NONE
# row = layout.row(align=True)
# row.label(text='Select')
# row.operator(AseExportCollectionsSelectAll.bl_idname, text='All', icon='CHECKBOX_HLT')
# row.operator(AseExportCollectionsDeselectAll.bl_idname, text='None', icon='CHECKBOX_DEHLT')
#
# layout.template_list('ASE_UL_CollectionList', '', pg, 'collection_list', pg, 'collection_list_index', rows=rows)
def invoke(self, context: bpy.types.Context, event):
# TODO: build a list of collections that have meshes in them
pg = getattr(context.scene, 'ase_export')
pg.collection_list.clear()
for collection in bpy.data.collections:
has_meshes = any(map(lambda x: x.type == 'MESH', collection.objects))
if has_meshes:
c = pg.collection_list.add()
c.collection = collection
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
options = AseBuilderOptions()
options.scale = self.units_scale[self.units]
options.use_raw_mesh_data = self.use_raw_mesh_data
try:
ase = build_ase(context, options)
writer_options = AseWriterOptions()
writer_options.should_use_sub_materials = self.should_use_sub_materials
AseWriter().write(self.filepath, ase, writer_options)
self.report({'INFO'}, 'ASE exported successful')
return {'FINISHED'}
except AseBuilderError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
__classes__ = (
AseExportCollectionPropertyGroup,
AseExportPropertyGroup,
AseExportCollectionsSelectAll,
AseExportCollectionsDeselectAll,
ASE_UL_CollectionList,
ASE_OT_ExportOperator
)