# Copyright (C) 2021 Victor Soupday # This file is part of CC/iC Blender Tools # # CC/iC Blender Tools is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # CC/iC Blender Tools is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with CC/iC Blender Tools. If not, see . import math import bpy, bmesh, mathutils from . import materials, geom, jsonutils, utils, vars def add_vertex_group(obj, name): if name not in obj.vertex_groups: return obj.vertex_groups.new(name = name) else: #group = obj.vertex_groups[name] #clear_vertex_group(obj, group) return obj.vertex_groups[name] def remove_vertex_group(obj : bpy.types.Object, name): if name in obj.vertex_groups: obj.vertex_groups.remove(obj.vertex_groups[name]) def get_vertex_group(obj, names) -> bpy.types.VertexGroup: if type(names) is str: names = [ names ] for name in names: if name in obj.vertex_groups: return obj.vertex_groups[name] return None def clear_vertex_group(obj, vertex_group: bpy.types.VertexGroup): all_verts = [] for v in obj.data.vertices: all_verts.append(v.index) vertex_group.remove(all_verts) def set_vertex_group(obj, vertex_group, value): if type(vertex_group) is str: try: vertex_group = obj.vertex_groups[vertex_group] except: vertex_group = None if vertex_group: all_verts = [] for v in obj.data.vertices: all_verts.append(v.index) vertex_group.add(all_verts, value, 'ADD') def count_vertex_group(obj, vertex_group: bpy.types.VertexGroup): if type(vertex_group) is str or type(vertex_group) is list: vertex_group = get_vertex_group(obj, vertex_group) count = 0 if vertex_group: vg_idx = vertex_group.index for vert in obj.data.vertices: for g in vert.groups: if g.group == vg_idx: count += 1 return count def total_vertex_group_weight(obj, vertex_group: bpy.types.VertexGroup): if type(vertex_group) is str or type(vertex_group) is list: vertex_group = get_vertex_group(obj, vertex_group) weight = 0.0 if vertex_group: vg_idx = vertex_group.index for vert in obj.data.vertices: for g in vert.groups: if g.group == vg_idx: weight += g.weight return weight def is_empty_vertex_group(obj, vertex_group: bpy.types.VertexGroup, threshold = 0.001): if type(vertex_group) is str or type(vertex_group) is list: vertex_group = get_vertex_group(obj, vertex_group) weight = 0.0 if vertex_group: vg_idx = vertex_group.index for vert in obj.data.vertices: for g in vert.groups: if g.group == vg_idx: weight += g.weight break if weight > threshold: return False return True def generate_eye_occlusion_vertex_groups(obj, mat_left, mat_right): vertex_group_inner_l = add_vertex_group(obj, vars.OCCLUSION_GROUP_INNER + "_L") vertex_group_outer_l = add_vertex_group(obj, vars.OCCLUSION_GROUP_OUTER + "_L") vertex_group_top_l = add_vertex_group(obj, vars.OCCLUSION_GROUP_TOP + "_L") vertex_group_bottom_l = add_vertex_group(obj, vars.OCCLUSION_GROUP_BOTTOM + "_L") vertex_group_all_l = add_vertex_group(obj, vars.OCCLUSION_GROUP_ALL + "_L") vertex_group_inner_r = add_vertex_group(obj, vars.OCCLUSION_GROUP_INNER + "_R") vertex_group_outer_r = add_vertex_group(obj, vars.OCCLUSION_GROUP_OUTER + "_R") vertex_group_top_r = add_vertex_group(obj, vars.OCCLUSION_GROUP_TOP + "_R") vertex_group_bottom_r = add_vertex_group(obj, vars.OCCLUSION_GROUP_BOTTOM + "_R") vertex_group_all_r = add_vertex_group(obj, vars.OCCLUSION_GROUP_ALL + "_R") mesh = obj.data ul = mesh.uv_layers[0] index = [0] for poly in mesh.polygons: for loop_index in poly.loop_indices: loop_entry = mesh.loops[loop_index] vertex = mesh.vertices[loop_entry.vertex_index] uv = ul.data[loop_entry.index].uv index[0] = vertex.index slot = obj.material_slots[poly.material_index] if slot.material == mat_left: vertex_group_inner_l.add(index, uv.x, 'REPLACE') vertex_group_outer_l.add(index, 1.0 - uv.x, 'REPLACE') vertex_group_top_l.add(index, uv.y, 'REPLACE') vertex_group_bottom_l.add(index, 1.0 - uv.y, 'REPLACE') vertex_group_all_l.add([vertex.index], 1.0, 'REPLACE') elif slot.material == mat_right: vertex_group_inner_r.add(index, uv.x, 'REPLACE') vertex_group_outer_r.add(index, 1.0 - uv.x, 'REPLACE') vertex_group_top_r.add(index, uv.y, 'REPLACE') vertex_group_bottom_r.add(index, 1.0 - uv.y, 'REPLACE') vertex_group_all_r.add([vertex.index], 1.0, 'REPLACE') def generate_tearline_vertex_groups(obj, mat, is_left=True, is_plus=False): suffix = "_L" if is_left else "_R" vertex_group_inner = add_vertex_group(obj, vars.TEARLINE_GROUP_INNER + suffix) vertex_group_all = add_vertex_group(obj, vars.TEARLINE_GROUP_ALL + suffix) mesh = obj.data ul = mesh.uv_layers[0] for poly in mesh.polygons: slot = obj.material_slots[poly.material_index] if slot.material == mat: for loop_index in poly.loop_indices: loop_entry = mesh.loops[loop_index] vertex = mesh.vertices[loop_entry.vertex_index] uv = ul.data[loop_entry.index].uv if is_plus: if is_left: weight = utils.smoothstep(0.3, 0.0, uv.x) * (1.0 if uv.y < 0.5 else 0.0) else: weight = utils.smoothstep(0.7, 1.0, uv.x) * (1.0 if uv.y > 0.5 else 0.0) else: weight = 1.0 - utils.smoothstep(0, 0.1, abs(uv.x - 0.5)) vertex_group_inner.add([vertex.index], weight, 'REPLACE') vertex_group_all.add([vertex.index], 1.0, 'REPLACE') def rebuild_eye_vertex_groups(chr_cache): if chr_cache: for obj in chr_cache.get_cache_objects(): obj_cache = chr_cache.get_object_cache(obj) if obj and obj_cache and obj_cache.is_eye() and not obj_cache.disabled: mat_left, mat_right = materials.get_left_right_eye_materials(obj) cache_left = chr_cache.get_material_cache(mat_left) cache_right = chr_cache.get_material_cache(mat_right) if cache_left and cache_right: # Re-create the eye displacement group generate_eye_vertex_groups(obj, mat_left, mat_right, cache_left, cache_right) def generate_eye_vertex_groups(obj, mat_left, mat_right, cache_left, cache_right): prefs = vars.prefs() vertex_group_l = add_vertex_group(obj, prefs.eye_displacement_group + "_L") vertex_group_r = add_vertex_group(obj, prefs.eye_displacement_group + "_R") mesh = obj.data ul = mesh.uv_layers[0] for poly in mesh.polygons: for loop_index in poly.loop_indices: loop_entry = mesh.loops[loop_index] vertex = mesh.vertices[loop_entry.vertex_index] uv = ul.data[loop_entry.index].uv x = uv.x - 0.5 y = uv.y - 0.5 radial = math.sqrt(x * x + y * y) slot = obj.material_slots[poly.material_index] if slot.material == mat_left: sclera_scale = cache_left.parameters.eye_sclera_scale iris_radius = cache_left.parameters.eye_iris_radius radius = sclera_scale * (iris_radius / 0.16) * 0.128 #weight = 1.0 - utils.saturate(utils.smoothstep(0, radius, radial)) weight = utils.saturate(utils.remap(0, radius, 1.0, 0.0, radial)) vertex_group_l.add([vertex.index], weight, 'REPLACE') elif slot.material == mat_right: sclera_scale = cache_right.parameters.eye_iris_scale iris_radius = cache_right.parameters.eye_iris_radius radius = sclera_scale * (iris_radius / 0.16) * 0.128 #weight = 1.0 - utils.saturate(utils.smoothstep(0, radius, radial)) weight = utils.saturate(utils.remap(0, radius, 1.0, 0.0, radial)) vertex_group_r.add([vertex.index], weight, 'REPLACE') def get_material_vertex_indices(obj, mat): vert_indices = [] mesh = obj.data for poly in mesh.polygons: poly_mat = obj.material_slots[poly.material_index].material if poly_mat == mat: for vert_index in poly.vertices: if vert_index not in vert_indices: vert_indices.append(vert_index) return vert_indices def get_material_vertices(obj, mat): """Mesh Edit Mode""" verts = [] mesh = obj.data for poly in mesh.polygons: poly_mat = obj.material_slots[poly.material_index].material if poly_mat == mat: for vert_index in poly.vertices: if vert_index not in verts: verts.append(mesh.vertices[vert_index]) return verts def select_material_faces(obj, mat, select = True, deselect_first = False, include_edges = True, include_vertices = True): mesh : bpy.types.Mesh = obj.data poly : bpy.types.MeshPolygon for poly in mesh.polygons: poly_mat = obj.material_slots[poly.material_index].material if deselect_first: poly.select = False if poly_mat == mat: poly.select = select if include_edges: for edge_key in poly.edge_keys: for edge_index in edge_key: edge = mesh.edges[edge_index] if deselect_first: edge.select = False if poly_mat == mat: edge.select = select if include_vertices: for vertex_index in poly.vertices: vertex = mesh.vertices[vertex_index] if deselect_first: vertex.select = False if poly_mat == mat: vertex.select = select def remove_material_verts(obj, mat): mesh = obj.data utils.clear_selected_objects() if utils.edit_mode_to(obj): bpy.ops.mesh.select_all(action="DESELECT") if utils.object_mode_to(obj): for vert in mesh.vertices: vert.select = False for poly in mesh.polygons: poly_mat = obj.material_slots[poly.material_index].material if poly_mat == mat: for vert_index in poly.vertices: mesh.vertices[vert_index].select = True if utils.edit_mode_to(obj): bpy.ops.mesh.delete(type='VERT') utils.object_mode_to(obj) def find_shape_key(obj : bpy.types.Object, shape_key_name) -> bpy.types.ShapeKey: try: return obj.data.shape_keys.key_blocks[shape_key_name] except: return None def objects_have_shape_key(objects, shape_key_name): for obj in objects: if find_shape_key(obj, shape_key_name) is not None: return True return False def get_viseme_profile(objects): for key_name in vars.CC4_VISEME_NAMES: if objects_have_shape_key(objects, key_name): return vars.CC4_VISEME_NAMES for key_name in vars.DIRECT_VISEME_NAMES: if objects_have_shape_key(objects, key_name): return vars.DIRECT_VISEME_NAMES # there is some overlap between CC4 facial expression names and CC3 viseme names # so consider CC3 visemes last return vars.CC3_VISEME_NAMES def get_facial_profile(objects): expressionProfile = "UNKNOWN" visemeProfile = "UNKNOWN" for obj in objects: if obj.type != "MESH": continue if (find_shape_key(obj, "Mouth_Funnel_UL") or find_shape_key(obj, "Mouth_Funnel_UR") or find_shape_key(obj, "Eye_Look_Up_L") or find_shape_key(obj, "Eye_Look_Up_R") or find_shape_key(obj, "Jaw_Clench_L") or find_shape_key(obj, "Jaw_Clench_R")): expressionProfile = "MH" if (find_shape_key(obj, "Move_Jaw_Down") or find_shape_key(obj, "Turn_Jaw_Down") or find_shape_key(obj, "Move_Jaw_Down") or find_shape_key(obj, "Move_Jaw_Down")): expressionProfile = "TRA" if (find_shape_key(obj, "A01_Brow_Inner_Up") or find_shape_key(obj, "A06_Eye_Look_Up_Left") or find_shape_key(obj, "A15_Eye_Blink_Right") or find_shape_key(obj, "A25_Jaw_Open") or find_shape_key(obj, "A37_Mouth_Close")): if (expressionProfile == "UNKNOWN" or expressionProfile == "STD"): expressionProfile = "TRA" if (find_shape_key(obj, "Ear_Up_L") or find_shape_key(obj, "Ear_Up_R") or find_shape_key(obj, "Eyelash_Upper_Up_L") or find_shape_key(obj, "Eyelash_Upper_Up_R") or find_shape_key(obj, "Mouth_Pucker_Up_R") or find_shape_key(obj, "Mouth_Funnel_Up_R")): if (expressionProfile == "UNKNOWN" or expressionProfile == "STD"): expressionProfile = "EXT" if (find_shape_key(obj, "Mouth_L") or find_shape_key(obj, "Mouth_R") or find_shape_key(obj, "Mouth_Pucker") or find_shape_key(obj, "Mouth_Funnel") or find_shape_key(obj, "Eye_L_Look_L") or find_shape_key(obj, "Eye_R_Look_R")): if expressionProfile == "UNKNOWN": expressionProfile = "STD" if (find_shape_key(obj, "V_Open") or find_shape_key(obj, "V_Tight") or find_shape_key(obj, "V_Tongue_up") or find_shape_key(obj, "V_Tongue_Raise")): visemeProfile = "PAIRS4" if (find_shape_key(obj, "Open") or find_shape_key(obj, "Tight") or find_shape_key(obj, "Tongue_up") or find_shape_key(obj, "Tongue_Raise")): if (visemeProfile == "PAIRS4" or visemeProfile == "DIRECT"): visemeProfile = "PAIRS3" if (find_shape_key(obj, "AE") or find_shape_key(obj, "EE") or find_shape_key(obj, "Er") or find_shape_key(obj, "Oh")): if visemeProfile == "UNKNOWN": visemeProfile = "DIRECT" return expressionProfile, visemeProfile def set_facial_profile(objects, facial_profile, viseme_profile): for obj in objects: if obj.type != "MESH": continue if facial_profile != "NONE" and facial_profile != "UNKNOWN": if (find_shape_key(obj, "Move_Jaw_Down") or find_shape_key(obj, "Turn_Jaw_Down") or find_shape_key(obj, "Move_Jaw_Down") or find_shape_key(obj, "Move_Jaw_Down") or find_shape_key(obj, "A01_Brow_Inner_Up") or find_shape_key(obj, "A06_Eye_Look_Up_Left") or find_shape_key(obj, "A15_Eye_Blink_Right") or find_shape_key(obj, "A25_Jaw_Open") or find_shape_key(obj, "A37_Mouth_Close") or find_shape_key(obj, "Ear_Up_L") or find_shape_key(obj, "Ear_Up_R") or find_shape_key(obj, "Eyelash_Upper_Up_L") or find_shape_key(obj, "Eyelash_Upper_Up_R") or find_shape_key(obj, "Eye_L_Look_L") or find_shape_key(obj, "Eye_R_Look_R") or find_shape_key(obj, "Mouth_L") or find_shape_key(obj, "Mouth_R") or find_shape_key(obj, "Eye_Wide_L") or find_shape_key(obj, "Eye_Wide_R") or find_shape_key(obj, "Mouth_Smile") or find_shape_key(obj, "Eye_Blink")): utils.set_prop(obj, "rl_facial_profile", facial_profile) if viseme_profile != "NONE" and viseme_profile != "UNKNOWN": if (find_shape_key(obj, "V_Open") or find_shape_key(obj, "V_Tight") or find_shape_key(obj, "V_Tongue_up") or find_shape_key(obj, "V_Tongue_Raise") or find_shape_key(obj, "Open") or find_shape_key(obj, "Tight") or find_shape_key(obj, "Tongue_up") or find_shape_key(obj, "Tongue_Raise") or find_shape_key(obj, "AE") or find_shape_key(obj, "EE") or find_shape_key(obj, "Er") or find_shape_key(obj, "Oh")): utils.set_prop(obj, "rl_viseme_profile", viseme_profile) def set_shading(obj, smooth=True): if utils.object_exists_is_mesh(obj): for poly in obj.data.polygons: poly.use_smooth = smooth obj.data.update() def get_child_objects_with_vertex_groups(parent, group_names, objects = None): if objects is None: objects = [] for vg in parent.vertex_groups: if vg.name in group_names: objects.append(parent) break for child in parent.children: get_child_objects_with_vertex_groups(child, group_names, objects) return objects def has_vertex_color_data(obj): if obj and obj.type == "MESH": if obj.data.vertex_colors and obj.data.vertex_colors.active: color_map = obj.data.vertex_colors.active for vcol_data in color_map.data: color = vcol_data.color for i in range(0,4): if color[i] > 0.0: return True return False def count_selected_vertices(obj): count = 0 if bpy.context.mode == 'EDIT_MESH': bm = bmesh.from_edit_mesh(obj.data) for v in bm.verts: if v.select: count += 1 else: for v in obj.data.vertices: if v.select: count += 1 return count def separate_mesh_by_material_slots(obj: bpy.types.Object, slot_indices: list): if obj: if slot_indices: if utils.edit_mode_to(obj, only_this=True): bpy.ops.mesh.select_all(action='DESELECT') for slot_index in slot_indices: if len(obj.material_slots) > slot_index: bpy.context.object.active_material_index = slot_index bpy.ops.object.material_slot_select() count = count_selected_vertices(obj) if count > 0 and count < len(obj.data.vertices): bpy.ops.mesh.separate(type="SELECTED") if utils.object_mode(): for o in bpy.context.selected_objects: if o != obj: return o return None def separate_mesh_material_type(chr_cache, obj: bpy.types.Object, material_type: str): if chr_cache and obj: material_slots = [] if utils.object_exists_is_mesh(obj): for slot in obj.material_slots: mat = slot.material if utils.material_exists(mat): mat_cache = chr_cache.get_material_cache(mat) if mat_cache and mat_cache.material_type == material_type: material_slots.append(slot.slot_index) if material_slots: return separate_mesh_by_material_slots(obj, material_slots) return None def get_head_material_and_json(chr_cache, chr_json): head_mat = None head_mat_cache = None head_mat_json = None # find the head material in the character for mat_cache in chr_cache.head_material_cache: mat = mat_cache.material if mat_cache.material_type == "SKIN_HEAD" and utils.material_exists(mat): head_mat = mat head_mat_cache = mat_cache # find the head material json, from it's original json object # the head material may have been split from the original body mesh, # so we look in all the meshes for the head material for obj in chr_cache.get_cache_objects(): obj_cache = chr_cache.get_object_cache(obj) if obj.type == "MESH": if head_mat.name in obj.data.materials: mat_json = jsonutils.get_json(chr_json, f"Meshes/{obj_cache.source_name}/Materials/{head_mat_cache.source_name}") if mat_json and jsonutils.get_json(mat_json, "Custom Shader/Shader Name") == "RLHead": head_mat_json = mat_json break return head_mat, head_mat_json def get_head_body_object_quick(chr_cache): if chr_cache: body_objects = chr_cache.get_objects_of_type("BODY") for obj in body_objects: if "wrinkle_source" in obj: if obj["wrinkle_source"]: return obj return get_head_body_object(chr_cache) return None def get_eye_object(chr_cache): # TODO merged expressions and morphs.... if chr_cache: eyes = chr_cache.get_objects_of_type("EYE") if eyes: return eyes[0] return None def get_tongue_object(chr_cache): # TODO merged expressions and morphs.... if chr_cache: tongues = chr_cache.get_objects_of_type("TONGUE") if tongues: return tongues[0] return None def get_teeth_object(chr_cache): # TODO merged expressions and morphs.... if chr_cache: teeth = chr_cache.get_objects_of_type("TEETH") if teeth: return teeth[0] return None def get_head_body_object(chr_cache): if not chr_cache: return None body_cache = chr_cache.get_body_cache() arm = chr_cache.get_armature() # collect all possible body objects together head_bones = [ "CC_Base_Head", "head", "spine.006" ] body_objects = {} if body_cache: body_id = body_cache.object_id for child in arm.children: if utils.get_rl_object_id(child) == body_id and child not in body_objects: body_objects[child] = total_vertex_group_weight(child, head_bones) else: for child in arm.children: if child not in body_objects: body_objects[child] = total_vertex_group_weight(child, head_bones) # try to find which one contains the head (contains the most weight to head bone) weight = -1 body = None if body_objects: for obj in body_objects: try: del obj["wrinkle_source"] except: ... if body_objects[obj] > weight: weight = body_objects[obj] body = obj # fall back to the imported source body if nothing works if not body: body = chr_cache.get_body() if body: try: body["wrinkle_source"] = True except: ... return body LASH_DATA = None def store_lash_data(chr_cache): global LASH_DATA # copy body body_obj = utils.duplicate_object(get_head_body_object(chr_cache)) head_obj = separate_mesh_material_type(chr_cache, body_obj, "SKIN_HEAD") lash_obj = separate_mesh_material_type(chr_cache, body_obj, "EYELASH") utils.log_always(f"HEAD: {head_obj.name}") utils.log_always(f"LASH: {lash_obj.name}") utils.delete_object(body_obj) mesh = lash_obj.data head_mesh = head_obj.data ul = mesh.uv_layers[0] verts_done = set() verts = {} i = 0 for poly in mesh.polygons: for loop_index in poly.loop_indices: loop_entry = mesh.loops[loop_index] if loop_entry.vertex_index not in verts_done: i += 1 verts_done.add(loop_entry.vertex_index) vertex = mesh.vertices[loop_entry.vertex_index] lash_co = vertex.co lash_uv = ul.data[loop_entry.index].uv uv_id = lash_uv.to_tuple(5) success, closest_local_co, closest_local_no, closest_face_index = head_obj.closest_point_on_mesh(lash_co) head_co = closest_local_co dir: mathutils.Vector = (lash_co - head_co) head_dist = dir.length head_dir = dir.normalized() head_uv = uv_from_point(head_mesh, head_co, closest_face_index) verts[uv_id] = (lash_uv.copy(), head_uv.copy(), head_dist, head_dir) utils.delete_object(lash_obj) utils.delete_object(head_obj) LASH_DATA = verts def restore_lash_data(chr_cache): diag = geom.diag_mesh_create() global LASH_DATA body_obj = get_head_body_object(chr_cache) lash_index = materials.get_material_slot_by_type(chr_cache, body_obj, "EYELASH") head_index = materials.get_material_slot_by_type(chr_cache, body_obj, "SKIN_HEAD") body_tm = geom.get_triangulated_bmesh(body_obj) mesh: bpy.types.Mesh = body_obj.data ul = mesh.uv_layers[0] verts_done = set() poly: bpy.types.MeshPolygon vertex: bpy.types.MeshVertex for poly in mesh.polygons: if poly.material_index == lash_index: for loop_index in poly.loop_indices: loop_entry = mesh.loops[loop_index] if loop_entry.vertex_index not in verts_done: verts_done.add(loop_entry.vertex_index) vertex = mesh.vertices[loop_entry.vertex_index] lash_uv = ul.data[loop_entry.index].uv.copy() uv_id = lash_uv.to_tuple(5) if uv_id in LASH_DATA: old_lash_uv, head_uv, head_dist, head_dir = LASH_DATA[uv_id] head_co = geom.get_local_from_uv(body_obj, body_tm, head_index, head_uv.to_3d(), 0.001) geom.diag_mesh_add_vert(head_co) target_co = head_co + (head_dir * head_dist) vertex.co = target_co.copy() geom.diag_finish() mesh.update() def uv_from_point(mesh: bpy.types.Mesh, co, face_index): ul = mesh.uv_layers[0] poly: bpy.types.MeshPolygon = mesh.polygons[face_index] num_verts = len(poly.loop_indices) num_tris = num_verts - 2 loop0 = mesh.loops[poly.loop_indices[0]] v0 = mesh.vertices[loop0.vertex_index].co uv0 = ul.data[loop0.index].uv.to_3d() for i in range(0, num_tris): j = i + 1 k = i + 2 loopj = mesh.loops[poly.loop_indices[j]] vj = mesh.vertices[loopj.vertex_index].co uvj = ul.data[loopj.index].uv.to_3d() loopk = mesh.loops[poly.loop_indices[k]] vk = mesh.vertices[loopk.vertex_index].co uvk = ul.data[loopk.index].uv.to_3d() uv = mathutils.geometry.barycentric_transform(co, v0, vj, vk, uv0, uvj, uvk) if mathutils.geometry.intersect_point_tri_2d(uv, uv0, uvj, uvk): uv = mathutils.Vector((uv.x, uv.y)) return uv # otherwise return the uv coords of the face vertex nearest to the co d = (v0 - co).length uv = ul.data[loop0.index].uv for i in range(1, num_verts): loopi = mesh.loops[poly.loop_indices[i]] vi = mesh.vertices[loopi.vertex_index].co di = (vi - co).length if di < d: d = di uv = ul.data[loopi.index].uv return uv