Initial code commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.idea
|
||||||
|
venv
|
||||||
|
*.pyc
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -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.
|
||||||
55
src/__init__.py
Normal file
55
src/__init__.py
Normal file
@@ -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)
|
||||||
49
src/ase.py
Normal file
49
src/ase.py
Normal file
@@ -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 = []
|
||||||
|
|
||||||
110
src/builder.py
Normal file
110
src/builder.py
Normal file
@@ -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
|
||||||
47
src/exporter.py
Normal file
47
src/exporter.py
Normal file
@@ -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'}
|
||||||
186
src/writer.py
Normal file
186
src/writer.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user