From 0bb81b35e68a99192f31cee1255f34e8dfb0e127 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 6 Feb 2021 00:03:27 -0800 Subject: [PATCH] Initial code commit --- .gitignore | 3 + README.md | 3 + src/__init__.py | 55 ++++++++++++++ src/ase.py | 49 +++++++++++++ src/builder.py | 110 ++++++++++++++++++++++++++++ src/exporter.py | 47 ++++++++++++ src/writer.py | 186 ++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 453 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 src/__init__.py create mode 100644 src/ase.py create mode 100644 src/builder.py create mode 100644 src/exporter.py create mode 100644 src/writer.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e12e8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +venv +*.pyc \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..beb954b --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# io_scene_ase + +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. \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..46a9195 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,55 @@ +bl_info = { + 'name': 'ASCII Scene Export', + 'description': 'Export ASE (ASCII Scene Export) files', + 'author': 'Colin Basnett (Darklight Games', + 'version': (1, 0, 0), + 'blender': (2, 90, 0), + 'location': 'File > Import-Export', + 'warning': 'This add-on is under development.', + 'wiki_url': 'https://github.com/cmbasnett/io_scene_ase/wiki', + 'tracker_url': 'https://github.com/cmbasnett/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 +import bpy.utils.previews +from bpy.props import IntProperty, CollectionProperty, StringProperty +import os +from . import ase +from . import builder +from . import writer +from . import exporter + +icons = [ + # 'lambda', +] + +classes = ( + exporter.ASE_OT_ExportOperator, +) + + +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) + + +def unregister(): + bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) + + for cls in classes: + bpy.utils.unregister_class(cls) diff --git a/src/ase.py b/src/ase.py new file mode 100644 index 0000000..96329f3 --- /dev/null +++ b/src/ase.py @@ -0,0 +1,49 @@ +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 ASEGeometryObject(object): + def __init__(self): + self.name = '' + self.vertices = [] + self.texture_vertices = [] + self.faces = [] + self.texture_vertex_faces = [] + self.face_normals = [] + 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 = [] + diff --git a/src/builder.py b/src/builder.py new file mode 100644 index 0000000..2961c06 --- /dev/null +++ b/src/builder.py @@ -0,0 +1,110 @@ +from .ase import * +import bpy +import bmesh +import math +from mathutils import Matrix + + +class ASEBuilderError(Exception): + pass + + +class ASEBuilderOptions(object): + def __init__(self): + self.scale = 1.0 + + +class ASEBuilder(object): + def build(self, context, options: ASEBuilderOptions): + ase = ASE() + + main_geometry_object = None + for obj in context.selected_objects: + if obj is None or obj.type != 'MESH': + continue + + mesh_data = obj.data + + 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 not geometry_object.is_collision and len(mesh_data.materials) == 0: + raise ASEBuilderError(f'Mesh \'{obj.name}\' must have at least one material') + + geometry_object.vertex_offset += len(geometry_object.vertices) + vertex_transform = Matrix.Scale(options.scale, 4) @ Matrix.Rotation(math.pi, 4, 'Z') @ obj.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(mesh_data.materials): + if material is None: + raise ASEBuilderError(f'Material slot {mesh_material_index + 1} for mesh \'{obj.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 face_index, loop_triangle in enumerate(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 geometry_object.is_collision: + face.material_index = material_indices[loop_triangle.material_index] + face.smoothing = poly_groups[loop_triangle.polygon_index] + geometry_object.faces.append(face) + + # Normals + if not geometry_object.is_collision: + 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 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) + + uv_layer = mesh_data.uv_layers.active.data + + # Texture Coordinates + geometry_object.texture_vertex_offset += len(geometry_object.texture_vertices) + if not geometry_object.is_collision: + for loop_index, loop in enumerate(mesh_data.loops): + u, v = uv_layer[loop_index].uv + geometry_object.texture_vertices.append((u, v, 0.0)) + + # Texture Faces + if not geometry_object.is_collision: + 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] + )) + + 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 diff --git a/src/exporter.py b/src/exporter.py new file mode 100644 index 0000000..4e61357 --- /dev/null +++ b/src/exporter.py @@ -0,0 +1,47 @@ +import bpy +import bpy_extras +from bpy.props import StringProperty, FloatProperty, EnumProperty +from .builder import * +from .writer import * + + +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( + items=(('M', 'Meters', ''), + ('U', 'Unreal', '')), + name='Units' + ) + + units_scale = { + 'M': 60.352, + 'U': 1.0 + } + + def draw(self, context): + layout = self.layout + layout.prop(self, 'units', expand=False) + + def execute(self, context): + options = ASEBuilderOptions() + options.scale = self.units_scale[self.units] + try: + ase = ASEBuilder().build(context, options) + ASEWriter().write(self.filepath, ase) + self.report({'INFO'}, 'ASE exported successful') + return {'FINISHED'} + except ASEBuilderError as e: + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} diff --git a/src/writer.py b/src/writer.py new file mode 100644 index 0000000..a5c7f11 --- /dev/null +++ b/src/writer.py @@ -0,0 +1,186 @@ +from .ase import * + + +class ASEFile(object): + def __init__(self): + self.commands = [] + + def add_command(self, name): + command = ASECommand(name) + self.commands.append(command) + return command + + +class ASECommand(object): + def __init__(self, name): + self.name = name + self.data = [] + self.children = [] + self.sub_commands = [] + + @property + def has_data(self): + return len(self.data) > 0 + + @property + def has_children(self): + return len(self.children) + + @property + def has_sub_commands(self): + return len(self.sub_commands) > 0 + + def push_datum(self, datum): + self.data.append(datum) + return self + + def push_data(self, data): + self.data += data + return self + + def push_sub_command(self, name): + command = ASECommand(name) + self.sub_commands.append(command) + return command + + def push_child(self, name): + child = ASECommand(name) + self.children.append(child) + return child + + +class ASEWriter(object): + + def __init__(self): + self.fp = None + self.indent = 0 + + def write_datum(self, datum): + if type(datum) is str: + self.fp.write(f'"{datum}"') + elif type(datum) is int: + self.fp.write(str(datum)) + elif type(datum) is float: + self.fp.write('{:0.4f}'.format(datum)) + elif type(datum) is dict: + for index, (key, value) in enumerate(datum.items()): + if index > 0: + self.fp.write(' ') + self.fp.write(f'{key}: ') + self.write_datum(value) + + def write_sub_command(self, sub_command): + self.fp.write(f' *{sub_command.name}') + if sub_command.has_data: + for datum in sub_command.data: + self.fp.write(' ') + self.write_datum(datum) + + def write_command(self, command): + self.fp.write('\t' * self.indent) + self.fp.write(f'*{command.name}') + if command.has_data: + for datum in command.data: + self.fp.write(' ') + self.write_datum(datum) + if command.has_sub_commands: + # Sub-commands are commands that appear inline with their parent command + for sub_command in command.sub_commands: + self.write_sub_command(sub_command) + if command.has_children: + self.fp.write(' {\n') + self.indent += 1 + for child in command.children: + self.write_command(child) + self.indent -= 1 + self.fp.write('\t' * self.indent + '}\n') + else: + self.fp.write('\n') + + def write_file(self, file: ASEFile): + for command in file.commands: + self.write_command(command) + + @staticmethod + def build_ase_tree(ase) -> ASEFile: + root = ASEFile() + root.add_command('3DSMAX_ASCIIEXPORT').push_datum(200) + + # Materials + if len(ase.materials) > 0: + material_list = root.add_command('MATERIAL_LIST') + material_list.push_child('MATERIAL_COUNT').push_datum(len(ase.materials)) + material_node = material_list.push_child('MATERIAL') + material_node.push_child('NUMSUBMTLS').push_datum(len(ase.materials)) + for material_index, material in enumerate(ase.materials): + submaterial_node = material_node.push_child('SUBMATERIAL') + submaterial_node.push_datum(material_index) + submaterial_node.push_child('MATERIAL_NAME').push_datum(material) + diffuse_node = submaterial_node.push_child('MAP_DIFFUSE') + diffuse_node.push_child('MAP_NAME').push_datum('default') + 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: + geomobject_node = root.add_command('GEOMOBJECT') + + geomobject_node.push_child('NODE_NAME').push_datum(geometry_object.name) + + mesh_node = geomobject_node.push_child('MESH') + + # Vertices + mesh_node.push_child('MESH_NUMVERTEX').push_datum(len(geometry_object.vertices)) + vertex_list_node = mesh_node.push_child('MESH_VERTEX_LIST') + for vertex_index, vertex in enumerate(geometry_object.vertices): + mesh_vertex = vertex_list_node.push_child('MESH_VERTEX').push_datum(vertex_index) + mesh_vertex.push_data([x for x in vertex]) + + # Faces + mesh_node.push_child('MESH_NUMFACES').push_datum(len(geometry_object.faces)) + faces_node = mesh_node.push_child('MESH_FACE_LIST') + for face_index, face in enumerate(geometry_object.faces): + face_node = faces_node.push_child('MESH_FACE') + face_node.push_datum({str(face_index): {'A': face.a, 'B': face.b, 'C': face.c, 'AB': 0, 'BC': 0, 'CA': 0}}) + face_node.push_sub_command('MESH_SMOOTHING').push_datum(face.smoothing) + face_node.push_sub_command('MESH_MTLID').push_datum(face.material_index) + + # Texture Coordinates + if len(geometry_object.texture_vertices) > 0: + mesh_node.push_child('MESH_NUMTVERTEX').push_datum(len(geometry_object.texture_vertices)) + tvertlist_node = mesh_node.push_child('MESH_TVERTLIST') + for tvert_index, tvert in enumerate(geometry_object.texture_vertices): + tvert_node = tvertlist_node.push_child('MESH_TVERT') + tvert_node.push_datum(tvert_index) + tvert_node.push_data(list(tvert)) + + # Texture Faces + if len(geometry_object.texture_vertex_faces) > 0: + mesh_node.push_child('MESH_NUMTVFACES').push_datum(len(geometry_object.texture_vertex_faces)) + texture_faces_node = mesh_node.push_child('MESH_TFACELIST') + for texture_face_index, texture_face in enumerate(geometry_object.texture_vertex_faces): + texture_face_node = texture_faces_node.push_child('MESH_TFACE') + texture_face_node.push_data([texture_face_index] + list(texture_face)) + + # Normals + if len(geometry_object.face_normals) > 0: + normals_node = mesh_node.push_child('MESH_NORMALS') + for normal_index, normal in enumerate(geometry_object.face_normals): + normal_node = normals_node.push_child('MESH_FACENORMAL') + normal_node.push_datum(normal_index) + normal_node.push_data(list(normal.normal)) + for vertex_normal in normal.vertex_normals: + vertex_normal_node = normals_node.push_child('MESH_VERTEXNORMAL') + vertex_normal_node.push_datum(vertex_normal.vertex_index) + vertex_normal_node.push_data(list(vertex_normal.normal)) + + geomobject_node.push_child('MATERIAL_REF').push_datum(0) + + return root + + def write(self, filepath, ase): + self.indent = 0 + ase_file = self.build_ase_tree(ase) + with open(filepath, 'w') as self.fp: + self.write_file(ase_file)