1 Commits

Author SHA1 Message Date
Colin Basnett
89fd3937e5 Initial commit for import functionality 2022-08-08 01:31:31 -07:00
12 changed files with 379 additions and 344 deletions

View File

@@ -1,38 +0,0 @@
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)

View File

@@ -1,27 +0,0 @@
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,200 +0,0 @@
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,
)

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
python-ase

215
src/__init__.py Normal file
View File

@@ -0,0 +1,215 @@
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'
}
import subprocess
import os
import sys
from collections import namedtuple
import bpy
def install_pip():
"""
Installs pip if not already present. Please note that ensurepip.bootstrap() also calls pip, which adds the
environment variable PIP_REQ_TRACKER. After ensurepip.bootstrap() finishes execution, the directory doesn't exist
anymore. However, when subprocess is used to call pip, in order to install a package, the environment variables
still contain PIP_REQ_TRACKER with the now nonexistent path. This is a problem since pip checks if PIP_REQ_TRACKER
is set and if it is, attempts to use it as temp directory. This would result in an error because the
directory can't be found. Therefore, PIP_REQ_TRACKER needs to be removed from environment variables.
:return:
"""
try:
# Check if pip is already installed
subprocess.run([sys.executable, '-m', 'pip', '--version'], check=True)
except subprocess.CalledProcessError:
import ensurepip
ensurepip.bootstrap()
os.environ.pop('PIP_REQ_TRACKER', None)
def install_and_import_module(module_name, package_name=None, global_name=None):
"""
Installs the package through pip and attempts to import the installed module.
:param module_name: Module to import.
:param package_name: (Optional) Name of the package that needs to be installed. If None it is assumed to be equal
to the module_name.
:param global_name: (Optional) Name under which the module is imported. If None the module_name will be used.
This allows to import under a different name with the same effect as e.g. "import numpy as np" where "np" is
the global_name under which the module can be accessed.
:raises: subprocess.CalledProcessError and ImportError
"""
if package_name is None:
package_name = module_name
if global_name is None:
global_name = module_name
# Blender disables the loading of user site-packages by default. However, pip will still check them to determine
# if a dependency is already installed. This can cause problems if the packages is installed in the user
# site-packages and pip deems the requirement satisfied, but Blender cannot import the package from the user
# site-packages. Hence, the environment variable PYTHONNOUSERSITE is set to disallow pip from checking the user
# site-packages. If the package is not already installed for Blender's Python interpreter, it will then try to.
# The paths used by pip can be checked with `subprocess.run([bpy.app.binary_path_python, "-m", "site"], check=True)`
# Create a copy of the environment variables and modify them for the subprocess call
environ_copy = dict(os.environ)
environ_copy['PYTHONNOUSERSITE'] = '1'
subprocess.run([sys.executable, '-m', 'pip', 'install', package_name], check=True, env=environ_copy)
# The installation succeeded, attempt to import the module again
import_module(module_name, global_name)
class EXAMPLE_OT_install_dependencies(bpy.types.Operator):
bl_idname = 'io_scene_ase.install_dependencies'
bl_label = 'Install dependencies'
bl_description = ('Downloads and installs the required python packages for this add-on. '
'Internet connection is required. Blender may have to be started with '
'elevated permissions in order to install the package')
bl_options = {'REGISTER', 'INTERNAL'}
@classmethod
def poll(self, context):
# Deactivate when dependencies have been installed
return not dependencies_installed
def execute(self, context):
try:
install_pip()
for dependency in dependencies:
install_and_import_module(module_name=dependency.module,
package_name=dependency.package,
global_name=dependency.name)
except (subprocess.CalledProcessError, ImportError) as err:
self.report({'ERROR'}, str(err))
return {'CANCELLED'}
global dependencies_installed
dependencies_installed = True
# Register the panels, operators, etc. since dependencies are installed
for cls in classes:
bpy.utils.register_class(cls)
return {'FINISHED'}
class EXAMPLE_preferences(bpy.types.AddonPreferences):
bl_idname = __name__
def draw(self, context):
layout = self.layout
layout.operator(EXAMPLE_OT_install_dependencies.bl_idname, icon='CONSOLE')
Dependency = namedtuple('Dependency', ['module', 'package', 'name'])
dependencies = (Dependency(module='asepy', package=None, name=None),)
dependencies_installed = False
def import_module(module_name, global_name=None, reload=True):
"""
Import a module.
:param module_name: Module to import.
:param global_name: (Optional) Name under which the module is imported. If None the module_name will be used.
This allows to import under a different name with the same effect as e.g. "import numpy as np" where "np" is
the global_name under which the module can be accessed.
:raises: ImportError and ModuleNotFoundError
"""
if global_name is None:
global_name = module_name
if global_name in globals():
importlib.reload(globals()[global_name])
else:
# Attempt to import the module and assign it to globals dictionary. This allow to access the module under
# the given name, just like the regular import would.
globals()[global_name] = importlib.import_module(module_name)
preference_classes = (EXAMPLE_OT_install_dependencies,
EXAMPLE_preferences)
if __name__ == 'io_scene_ase':
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)
if 'reader' in locals(): importlib.reload(reader)
import bpy
import bpy.utils.previews
from . import ase
from . import builder
from . import writer
from . import exporter
print('dependencies installed??')
print(dependencies_installed)
if dependencies_installed:
from . import reader
classes = (
exporter.ASE_OT_ExportOperator,
)
if dependencies_installed:
classes += reader.classes
def menu_func_export(self, context):
self.layout.operator(exporter.ASE_OT_ExportOperator.bl_idname, text='ASCII Scene Export (.ase)')
def menu_func_import(self, context):
self.layout.operator(reader.ASE_OT_ImportOperator.bl_idname, text='ASCII Scene Export (.ase)')
def register():
global dependencies_installed
for cls in preference_classes:
bpy.utils.register_class(cls)
try:
for dependency in dependencies:
import_module(module_name=dependency.module, global_name=dependency.name)
dependencies_installed = True
except ModuleNotFoundError:
# Don't register other panels, operators etc.
return
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
if 'reader' in locals():
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
def unregister():
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
if 'reader' in locals():
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
for cls in classes:
bpy.utils.unregister_class(cls)

View File

@@ -52,4 +52,3 @@ class ASE(object):
def __init__(self): def __init__(self):
self.materials = [] self.materials = []
self.geometry_objects = [] self.geometry_objects = []

View File

@@ -1,14 +1,9 @@
from typing import Iterable, Optional, List, Tuple from .ase import *
from bpy.types import Object, Context, Material
from .ase import ASE, ASEGeometryObject, ASEFace, ASEFaceNormal, ASEVertexNormal, ASEUVLayer, is_collision_name
import bpy import bpy
import bmesh import bmesh
import math import math
from mathutils import Matrix, Vector from mathutils import Matrix
SMOOTHING_GROUP_MAX = 32
class ASEBuilderError(Exception): class ASEBuilderError(Exception):
pass pass
@@ -16,63 +11,38 @@ class ASEBuilderError(Exception):
class ASEBuilderOptions(object): class ASEBuilderOptions(object):
def __init__(self): def __init__(self):
self.scale = 1.0
self.use_raw_mesh_data = False 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): class ASEBuilder(object):
def build(self, context: Context, options: ASEBuilderOptions, objects: Iterable[Object]): def build(self, context, options: ASEBuilderOptions):
ase = ASE() ase = ASE()
main_geometry_object = None main_geometry_object = None
mesh_objects = get_mesh_objects(objects) for selected_object in context.selected_objects:
if selected_object is None or selected_object.type != 'MESH':
context.window_manager.progress_begin(0, len(mesh_objects)) continue
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 # Evaluate the mesh after modifiers are applied
if options.use_raw_mesh_data: if options.use_raw_mesh_data:
mesh_object = obj mesh_object = selected_object
mesh_data = mesh_object.data mesh_data = mesh_object.data
else: else:
depsgraph = context.evaluated_depsgraph_get() depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new() bm = bmesh.new()
bm.from_object(obj, depsgraph) bm.from_object(selected_object, depsgraph)
mesh_data = bpy.data.meshes.new('') mesh_data = bpy.data.meshes.new('')
bm.to_mesh(mesh_data) bm.to_mesh(mesh_data)
del bm del bm
mesh_object = bpy.data.objects.new('', mesh_data) mesh_object = bpy.data.objects.new('', mesh_data)
mesh_object.matrix_world = matrix_world mesh_object.matrix_world = selected_object.matrix_world
if not is_collision_name(obj.name) and main_geometry_object is not None: if not is_collision_name(selected_object.name) and main_geometry_object is not None:
geometry_object = main_geometry_object geometry_object = main_geometry_object
else: else:
geometry_object = ASEGeometryObject() geometry_object = ASEGeometryObject()
geometry_object.name = obj.name geometry_object.name = selected_object.name
if not geometry_object.is_collision: if not geometry_object.is_collision:
main_geometry_object = geometry_object main_geometry_object = geometry_object
ase.geometry_objects.append(geometry_object) ase.geometry_objects.append(geometry_object)
@@ -84,43 +54,41 @@ class ASEBuilder(object):
for edge in bm.edges: for edge in bm.edges:
if not edge.is_manifold: if not edge.is_manifold:
del bm del bm
raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not manifold') raise ASEBuilderError(f'Collision mesh \'{selected_object.name}\' is not manifold')
if not edge.is_convex: if not edge.is_convex:
del bm del bm
raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not convex') raise ASEBuilderError(f'Collision mesh \'{selected_object.name}\' is not convex')
if not geometry_object.is_collision and len(obj.data.materials) == 0: if not geometry_object.is_collision and len(selected_object.data.materials) == 0:
raise ASEBuilderError(f'Mesh \'{obj.name}\' must have at least one material') raise ASEBuilderError(f'Mesh \'{selected_object.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): for vertex_index, vertex in enumerate(mesh_data.vertices):
geometry_object.vertices.append(vertex_transform @ vertex.co) geometry_object.vertices.append(vertex_transform @ vertex.co)
material_indices = [] material_indices = []
if not geometry_object.is_collision: if not geometry_object.is_collision:
for mesh_material_index, material in enumerate(obj.data.materials): for mesh_material_index, material in enumerate(selected_object.data.materials):
if material is None: if material is None:
raise ASEBuilderError(f'Material slot {mesh_material_index + 1} for mesh \'{obj.name}\' cannot be empty') raise ASEBuilderError(f'Material slot {mesh_material_index + 1} for mesh \'{selected_object.name}\' cannot be empty')
material_indices.append(ase.materials.index(material)) 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_loop_triangles()
mesh_data.calc_normals_split()
# Calculate smoothing groups.
poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=False) 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 # Faces
for face_index, loop_triangle in enumerate(mesh_data.loop_triangles): for face_index, loop_triangle in enumerate(mesh_data.loop_triangles):
face = ASEFace() 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) 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: if not geometry_object.is_collision:
face.material_index = material_indices[loop_triangle.material_index] face.material_index = material_indices[loop_triangle.material_index]
# The UT2K4 importer only accepts 32 smoothing groups. Anything past this completely mangles the # The UT2K4 importer only accepts 32 smoothing groups. Anything past this completely mangles the
@@ -130,7 +98,7 @@ class ASEBuilder(object):
# This may result in bad calculated normals on export in rare cases. For example, if a face with a # 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 # 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. # will be treated as part of the same smoothing group.
face.smoothing = (poly_groups[loop_triangle.polygon_index] - 1) % SMOOTHING_GROUP_MAX face.smoothing = (poly_groups[loop_triangle.polygon_index] - 1) % 32
geometry_object.faces.append(face) geometry_object.faces.append(face)
if not geometry_object.is_collision: if not geometry_object.is_collision:
@@ -139,12 +107,10 @@ class ASEBuilder(object):
face_normal = ASEFaceNormal() face_normal = ASEFaceNormal()
face_normal.normal = loop_triangle.normal face_normal.normal = loop_triangle.normal
face_normal.vertex_normals = [] face_normal.vertex_normals = []
for i in loop_triangle_index_order: for i in range(3):
vertex_normal = ASEVertexNormal() vertex_normal = ASEVertexNormal()
vertex_normal.vertex_index = geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[i]].vertex_index 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] 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) face_normal.vertex_normals.append(vertex_normal)
geometry_object.face_normals.append(face_normal) geometry_object.face_normals.append(face_normal)
@@ -159,25 +125,22 @@ class ASEBuilder(object):
# Texture Faces # Texture Faces
for loop_triangle in mesh_data.loop_triangles: for loop_triangle in mesh_data.loop_triangles:
geometry_object.texture_vertex_faces.append( geometry_object.texture_vertex_faces.append((
tuple(map(lambda l: geometry_object.texture_vertex_offset + loop_triangle.loops[l], loop_triangle_index_order)) 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 # Vertex Colors
if len(mesh_data.vertex_colors) > 0: if len(mesh_data.vertex_colors) > 0:
if mesh_data.vertex_colors.active is not None:
vertex_colors = mesh_data.vertex_colors.active.data vertex_colors = mesh_data.vertex_colors.active.data
for color in map(lambda x: x.color, vertex_colors): for color in map(lambda x: x.color, vertex_colors):
geometry_object.vertex_colors.append(tuple(color[0:3])) geometry_object.vertex_colors.append(tuple(color[0:3]))
# Update data offsets for next iteration # 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) 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: if len(ase.geometry_objects) == 0:
raise ASEBuilderError('At least one mesh object must be selected') raise ASEBuilderError('At least one mesh object must be selected')

48
src/exporter.py Normal file
View File

@@ -0,0 +1,48 @@
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',
default='U'
)
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 export successful')
return {'FINISHED'}
except ASEBuilderError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}

25
src/reader.py Normal file
View File

@@ -0,0 +1,25 @@
import bpy
from bpy.props import StringProperty
import bpy_extras
from asepy import read_ase
class ASE_OT_ImportOperator(bpy.types.Operator, bpy_extras.io_utils.ImportHelper):
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,
)
classes = (
ASE_OT_ImportOperator
)

View File

@@ -0,0 +1,18 @@
import bpy
try:
bpy.ops.object.mode_set(mode='OBJECT')
except:
pass
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
bpy.ops.mesh.primitive_monkey_add()
bpy.ops.object.select_all(action='SELECT')
mesh_object = bpy.context.view_layer.objects.active
material = bpy.data.materials.new('asd')
mesh_object.data.materials.append(material)
r = bpy.ops.io_scene_ase.ase_export(filepath=r'.\\flat.ase')

28
src/tests/tests.py Normal file
View File

@@ -0,0 +1,28 @@
import unittest
import os
from subprocess import run, PIPE, STDOUT
from ..reader import read_ase
from dotenv import load_dotenv
def run_blender_script(script_path: str, args=list()):
return run([os.environ['BLENDER_PATH'], '--background', '--python', script_path, '--'] + args)
class AseExportTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
load_dotenv()
def test_flat(self):
run_blender_script('src\\tests\\scripts\\export_flat_test.py')
read_ase('./flat.ase')
def test_smooth(self):
pass
if __name__ == '__main__':
print()
unittest.main()

View File

@@ -1,3 +1,6 @@
from .ase import *
class ASEFile(object): class ASEFile(object):
def __init__(self): def __init__(self):
self.commands = [] self.commands = []