bl_info = { "name": "RAT", "author": "redstrate", "version": (0, 1), "blender": (2, 92, 3), "location": "3D View -> RAT", "description": "Non-destructive extea blender tooling for my VRChat avatars.", "doc_url": "https://git.redstrate.com/redstrate/reds-avatar-tools", "category": "Import-Export" } import bpy from bpy.types import Operator, AddonPreferences from bpy.props import StringProperty, BoolProperty, PointerProperty from bpy_extras.io_utils import ( ExportHelper, ) import shutil from mathutils import Matrix class RATAddonPreferences(AddonPreferences): bl_idname = __name__ pcAssetPath: StringProperty( name="PC Asset Dir Path", subtype='DIR_PATH' ) questAssetPath: StringProperty( name="Quest Asset Dir Path", subtype='DIR_PATH' ) def draw(self, context): layout = self.layout layout.prop(self, "pcAssetPath") layout.prop(self, "questAssetPath") # From https://blender.stackexchange.com/questions/127403/change-active-collection?noredirect=1&lq=1 def recurLayerCollection(layerColl, collName): found = None if (layerColl.name == collName): return layerColl for layer in layerColl.children: found = recurLayerCollection(layer, collName) if found: return found # also from stack overflow, used for parenting to bones but will be obsolete soon def parent_set(object, armature, bone): I = Matrix() # parent from tip to head offset = 0 # 0 tip, 1 head # tip matrix world T = Matrix.Translation( (1 - offset) * (bone.tail - bone.head) ) tmw = armature.matrix_world @ T @ bone.matrix amwi = armature.matrix_world.inverted() cmw = object.matrix_world.copy() cmb = object.matrix_basis.copy() cml = tmw.inverted() @ cmw #object.matrix_parent_inverse = cml @ object.matrix_basis.inverted() object.parent = armature object.parent_bone = bone.name object.parent_type = 'BONE' object.matrix_world = cmw class AvatarPrepOp(bpy.types.Operator): bl_idname = "scene.avatar_prep" bl_label = "Prep for export (debug)" bl_options = {"REGISTER", "UNDO"} perform_quest_optimizations: bpy.props.BoolProperty() def execute(self, context): arm_ob = recurLayerCollection(bpy.context.view_layer.layer_collection, 'Master').collection.objects[context.scene.rat_armature] # we are going to prep the hair for export first, and then add it into the collection bpy.context.view_layer.active_layer_collection = recurLayerCollection(bpy.context.view_layer.layer_collection, 'original hair') for ob in bpy.context.view_layer.active_layer_collection.collection.objects: if ob.type == 'CURVE' and not ob.hide_render: if self.perform_quest_optimizations: ob.data.resolution_u = 1 bpy.context.view_layer.objects.active = ob bpy.context.object.select_set( state = True, view_layer = None) bpy.ops.object.convert(target='MESH', keep_original=False) bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM') bpy.context.object.select_set( state = False, view_layer = None) recurLayerCollection(bpy.context.view_layer.layer_collection, 'Master').collection.objects.link(ob) parent_set(ob, arm_ob, arm_ob.pose.bones.get("Head")) ob.modifiers.new(name = 'Skeleton', type = 'ARMATURE') ob.modifiers['Skeleton'].object = arm_ob vtx_group = ob.vertex_groups.new(name = "Head") vtx_group.add(range(0,len(ob.data.vertices)), 1.0, 'REPLACE') #for edge in ob.data.edges: # edge.use_seam = True # isn't working bpy.context.view_layer.active_layer_collection = recurLayerCollection(bpy.context.view_layer.layer_collection, 'Master') # first we want to prep our current collection, but we want to duplicate it just in case something goes wrong. for window in context.window_manager.windows: screen = window.screen for area in screen.areas: if area.type == 'OUTLINER': override = {'window': window, 'screen': screen, 'area': area} bpy.ops.outliner.collection_duplicate(override) break # just for now, assume the second collection (from root) is our duplicate collection context.view_layer.active_layer_collection = context.view_layer.layer_collection.children[1] current_collection = context.view_layer.active_layer_collection.collection # disable the original collections recurLayerCollection(bpy.context.view_layer.layer_collection, 'original hair').exclude = True recurLayerCollection(bpy.context.view_layer.layer_collection, 'Master').exclude = True # context override c = {} obs = [] # loop through all objects, delete objects that are render disabled (for some reason, duping the collection re-enables view but not render) # and apply modifiers except for armature on objects that are not disabled for ob in current_collection.objects: if ob.hide_render: bpy.ops.object.delete({"selected_objects": [ob]}) else: if ob.type != 'ARMATURE': bpy.context.view_layer.objects.active = ob # delete the "to remove" vertex group if it exists if self.perform_quest_optimizations and ob.vertex_groups.find('to remove') != -1: bpy.ops.object.editmode_toggle() ob.vertex_groups.active = ob.vertex_groups['to remove'] bpy.ops.mesh.select_all(action='DESELECT') bpy.ops.mesh.select_mode(type = 'FACE') bpy.ops.object.vertex_group_select() bpy.ops.mesh.delete(type='FACE') bpy.ops.object.editmode_toggle() for modifier in ob.modifiers: if modifier.type == 'SUBSURF' and self.perform_quest_optimizations: modifier.levels = 1 modifier.render_levels = 1 if modifier.type == 'SOLIDIFY' and self.perform_quest_optimizations: bpy.ops.object.modifier_remove(modifier=modifier.name) else: bpy.ops.object.modifier_apply(modifier=modifier.name) c["object"] = c["active_object"] = ob obs.append(ob) c["selected_objects"] = c["selected_editable_objects"] = obs # join all objects that are left over bpy.ops.object.join(c) new_obj = current_collection.objects[1] # delete all pre-existing uv maps #new_obj.data.uv_layers.remove(new_obj.data.uv_layers.get("UVMap")) # generate new smart uv map bpy.context.view_layer.objects.active = new_obj bpy.ops.object.editmode_toggle() bpy.ops.mesh.select_all(action='SELECT') # for all faces bpy.ops.uv.smart_project(angle_limit=66.0, island_margin=0.000, area_weight=0.0) bpy.ops.object.editmode_toggle() # now select the new armature bpy.context.view_layer.objects.active = current_collection.objects[0] return {'FINISHED'} class AvatarBakeOp(bpy.types.Operator): bl_idname = "scene.avatar_bake" bl_label = "Export to PC Unity Project" bl_options = {"REGISTER", "UNDO"} def execute(self, context): bpy.ops.scene.avatar_prep(perform_quest_optimizations = False) # bake options for PC context.scene.bake_resolution = 2048 context.scene.bake_use_decimation = True context.scene.bake_max_tris = 32000 - 55 context.scene.bake_preserve_seams = True context.scene.bake_remove_doubles = False context.scene.bake_generate_uvmap = False context.scene.bake_prioritize_face = True context.scene.bake_face_scale = 2.0 context.scene.bake_uv_overlap_correction = 'REPROJECT' context.scene.bake_quick_compare = False context.scene.bake_pass_diffuse = True context.scene.bake_diffuse_vertex_colors = False context.scene.bake_diffuse_alpha_pack = 'SMOOTHNESS' context.scene.bake_pass_normal = True context.scene.bake_normal_apply_trans = False context.scene.bake_pass_ao = True context.scene.bake_pass_questdiffuse = False bpy.ops.cats_bake.bake() for obj in bpy.data.collections['CATS Bake'].all_objects: obj.select_set(True) preferences = context.preferences addon_prefs = preferences.addons[__name__].preferences # now export ops = {} ops['filepath'] = addon_prefs.pcAssetPath + "/sakura.fbx" ops['use_selection'] = True ops['path_mode'] = 'STRIP' ops['add_leaf_bones'] = False bpy.ops.export_scene.fbx(**ops) # copy baked images shutil.copyfile(bpy.path.abspath("//") + '/CATS Bake/SCRIPT_diffuse.png', addon_prefs.pcAssetPath + '/SCRIPT_diffuse.png') shutil.copyfile(bpy.path.abspath("//") + '/CATS Bake/SCRIPT_ao.png', addon_prefs.pcAssetPath + '/SCRIPT_ao.png') shutil.copyfile(bpy.path.abspath("//") + '/CATS Bake/SCRIPT_normal.png', addon_prefs.pcAssetPath + '/SCRIPT_normal.png') return {'FINISHED'} class AvatarBakeQuestOp(bpy.types.Operator): bl_idname = "scene.avatar_bake_quest" bl_label = "Export to Quest Unity Project" bl_options = {"REGISTER", "UNDO"} def execute(self, context): bpy.ops.scene.avatar_prep(perform_quest_optimizations = True) # bake! context.scene.bake_resolution = 1024 context.scene.bake_use_decimation = True context.scene.bake_max_tris = 7500 - 100 context.scene.bake_preserve_seams = True context.scene.bake_remove_doubles = True context.scene.bake_generate_uvmap = False context.scene.bake_prioritize_face = True context.scene.bake_face_scale = 5.0 context.scene.bake_uv_overlap_correction = 'NONE' context.scene.bake_quick_compare = False context.scene.bake_pass_diffuse = True context.scene.bake_diffuse_vertex_colors = False context.scene.bake_diffuse_alpha_pack = 'SMOOTHNESS' context.scene.bake_pass_normal = True context.scene.bake_normal_apply_trans = False context.scene.bake_pass_ao = True context.scene.bake_pass_questdiffuse = True bpy.ops.cats_bake.bake() for obj in bpy.data.collections['CATS Bake'].all_objects: obj.select_set(True) preferences = context.preferences addon_prefs = preferences.addons[__name__].preferences # now export ops = {} ops['filepath'] = addon_prefs.questAssetPath + "/sakura.fbx" ops['use_selection'] = True ops['path_mode'] = 'STRIP' ops['add_leaf_bones'] = False bpy.ops.export_scene.fbx(**ops) # copy baked images shutil.copyfile(bpy.path.abspath("//") + '\CATS Bake\SCRIPT_diffuse.png', addon_prefs.questAssetPath + '\SCRIPT_diffuse.png') shutil.copyfile(bpy.path.abspath("//") + '\CATS Bake\SCRIPT_questdiffuse.png', addon_prefs.questAssetPath + '\SCRIPT_questdiffuse.png') shutil.copyfile(bpy.path.abspath("//") + '\CATS Bake\SCRIPT_normal.png', addon_prefs.questAssetPath + '\SCRIPT_normal.png') return {'FINISHED'} class ToolPanel(object): bl_idname = "OBJECT_AW_TOOLPANEL" bl_label = "Red's Avatar Tools" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "RAT" class ExportPanel(ToolPanel, bpy.types.Panel): bl_idname = "OBJECT_AW_EXPORT" bl_label = "Export" def draw(self, context): # unused for now #self.layout.prop_search(context.scene, # "master_collection", # bpy.data, # "collections") #self.layout.prop_search(context.scene, # "hair_collection", # bpy.data, # "collections") self.layout.prop_search(context.scene, "rat_armature", bpy.data, "objects") self.layout.operator(AvatarPrepOp.bl_idname) self.layout.operator(AvatarBakeOp.bl_idname) self.layout.operator(AvatarBakeQuestOp.bl_idname) def register(): bpy.utils.register_class(RATAddonPreferences) bpy.types.Scene.rat_armature = StringProperty(default="None") bpy.types.Scene.master_collection = StringProperty(default="None") bpy.types.Scene.hair_collection = StringProperty(default="None") bpy.utils.register_class(AvatarPrepOp) bpy.utils.register_class(AvatarBakeOp) bpy.utils.register_class(AvatarBakeQuestOp) bpy.utils.register_class(ExportPanel) def unregister(): bpy.utils.unregister_class(AvatarBakeQuestOp) bpy.utils.unregister_class(AvatarBakeOp) bpy.utils.unregister_class(ExportPanel) bpy.utils.unregister_class(AvatarPrepOp) bpy.utils.unregister_class(RATAddonPreferences) if __name__ == "__main__": register()