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
12 changed files with 344 additions and 379 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)

View File

@@ -52,3 +52,4 @@ class ASE(object):
def __init__(self): def __init__(self):
self.materials = [] self.materials = []
self.geometry_objects = [] 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"

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 bpy
import bmesh import bmesh
import math import math
from mathutils import Matrix from mathutils import Matrix, Vector
SMOOTHING_GROUP_MAX = 32
class ASEBuilderError(Exception): class ASEBuilderError(Exception):
pass pass
@@ -11,38 +16,63 @@ 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, options: ASEBuilderOptions): def build(self, context: Context, options: ASEBuilderOptions, objects: Iterable[Object]):
ase = ASE() ase = ASE()
main_geometry_object = None main_geometry_object = None
for selected_object in context.selected_objects: mesh_objects = get_mesh_objects(objects)
if selected_object is None or selected_object.type != 'MESH':
continue 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 # Evaluate the mesh after modifiers are applied
if options.use_raw_mesh_data: if options.use_raw_mesh_data:
mesh_object = selected_object mesh_object = obj
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(selected_object, depsgraph) bm.from_object(obj, 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 = 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 geometry_object = main_geometry_object
else: else:
geometry_object = ASEGeometryObject() geometry_object = ASEGeometryObject()
geometry_object.name = selected_object.name geometry_object.name = obj.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)
@@ -54,41 +84,43 @@ 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 \'{selected_object.name}\' is not manifold') raise ASEBuilderError(f'Collision mesh \'{obj.name}\' is not manifold')
if not edge.is_convex: if not edge.is_convex:
del bm 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: if not geometry_object.is_collision and len(obj.data.materials) == 0:
raise ASEBuilderError(f'Mesh \'{selected_object.name}\' must have at least one material') 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): 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(selected_object.data.materials): for mesh_material_index, material in enumerate(obj.data.materials):
if material is None: if material is None:
raise ASEBuilderError(f'Material slot {mesh_material_index + 1} for mesh \'{selected_object.name}\' cannot be empty') raise ASEBuilderError(f'Material slot {mesh_material_index + 1} for mesh \'{obj.name}\' cannot be empty')
try: material_indices.append(ase.materials.index(material))
# 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 = geometry_object.vertex_offset + mesh_data.loops[loop_triangle.loops[0]].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)
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
@@ -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 # 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) % 32 face.smoothing = (poly_groups[loop_triangle.polygon_index] - 1) % SMOOTHING_GROUP_MAX
geometry_object.faces.append(face) geometry_object.faces.append(face)
if not geometry_object.is_collision: if not geometry_object.is_collision:
@@ -107,10 +139,12 @@ 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 range(3): for i in loop_triangle_index_order:
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)
@@ -125,22 +159,25 @@ 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(
geometry_object.texture_vertex_offset + loop_triangle.loops[0], 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[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')

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): class ASEFile(object):
def __init__(self): def __init__(self):
self.commands = [] self.commands = []

View File

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

View File

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

@@ -1,48 +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',
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'}

View File

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

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

View File

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