From 9ac8a43f9e39d55b4b74d360405912ec2675a28e Mon Sep 17 00:00:00 2001 From: redstrate Date: Wed, 11 Aug 2021 18:25:18 -0400 Subject: [PATCH] Add existing code --- LICENSE | 21 ++++ README.md | 10 ++ __init__.py | 330 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 LICENSE create mode 100644 __init__.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8889d80 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Joshua Goins + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e69de29..468a8a1 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,10 @@ +# Red's Avatar Tools + +Non-destructive extra blender tooling for my VRChat avatars. Supports both PC and Quest thanks to CATs. + +Right now it's barely usable except for my avatar, but it takes care of automatically calling CATs and all of that. Giving it a +directory of your asset's dir it will automatically export to Unity with all of the correct baking options. It also takes care of +my curve hair and all of my modifiers as part of my non-destructive character modeling workflow. It does this process for PC and +Quest and sets the options accordingly for Excellent rated avatars. + +This is mostly an exercise in Blender addon development, please don't expect any support at the moment. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..4a7e615 --- /dev/null +++ b/__init__.py @@ -0,0 +1,330 @@ +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['Armature.002'] + + # 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 + + # 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! + context.scene.bake_use_decimation = True + context.scene.bake_quick_compare = False + context.scene.bake_max_tris = 32000 - 55 + 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 + context.scene.bake_resolution = 2048 + context.scene.bake_generate_uvmap = True + context.scene.bake_pass_diffuse = True + context.scene.bake_diffuse_vertex_colors = False + context.scene.bake_preserve_seams = True + context.scene.bake_pass_diffuse = True + context.scene.bake_uv_overlap_correction = 'REPROJECT' + context.scene.bake_diffuse_alpha_pack = 'SMOOTHNESS' + context.scene.bake_prioritize_face = True + context.scene.bake_face_scale = 2.0 + + 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_use_decimation = True + context.scene.bake_quick_compare = False + context.scene.bake_max_tris = 7500 - 100 + context.scene.bake_pass_normal = True + context.scene.bake_pass_ao = True + context.scene.bake_pass_questdiffuse = True + context.scene.bake_resolution = 1024 + context.scene.bake_generate_uvmap = True + context.scene.bake_diffuse_vertex_colors = False + context.scene.bake_normal_apply_trans = False + context.scene.bake_preserve_seams = True + context.scene.bake_pass_diffuse = True + context.scene.bake_remove_doubles = True + context.scene.bake_uv_overlap_correction = 'NONE' + context.scene.bake_diffuse_alpha_pack = 'SMOOTHNESS' + context.scene.bake_prioritize_face = True + context.scene.bake_face_scale = 5.0 + + 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.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.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()