diff --git a/functions/uv_tools.py b/functions/uv_tools.py new file mode 100644 index 0000000..504fc84 --- /dev/null +++ b/functions/uv_tools.py @@ -0,0 +1,296 @@ +from typing import TypedDict +import bpy +from bpy.types import Operator, Object, Context, Mesh, MeshUVLoopLayer +import bmesh +import numpy as np +import math +from ..functions.translations import t +from ..core.register import register_wrap + +class GenerateLoopTreeResult(TypedDict): + tree: dict[str, set[str]] + selected_loops: dict[str,list[int]] + selected_verts: dict[str,int] + +@register_wrap +class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): + bl_idname = "avatar_toolkit.align_uv_edges_to_target" + bl_label = t("avatar_toolkit.align_uv_edges_to_target.label") + bl_description = t("avatar_toolkit.align_uv_edges_to_target.desc") + bl_options = {'REGISTER', 'UNDO'} + + + + #all selected objects need to be meshes for this to work - @989onan + @classmethod + def poll(cls, context: Context): + if not ((context.view_layer.objects.active is not None) and (len(context.view_layer.objects.selected) > 0)): + return False + if context.mode != "EDIT_MESH": + return False + for obj in context.view_layer.objects.selected: + if obj.type != "MESH": + return False + if not context.space_data: + return False + if not context.space_data.show_uvedit: + return False + if context.scene.tool_settings.use_uv_select_sync: + return False + return True + + def execute(self, context: Context): + + + target: str = context.view_layer.objects.active.name #The object which we want to align every other selected object's selected UV vertex line to + + sources: list[str] = [i.name for i in context.view_layer.objects.selected] #The objects which we want to align their selected UV lines to the target's UV line + + prev_mode: str = bpy.context.object.mode + bpy.ops.object.mode_set(mode='OBJECT') + + + def generate_loop_tree(obj_name: str) -> GenerateLoopTreeResult: + print("Finding selected line for: \""+obj_name+"\"!") + + + vert_target_loops: dict[str,list[int]] = {} + vert_target_verts: dict[str,int] = {} + + me: Mesh = bpy.data.objects[obj_name].data + uv_lay: MeshUVLoopLayer = me.uv_layers.active + bm: bmesh.types.BMesh = bmesh.new() + bm.from_mesh(me) + bm.verts.ensure_lookup_table() + + + + # To explain: + # So loops in UV maps are X polygons that make up a face (So a MeshLoop represent a face and each vertex on that face is in order) + # + # For some preknowledge: + # When a mesh is UV unwrapped, if a vertice is shared by two different faces on the model in the viewport and the vertice of both faces are in + # the same position on the UV map, then it considers it one point and the user can move it + # (is why the uv map doesn't split apart when you try to move a vertex because that would be annoying) + # + # The problem: + # The problem is that the data for whether the uv corners of two faces that share a vertex physically being connected and selected as one vertex on the uv map does not exist + # Though thankfully, blender forcibly (whether you like it or not) merges vertices of a uv map if the vertex of two different faces are actually shared in the UI, + # allowing for the moving of vertices of 4 faces connected by a single vertex. Behavior every normal blender user is familiar with. + # + # The solution + # We can use this to our advantage, by finding vertices on the uv map that share the same coridinate as another vertex that is also selected. + # that way we can group each pair shared in a line as the same vertex, and identify the line using these pairs and using the data that says for certain + # that two vertices share the same face loop, and therefore are connected. + + #hmmm real stupid grimlin hours with this one. Using a string as the index of a dictionary of loop corners that end up on the same coordinate + + for k,i in enumerate(uv_lay.vertex_selection): #go through the selected vertices on object. + if (i.value == True) and (bm.verts[me.loops[k].vertex_index].select == True) and (bm.verts[me.loops[k].vertex_index].hide == False): #filter out vertices that are hidden from UV port + key = np.array(uv_lay.uv[k].vector[:]) + key = key.round(decimals=5) #make a key that is the position of a selected vertex + + if str(key) not in vert_target_loops: + vert_target_loops[str(key)] = [] #if the vertex's position is not a list yet, add it. + vert_target_loops[str(key)].append(k) #Basically, group vertices based on their position on a UV map as a list. + vert_target_verts[str(key)] = me.loops[k].vertex_index #associate the index of the physical vertex in real space with the coordinate of the uv vertices that share a position (Basically associate UV vert with real vert) + if len(vert_target_loops) > 4000: #This usually indicates that the user has a bunch of crap selected. + self.report({'WARNING'}, t("UVTools.align_uv_to_target.warning.too_much")) + return + print("Finding connections on line for \""+obj_name+"\"!") + me.validate() + + bm = bmesh.new() + bm.from_mesh(me) + + + #print(vert_target_loops) + #print(vert_target_verts) + tree: dict[str, set[str]] = {} + selected_verts = np.hstack(list(vert_target_loops.values())) + #print(selected_verts) + bm.verts.ensure_lookup_table() + for uvcoordsstr in vert_target_loops: + + uv_lay = me.uv_layers.active + + + #before this section, each vert_target_loops is just groupings of vertices that share coordinates. + # Using the data that determines UV face corners (uvloops) that are associated with the real vertex, + # and the uv face corners (loops) that are on the same faces as the vertices that share coordinates in + # vert_target_loops, we can now identify them + #TL;DR: pairs of vertices that share cooridinates (chain links) find their buddies (make chain connected) + + # Someone explain this better than me if you can please - @989onan + extension_loops = [] + loops = bm.verts[vert_target_verts[uvcoordsstr]].link_loops + loops_indexes = [i.index for i in loops] + for loop in vert_target_loops[uvcoordsstr]: + if loop in loops_indexes: + loop_obj = loops[loops_indexes.index(loop)] + extension_loops.append(loop_obj.link_loop_next.index) + extension_loops.append(loop_obj.link_loop_prev.index) + + + + + + #make a tree out of the vertices we identified as sharing faces with the vertices in vert_target_loops, and then link them together in a dictionary. + #the order of this dictionary is unknown. + # Someone explain this better than me if you can please - @989onan + tree[uvcoordsstr] = set() + + for i in extension_loops: + if i in selected_verts: + key = np.array(uv_lay.uv[i].vector[:]) + key = key.round(decimals=5) + tree[uvcoordsstr].add(str(key)) + + if uvcoordsstr in tree: + if len(tree[uvcoordsstr]) > 2: + self.report({'WARNING'}, t("UVTools.align_uv_to_target.warning.need_a_line").format(obj=obj_name)) + return {'FINISHED'} + + uv_lay = me.uv_layers.active + for uvcoordstr in vert_target_loops: + for loop in vert_target_loops[uvcoordstr]: + uv_lay.vertex_selection[loop].value = True + + + bm.free() + me.validate() + print("found UV line connections for \""+obj_name+"\":") + #print(tree) + + return {"tree":tree,"selected_loops":vert_target_loops,"selected_verts":vert_target_verts} + + + + #This function uses the previous point to find the next point based on connected loops and faces. + def sort_uv_tree(originaltree: dict[str, set[str]], obj_name: str): + sortedtree: dict[str, set[str]] = originaltree.copy() + startpoints: list[str] = [] + for i in sortedtree: + if len(sortedtree[i]) < 2: + startpoints.append(i) + + if len(startpoints) != 2: + self.report({'WARNING'}, t("UVTools.align_uv_to_target.warning.need_a_line").format(obj=obj_name)) + return + + a_list1 = startpoints[0].replace(", "," ").replace("[","").replace("]","").split() + map_object1 = map(float, a_list1) + uvcoords1 = list(map_object1) + a_list2 = startpoints[1].replace(", "," ").replace("[","").replace("]","").split() + map_object2 = map(float, a_list2) + uvcoords2 = list(map_object2) + + cursor = context.space_data.cursor_location + + startpoint = None + if math.sqrt( (((uvcoords1[0]) - (cursor[0])) **2) + (((uvcoords1[1]) - (cursor[1])) **2) ) > math.sqrt( (((uvcoords2[0]) - (cursor[0])) **2) + (((uvcoords2[1]) - (cursor[1])) **2) ): + startpoint = startpoints[0] + else: + startpoint = startpoints[1] + + #Wew my first actual recursive sort! - @989onan + def recursive_sort_uv_tree(point: str, sortedfinal: list[str]): + #print("appending "+point) + sortedfinal.append(point) + + new_point: str = "" + for i in sortedtree: + if point in sortedtree[i]: + new_point = i + removed_value = sortedtree.pop(i) + #print(removed_value) + break + + if new_point == "": + print("BROKE OUT OF SORTING, FINAL TREE (Should be empty, if not you errored here!):") + print(sortedtree) + + return sortedfinal + + return recursive_sort_uv_tree(new_point, sortedfinal) + + array = [] + + sortedtree.pop(startpoint) + return recursive_sort_uv_tree(startpoint, array) + + def lerp(v0, v1, t): + return v0 + t * (v1 - v0) + + + target_data: GenerateLoopTreeResult = generate_loop_tree(target) + sorted_target_tree = sort_uv_tree(target_data["tree"], target) + print("sorted target.") + #print(sorted_target_tree) + + for source in sources: + if source == target: + continue + + #create our list of points that is a chain. then sort the chain into the correct order based on connections of vertices and the faces that the vertices make up in the UV map. + try: + source_data = generate_loop_tree(source) + sorted_source_tree = sort_uv_tree(source_data["tree"], source) + print("Sorted source "+source) + print(sorted_source_tree) + + vertex_factor = float(len(sorted_target_tree)-1) / (float(len(sorted_source_tree)-1)) + + print(str(vertex_factor)+" = "+str(float(len(sorted_target_tree)-1)) + " / " + str((float(len(sorted_source_tree)-1)))+")") + except Exception as e: + print(e) + return {'FINISHED'} + + for k,i in enumerate(sorted_source_tree): + + try: + #find where we are on the target edges, to interpolate the current point we're placing along the target point's line. + progress_along_edge = (float(k)*vertex_factor) + previous_vertex_index = math.floor(progress_along_edge) + next_vertex_index = math.ceil(progress_along_edge) + + + #find the uv coordinates of the previous and next points on the target uv line. + a_list1 = sorted_target_tree[previous_vertex_index].replace(", "," ").replace("[","").replace("]","").split() + map_object1 = map(float, a_list1) + previous_point = list(map_object1) + a_list2 = sorted_target_tree[next_vertex_index].replace(", "," ").replace("[","").replace("]","").split() + map_object2 = map(float, a_list2) + next_point = list(map_object2) + + + + #create a point between these two values that represents a decimal 0-1 going where we are to where we are going between the two current points on the edge we are targeting this whole shebang with. + progress_between_points = progress_along_edge - int(progress_along_edge) + lerped_point = [lerp(previous_point[0],next_point[0],progress_between_points),lerp(previous_point[1],next_point[1],progress_between_points)] + + #grab our uv face corners for each uv coord that we saved. + #Since each face is considered separate internally, we have to treat each connected face to a vertex in a uv map as separate entities/vertexes. + #basically pretend they are split apart. + uv_face_corners = source_data["selected_loops"][i] + #print("doing from vertex "+str(previous_vertex_index)+" to "+str(next_vertex_index)+" total progress: "+str(progress_along_edge)) + + + + me: Mesh = bpy.data.objects[source].data + me.validate() + bm: bmesh.types.BMesh = bmesh.new() + bm.from_mesh(me) + uv_lay: MeshUVLoopLayer = me.uv_layers.active + bm.verts.ensure_lookup_table() + for corner in uv_face_corners: + uv_lay.uv[corner].vector = lerped_point #put the vertcies at the point we calculated. + except: + print("This is probably fine? - @989onan") #TODO: What happened here? The magic of making code so complex you forget if this is even an issue. - @989onan + + print("Finished mesh \""+source+"\" for UV's") + + + + bpy.ops.object.mode_set(mode=prev_mode) + return {'FINISHED'} \ No newline at end of file diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index b433d0e..67e268c 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -126,6 +126,10 @@ "Tools.digitigrade_legs.success": "Digitigrade legs created successfully", "Tools.import_any_model.desc": "Import any supported model, FBX, SMD, DMX, GLTF, PMD, PMX and more.", "Tools.import_any_model.label": "Import Model", + "UVTools.align_uv_to_target.warning.too_much": "Error! You have way to much stuff selected. Are you sure you're selecting two edges?", + "UVTools.align_uv_to_target.warning.need_a_line": "You need one line of selected uv points per selected object. Object \"{obj}\" does not meet this requirement!", + "avatar_toolkit.align_uv_edges_to_target.label":"Align UV Edges to Target", + "avatar_toolkit.align_uv_edges_to_target.desc":"Aligns a selected line of UV points on each selected mesh\nto the line of selected uv points on the active mesh.\nUseful for kitbashing textures of one model onto another.\nUses distance from the 2D cursor to identify the start of the line of uv points on each mesh.", "Tools.label": "Tools", "Tools.no_armature_selected": "No armature selected", "Tools.select_armature": "Please select an armature", diff --git a/ui/optimization.py b/ui/optimization.py index eefba12..3e19c9f 100644 --- a/ui/optimization.py +++ b/ui/optimization.py @@ -17,7 +17,7 @@ class AvatarToolkit_PT_OptimizationPanel(bpy.types.Panel): bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order = 2 - def draw(self, context): + def draw(self: bpy.types.Panel, context: bpy.types.Context): layout = self.layout armature = get_selected_armature(context) diff --git a/ui/panel.py b/ui/panel.py index bd2dd73..c4a04f4 100644 --- a/ui/panel.py +++ b/ui/panel.py @@ -2,6 +2,12 @@ import bpy from ..core.register import register_wrap from ..functions.translations import t +def draw_title(self: bpy.types.Panel): + layout = self.layout + layout.label(text=t("AvatarToolkit.welcome")) + layout.label(text=t("AvatarToolkit.description")) + layout.label(text=t("AvatarToolkit.alpha_warning")) + CATEGORY_NAME = "Avatar Toolkit" @register_wrap @@ -12,10 +18,7 @@ class AvatarToolKit_PT_AvatarToolkitPanel(bpy.types.Panel): bl_region_type = 'UI' bl_category = CATEGORY_NAME - def draw(self, context): - layout = self.layout - layout.label(text=t("AvatarToolkit.welcome")) - layout.label(text=t("AvatarToolkit.description")) - layout.label(text=t("AvatarToolkit.alpha_warning")) + def draw(self: bpy.types.Panel, context: bpy.types.Context): + draw_title(self) diff --git a/ui/uv_panel.py b/ui/uv_panel.py new file mode 100644 index 0000000..8221e31 --- /dev/null +++ b/ui/uv_panel.py @@ -0,0 +1,17 @@ +import bpy +from ..core.register import register_wrap +from ..functions.translations import t +from .panel import draw_title + +@register_wrap +class UVTools_PT_MainPanel(bpy.types.Panel): + bl_label = t("AvatarToolkit.label") + bl_idname = "OBJECT_PT_avatar_toolkit_uv" + bl_space_type = 'IMAGE_EDITOR' + bl_region_type = 'UI' + bl_category = "Avatar Toolkit" + + def draw(self: bpy.types.Panel, context: bpy.types.Context): + layout = self.layout + + draw_title(self) \ No newline at end of file diff --git a/ui/uv_tools.py b/ui/uv_tools.py new file mode 100644 index 0000000..d5a6d83 --- /dev/null +++ b/ui/uv_tools.py @@ -0,0 +1,22 @@ + +import bpy +from ..core.register import register_wrap +from ..functions.translations import t +from ..functions.uv_tools import AvatarToolkit_OT_AlignUVEdgesToTarget +from .panel import draw_title + +@register_wrap +class UVTools_PT_Tools(bpy.types.Panel): + bl_label = t("Tools.label") + bl_idname = "OBJECT_PT_avatar_toolkit_uv_tools" + bl_space_type = 'IMAGE_EDITOR' + bl_region_type = 'UI' + bl_category = "Avatar Toolkit" + bl_parent_id = "OBJECT_PT_avatar_toolkit_uv" + bl_order = 3 + + def draw(self, context: bpy.types.Context): + layout = self.layout + + row = layout.row(align=True) + row.operator(AvatarToolkit_OT_AlignUVEdgesToTarget.bl_idname, text=t("avatar_toolkit.align_uv_edges_to_target.label"), icon='GP_MULTIFRAME_EDITING') \ No newline at end of file