19 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
Colin Basnett
51be4b8ee9 Removed zip file committed in error 2022-08-15 21:53:24 -07:00
Colin Basnett
94b3554f2f Fixed multi-object export texture corruption error
This fixes the bug where exporting 3 or more objects would result in
one or more objects have corrupted texture coordinates
2022-08-15 21:39:13 -07:00
Colin Basnett
ce6ae4a64c Added description to raw mesh data option 2022-08-11 02:09:27 -07:00
Colin Basnett
a593224b14 Added (ASE) to the name of the addon
This should make it easier to find in the addon list when searching
2022-08-11 02:08:26 -07:00
Colin Basnett
feb88794b2 Incremented version to 1.2.0 2022-08-11 02:07:38 -07:00
Colin Basnett
499e6f19c6 The default units are now Unreal instead of Meters 2022-08-11 02:06:46 -07:00
Colin Basnett
1bdc158f08 Merge branch 'feature-wysiwyg' 2022-08-11 00:02:10 -07:00
7 changed files with 312 additions and 112 deletions

View File

@@ -1,17 +1,3 @@
bl_info = {
'name': 'ASCII Scene Export',
'description': 'Export ASE (ASCII Scene Export) files',
'author': 'Colin Basnett (Darklight Games)',
'version': (1, 1, 2),
'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)
@@ -26,24 +12,27 @@ from . import builder
from . import writer
from . import exporter
classes = (
exporter.ASE_OT_ExportOperator,
)
classes = exporter.classes
def menu_func_export(self, context):
self.layout.operator(exporter.ASE_OT_ExportOperator.bl_idname, text='ASCII Scene Export (.ase)')
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)

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"

View File

@@ -1,9 +1,14 @@
from .ase import *
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
from mathutils import Matrix, Vector
SMOOTHING_GROUP_MAX = 32
class ASEBuilderError(Exception):
pass
@@ -11,38 +16,63 @@ class ASEBuilderError(Exception):
class ASEBuilderOptions(object):
def __init__(self):
self.scale = 1.0
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, options: ASEBuilderOptions):
def build(self, context: Context, options: ASEBuilderOptions, objects: Iterable[Object]):
ase = ASE()
main_geometry_object = None
for selected_object in context.selected_objects:
if selected_object is None or selected_object.type != 'MESH':
continue
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 = selected_object
mesh_object = obj
mesh_data = mesh_object.data
else:
depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new()
bm.from_object(selected_object, depsgraph)
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 = selected_object.matrix_world
mesh_object.matrix_world = matrix_world
if not is_collision_name(selected_object.name) and main_geometry_object is not None:
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 = selected_object.name
geometry_object.name = obj.name
if not geometry_object.is_collision:
main_geometry_object = geometry_object
ase.geometry_objects.append(geometry_object)
@@ -54,41 +84,43 @@ class ASEBuilder(object):
for edge in bm.edges:
if not edge.is_manifold:
del bm
raise ASEBuilderError(f'Collision mesh \'{selected_object.name}\' is not manifold')
raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not manifold')
if not edge.is_convex:
del bm
raise ASEBuilderError(f'Collision mesh \'{selected_object.name}\' is not convex')
raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not convex')
if not geometry_object.is_collision and len(selected_object.data.materials) == 0:
raise ASEBuilderError(f'Mesh \'{selected_object.name}\' must have at least one material')
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
vertex_transform = Matrix.Scale(options.scale, 4) @ Matrix.Rotation(math.pi, 4, 'Z') @ mesh_object.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(selected_object.data.materials):
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 \'{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)
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()
mesh_data.calc_normals_split()
# 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 = 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
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
@@ -98,7 +130,7 @@ class ASEBuilder(object):
# 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
face.smoothing = (poly_groups[loop_triangle.polygon_index] - 1) % SMOOTHING_GROUP_MAX
geometry_object.faces.append(face)
if not geometry_object.is_collision:
@@ -107,10 +139,12 @@ class ASEBuilder(object):
face_normal = ASEFaceNormal()
face_normal.normal = loop_triangle.normal
face_normal.vertex_normals = []
for i in range(3):
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)
@@ -125,22 +159,25 @@ class ASEBuilder(object):
# 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]
))
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.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')

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,6 +1,3 @@
from .ase import *
class ASEFile(object):
def __init__(self):
self.commands = []

View File

@@ -1,50 +0,0 @@
import bpy
import bpy_extras
from bpy.props import StringProperty, FloatProperty, EnumProperty, BoolProperty
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'
)
use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data')
units_scale = {
'M': 60.352,
'U': 1.0
}
def draw(self, context):
layout = self.layout
layout.prop(self, 'units', expand=False)
layout.prop(self, 'use_raw_mesh_data')
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 = 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'}