# 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 bpy, bmesh import os, math, random from mathutils import Vector from . import springbones, modifiers, geom, utils, jsonutils, bones, meshutils, vars STROKE_JOIN_THRESHOLD = 1.0 / 100.0 # 1cm BONE_SMOOTH_LEVEL_CUSTOM_PROP = "rl_generated_smoothing_level" def begin_hair_sculpt(chr_cache): return def end_hair_sculpt(chr_cache): return def find_obj_cache(chr_cache, obj): if chr_cache and obj and obj.type == "MESH": # try to find directly obj_cache = chr_cache.get_object_cache(obj) if obj_cache: return obj_cache # obj might be part of a split or a copy from original character object # so will have the same name but with duplication suffixes possible = [] source_name = utils.strip_name(obj.name) for obj_cache in chr_cache.object_cache: if obj_cache.is_mesh() and obj_cache.source_name == source_name: possible.append(obj_cache) # if only one possibility return that if possible and len(possible) == 1: return possible[0] # try to find the correct object cache by matching the materials # try matching all the materials first for obj_cache in possible: o = obj_cache.get_object() if o: found = True for mat in obj.data.materials: if mat not in o.data.materials: found = False if found: return obj_cache # then try just matching any for obj_cache in possible: o = obj_cache.get_object() if o: found = True for mat in obj.data.materials: if mat in o.data.materials: return obj_cache return None def clear_particle_systems(obj): if utils.object_mode() and utils.set_only_active_object(obj): for i in range(0, len(obj.particle_systems)): bpy.ops.object.particle_system_remove() return True return False def convert_hair_group_to_particle_systems(obj, curves): if clear_particle_systems(obj): for c in curves: if utils.set_only_active_object(c): bpy.ops.curves.convert_to_particle_system() def export_blender_hair(op, chr_cache, objects, base_path): props = vars.props() prefs = vars.prefs() utils.expand_with_child_objects(objects) folder, name = os.path.split(base_path) file, ext = os.path.splitext(name) parents = [] for obj in objects: if obj.type == "CURVES": if obj.parent: if obj.parent not in parents: parents.append(obj.parent) else: op.report({'ERROR'}, f"Curve: {obj.data.name} has no parent!") json_data = { "Hair": { "Objects": { } } } export_id = 0 for parent in parents: groups = {} obj_cache = find_obj_cache(chr_cache, parent) if obj_cache: parent_name = utils.determine_object_export_name(chr_cache, parent, obj_cache) json_data["Hair"]["Objects"][parent_name] = { "Groups": {} } if props.hair_export_group_by == "CURVE": for obj in objects: if obj.type == "CURVES" and obj.parent == parent: group = [obj] name = obj.data.name groups[name] = group utils.log_info(f"Group: {name}, Object: {obj.data.name}") elif props.hair_export_group_by == "NAME": for obj in objects: if obj.type == "CURVES" and obj.parent == parent: name = utils.strip_name(obj.data.name) if name not in groups.keys(): groups[name] = [] groups[name].append(obj) utils.log_info(f"Group: {name}, Object: {obj.data.name}") else: #props.hair_export_group_by == "NONE": if "Hair" not in groups.keys(): groups["Hair"] = [] for obj in objects: if obj.type == "CURVES" and obj.parent == parent: groups["Hair"].append(obj) utils.log_info(f"Group: Hair, Object: {obj.data.name}") for group_name in groups.keys(): file_name = f"{file}_{export_id}.abc" file_path = os.path.join(folder, file_name) export_id += 1 convert_hair_group_to_particle_systems(parent, groups[group_name]) utils.try_select_objects(groups[group_name], True) utils.set_active_object(parent) json_data["Hair"]["Objects"][parent_name]["Groups"][group_name] = { "File": file_name } bpy.ops.wm.alembic_export( filepath=file_path, check_existing=False, global_scale=100.0, start=1, end=1, use_instancing = False, selected=True, visible_objects_only=True, evaluation_mode = "RENDER", packuv=False, export_hair=True, export_particles=True) clear_particle_systems(parent) else: op.report({'ERROR'}, f"Unable to find source mesh object in character for: {parent.name}!") new_json_path = os.path.join(folder, file + ".json") jsonutils.write_json(json_data, new_json_path) utils.try_select_objects(objects, True) def create_curve(): curve = bpy.data.curves.new("Hair Curve", type="CURVE") curve.dimensions = "3D" obj = bpy.data.objects.new("Hair Curve", curve) bpy.context.collection.objects.link(obj) return curve def create_hair_curves(): curves = bpy.data.hair_curves.new("Hair Curves") obj = bpy.data.objects.new("Hair Curves", curves) bpy.context.collection.objects.link(obj) return curves def add_poly_spline(points, curve): """Create a poly curve from a list of Vectors """ spline : bpy.types.Spline = curve.splines.new("POLY") spline.points.add(len(points) - 1) for i in range(0, len(points)): co = points[i] spline.points[i].co = (co.x, co.y, co.z, 1.0) def card_dir_from_uv_map(card_dirs, uv_map): # analyse uv bounds uv_min, uv_max = geom.get_uv_bounds(uv_map) uv_extent = uv_max - uv_min if abs(uv_extent.x) < 0.0001 or abs(uv_extent.y < 0.0001): return card_dirs["SQUARE"] uv_aspect = uv_extent.x / uv_extent.y # only deal with vertical or horizontal cards # squarish cards are patches of hair that shouldn't be weighted if uv_aspect >= 2.0: uv_orient = "HORIZONTAL" elif uv_aspect <= 0.5: uv_orient = "VERTICAL" else: uv_orient = "SQUARE" return card_dirs[uv_orient] def parse_loop(bm, edge_index, edges_left, loop, edge_map): """Returns a set of vertex indices in the edge loop """ if edge_index in edges_left: edges_left.remove(edge_index) edge = bm.edges[edge_index] loop.add(edge.verts[0].index) loop.add(edge.verts[1].index) if edge.index in edge_map: for ce in edge_map[edge.index]: parse_loop(bm, ce, edges_left, loop, edge_map) def sort_func_u(vert_uv_pair): return vert_uv_pair[-1].x def sort_func_v(vert_uv_pair): return vert_uv_pair[-1].y def sort_verts_by_uv(obj, bm, loop, uv_map, dir): sorted = [] for vert in loop: uv = uv_map[vert] sorted.append([vert, uv]) if dir.x > 0: sorted.sort(reverse=False, key=sort_func_u) elif dir.x < 0: sorted.sort(reverse=True, key=sort_func_u) elif dir.y > 0: sorted.sort(reverse=False, key=sort_func_v) else: sorted.sort(reverse=True, key=sort_func_v) return [ obj.matrix_world @ bm.verts[v].co for v, uv in sorted] def get_ordered_coordinate_loops(obj, bm, edges, dir, uv_map, edge_map): edges_left = set(edges) loops = [] # separate edges into vertex loops while len(edges_left) > 0: loop = set() edge_index = list(edges_left)[0] parse_loop(bm, edge_index, edges_left, loop, edge_map) sorted = sort_verts_by_uv(obj, bm, loop, uv_map, dir) loops.append(sorted) return loops def get_vert_loops(obj, bm, edges, edge_map): edges_left = set(edges) vert_loops = [] # separate edges into vertex loops while len(edges_left) > 0: loop = set() edge_index = list(edges_left)[0] parse_loop(bm, edge_index, edges_left, loop, edge_map) verts = [ index for index in loop] vert_loops.append(verts) return vert_loops def merge_length_coordinate_loops(loops): size = len(loops[0]) for merged in loops: if len(merged) != size: return None num = len(loops) merged = [] for i in range(0, size): co = Vector((0,0,0)) for l in range(0, num): co += loops[l][i] co /= num merged.append(co) return merged def sort_lateral_card(obj, bm, loops, uv_map, dir): sorted = [] card = {} for loop in loops: co = Vector((0,0,0)) uv = Vector((0,0)) count = 0 for vert_index in loop: co += obj.matrix_world @ bm.verts[vert_index].co uv += uv_map[vert_index] count += 1 co /= count uv /= count sorted.append([co, loop, uv]) if dir.x > 0: sorted.sort(reverse=False, key=sort_func_u) elif dir.x < 0: sorted.sort(reverse=True, key=sort_func_u) elif dir.y > 0: sorted.sort(reverse=False, key=sort_func_v) else: sorted.sort(reverse=True, key=sort_func_v) card["median"] = [ co for co, loop, uv in sorted ] card["loops"] = [ loop for co, loop, uv in sorted ] return card def grid_to_loops(obj, bm, island, card_dirs, one_loop_per_card): props = vars.props() # each island has a unique UV map uv_map = geom.get_uv_island_map(bm, 0, island) card_dir = card_dir_from_uv_map(card_dirs, uv_map) # get all edges aligned with the card dir in the island edges = geom.get_uv_aligned_edges(bm, island, card_dir, uv_map, dir_threshold=props.hair_card_dir_threshold) utils.log_info(f"{len(edges)} aligned edges.") # map connected edges edge_map = geom.get_linked_edge_map(bm, edges) # separate into ordered vertex loops loops = get_ordered_coordinate_loops(obj, bm, edges, card_dir, uv_map, edge_map) utils.log_info(f"{len(loops)} ordered loops.") # (merge and) generate poly curves if one_loop_per_card: loop = merge_length_coordinate_loops(loops) if loop: return [loop] else: utils.log_info("Loops have differing lengths, grid extraction failed.") return None else: return loops return True def get_vert_loop_to(obj, bm, from_index, to_index, edges, reverse = False): verts = [] co_loop = [] following = False for edge_index in edges: if edge_index == from_index: following = True if edge_index == to_index: following = False if following: edge = bm.edges[edge_index] for vert in edge.verts: if vert.index not in verts: verts.append(vert.index) if reverse: co_loop.insert(0, obj.matrix_world @ vert.co) else: co_loop.append(obj.matrix_world @ vert.co) return verts, co_loop def sort_boundary_edges(bm, edges : set, start_index): edge = bm.edges[start_index] vert = edge.verts[1] edge_loop = [start_index] edges.remove(start_index) following = True while following: following = False for next_edge in vert.link_edges: if next_edge != edge and next_edge.index in edges: edge_loop.append(next_edge.index) edges.remove(next_edge.index) for next_vert in next_edge.verts: if next_vert.index != vert.index: edge = next_edge vert = next_vert following = True break if following: break return edge_loop def split_boundary_loops(obj, bm, boundary_edges, card_dir, uv_map): edge : bmesh.types.BMEdge min_proj = math.inf max_proj = -math.inf list_edges = list(boundary_edges) min_edge_index = list_edges[0] max_edge_index = list_edges[-1] for edge_index in boundary_edges: edge = bm.edges[edge_index] vert = edge.verts[0] uv = uv_map[vert.index] proj = -card_dir.dot(uv) if proj < min_proj: min_proj = proj min_edge_index = edge.index if proj > max_proj: max_proj = proj max_edge_index = edge.index # sort the boundary edges in order, starting from max_edge (the top most UV edge) num_edges = len(boundary_edges) edge_loop = sort_boundary_edges(bm, boundary_edges, max_edge_index) # return nothing if the sorted boundary edge does not contain all the edges # (i.e. there are breaks in the edges) if len(edge_loop) != num_edges: utils.log_info(f"Unable to sort boundary edges: {len(edge_loop)} != {num_edges}") return None, None # extract the left and right coordinate loops left_verts, left_coords = get_vert_loop_to(obj, bm, max_edge_index, min_edge_index, edge_loop) right_verts, right_coords = get_vert_loop_to(obj, bm, min_edge_index, max_edge_index, edge_loop, reverse=True) # need to reverse the order of one of these... but which one return left_coords, right_coords def get_projection_on_loop(loop, co): p0 = loop[0] min_distance = math.inf projected_point = p0 projected_length = 0.0 length = 0.0 for i in range(1, len(loop)): p1 = loop[i] segment_length = (p1 - p0).length dist, fac = distance_from_line(co, p0, p1) if dist < min_distance: min_distance = dist projected_point = p0 * (1.0 - fac) + p1 * fac projected_length = length + segment_length * fac length += segment_length p0 = p1 return projected_point, projected_length def proj_loop_sort_func(co_len_pair): return co_len_pair[1] def project_boundary_loop(src_loop, dst_loop): """Projects the source loop onto the destination loop.""" sort_points = [] # add the original points & lengths for i in range(0, len(dst_loop)): sort_points.append([dst_loop[i], loop_length(dst_loop, i)]) # add the projected points & lengths for co in src_loop: projected_point, projected_length = get_projection_on_loop(dst_loop, co) sort_points.append([projected_point, projected_length]) # sort by length sort_points.sort(key=proj_loop_sort_func) # return the coordinate loop loop = [ pair[0] for pair in sort_points ] return loop def mesh_to_loops(obj, bm, island, card_dirs): props = vars.props() # each island has a unique UV map uv_map = geom.get_uv_island_map(bm, 0, island) card_dir = card_dir_from_uv_map(card_dirs, uv_map) # find the boundary edges boundary_edges = geom.get_boundary_edges(bm, island) # check for minimum bound edges if len(boundary_edges) < 4: return None # the top most UV in the boundary edge is the start of the left hand side # the bottom most UV in the boundary edge is the end of the right hand side # split the boundary edge into two loops left and right left_loop, right_loop = split_boundary_loops(obj, bm, boundary_edges, card_dir, uv_map) if left_loop and right_loop: # project each vertex in loop_left into loop_right, order by projected length projected_right_loop = project_boundary_loop(left_loop, right_loop) # project each vertex in loop_right into loop_left, order by projected length projected_left_loop = project_boundary_loop(right_loop, left_loop) # the two loops should now be the same length and each index in the loops represents # a point in one loop and/or it's projection in the other # now average the loops into one loop representing the mesh hair card loop = merge_length_coordinate_loops([projected_left_loop, projected_right_loop]) if loop: return [loop] utils.log_info("Loops have differing lengths or breaks, mesh extraction failed.") return None def selected_cards_to_length_loops(chr_cache, obj, card_dirs, one_loop_per_card = True, card_selection_mode = "SELECTED"): prefs = vars.prefs() props = vars.props() # select linked and set to edge mode utils.edit_mode_to(obj, only_this=True) if card_selection_mode == "ALL": bpy.ops.mesh.select_all(action='SELECT') else: bpy.ops.mesh.select_linked(delimit={'UV'}) bpy.ops.mesh.select_mode(use_extend=False, use_expand=False, type='FACE') # object mode to save edit changes utils.object_mode_to(obj) deselect_invalid_materials(chr_cache, obj) # get the bmesh mesh = obj.data bm = geom.get_bmesh(mesh) # get lists of the faces in each selected island islands = geom.get_uv_islands(bm, 0, use_selected=True) utils.log_info(f"{len(islands)} islands selected.") all_loops = [] cards = [] for island in islands: utils.log_info(f"Processing island, faces: {len(island)}") utils.log_indent() is_grid = geom.is_island_grid(bm, island) loops = None if is_grid: loops = grid_to_loops(obj, bm, island, card_dirs, one_loop_per_card) if not loops: is_grid = False loops = mesh_to_loops(obj, bm, island, card_dirs) if is_grid: utils.log_info("Grid") else: utils.log_info("Polymesh") face : bmesh.types.BMFace verts = set() for face_index in island: face = bm.faces[face_index] for vert in face.verts: verts.add(vert.index) card = { "verts": verts, "loops": loops } cards.append(card) utils.log_recess() return cards, bm def debug_loop(loop): curve = create_curve() add_poly_spline(loop, curve) def selected_cards_to_curves(chr_cache, obj, card_dirs, one_loop_per_card = True): curve = create_curve() cards, bm = selected_cards_to_length_loops(chr_cache, obj, card_dirs, one_loop_per_card) for card in cards: loops = card["loops"] for loop in loops: add_poly_spline(loop, curve) # TODO # Put the curve object to the same scale as the body mesh # With roots above the scalp plant the root of the curves into the scalp? (within tolerance) # or add an new root point on the scalp... # With roots below the scalp, crop the loop # convert to curves # set curve render subdivision to at least 2 # snap curves to surface def loop_length(loop, index = -1): if index == -1: index = len(loop) - 1 p0 = loop[0] d = 0 for i in range(1, index + 1): p1 = loop[i] d += (p1 - p0).length p0 = p1 return d def eval_loop_at(loop, length, fac): p0 = loop[0] f0 = 0 for i in range(1, len(loop)): p1 = loop[i] v = p1 - p0 fl = v.length / length f1 = f0 + fl if fl > 0 and fac <= f1 and fac >= f0: df = fac - f0 return p0 + v * (df / fl) f0 = f1 p0 = p1 f1 += fl return p0 def is_on_loop(co, loop, threshold = 0.001): """Is the coordinate on the loop. (All coordintes should be in world space)""" p0 = loop[0] min_distance = threshold + 1.0 for i in range(1, len(loop)): p1 = loop[i] dist, fac = distance_from_line(co, p0, p1) if dist < min_distance: min_distance = dist if min_distance < threshold: return True p0 = p1 return min_distance < threshold def clear_hair_bone_weights(chr_cache, arm, objects, card_mode, bone_mode, parent_mode): utils.object_mode_to(arm) bone_chain_defs = get_bone_chain_defs(chr_cache, arm, bone_mode, parent_mode) hair_bones = [] for bone_chain in bone_chain_defs: for bone_def in bone_chain: hair_bones.append(bone_def["name"]) if not objects: objects = meshutils.get_child_objects_with_vertex_groups(arm, hair_bones) for obj in objects: remove_hair_bone_weights(obj, hair_bones, card_mode) arm.data.pose_position = "POSE" utils.pose_mode_to(arm) def remove_hair_bones(chr_cache, arm, bone_mode, parent_mode): utils.object_mode_to(arm) hair_bones = [] bone_chain_defs = get_bone_chain_defs(chr_cache, arm, bone_mode, parent_mode) for bone_chain in bone_chain_defs: for bone_def in bone_chain: hair_bones.append(bone_def["name"]) # remove the bones in edit mode if hair_bones and utils.edit_mode_to(arm, True): for bone_name in hair_bones: arm.data.edit_bones.remove(arm.data.edit_bones[bone_name]) # remove the spring rig if there are no child bones left if utils.edit_mode_to(arm): spring_rig = springbones.get_spring_rig(chr_cache, arm, parent_mode, mode = "EDIT") if spring_rig and not spring_rig.children: arm.data.edit_bones.remove(spring_rig) #use all mesh objects in the character with matching vertex groups objects = meshutils.get_child_objects_with_vertex_groups(arm, hair_bones) #remove the weights from the character meshes for obj in objects: remove_hair_bone_weights(obj, hair_bones, "ALL") utils.object_mode_to(arm) def rename_hair_bones(chr_cache, arm, base_name, parent_mode): utils.object_mode_to(arm) bone_remap = {} bone_chain_defs = None hair_bones = [] bone_chain_defs = get_bone_chain_defs(chr_cache, arm, "SELECTED", parent_mode) for bone_chain in bone_chain_defs: for bone_def in bone_chain: hair_bones.append(bone_def["name"]) utils.edit_mode_to(arm) loop_index = 1 for bone_chain in bone_chain_defs: loop_index = find_unused_hair_bone_index(arm, loop_index, base_name) chain_index = 0 for bone_def in bone_chain: old_name = bone_def["name"] new_name = f"{base_name}_{loop_index}_{chain_index}" edit_bone : bpy.types.EditBone edit_bone = arm.data.edit_bones[old_name] edit_bone.name = new_name edit_bone.select = True bone_remap[old_name] = edit_bone.name chain_index += 1 utils.object_mode_to(arm) #if no objects selected, use all mesh objects in the character with matching vertex groups objects = meshutils.get_child_objects_with_vertex_groups(arm, hair_bones) # now rename the vertex groups in all the objects... for obj in objects: for vg in obj.vertex_groups: if vg.name in bone_remap: vg.name = bone_remap[vg.name] def contains_hair_bone_chain(arm, loop_index, prefix): """Edit mode""" for bone in arm.data.edit_bones: if bone.name.startswith(f"{prefix}_{loop_index}_"): return True return False def find_unused_hair_bone_index(arm, loop_index, prefix): """Edit mode""" while contains_hair_bone_chain(arm, loop_index, prefix): loop_index += 1 return loop_index def is_nearby_bone(arm, world_pos): """Edit mode""" for edit_bone in arm.data.edit_bones: length = (world_pos - arm.matrix_world @ edit_bone.head).length if length < 0.01: return True return False def custom_bone(chr_cache, arm, parent_mode, loop_index, bone_length, new_bones): """Must be in edit mode on the armature.""" props = vars.props() hair_rig = springbones.get_spring_rig(chr_cache, arm, parent_mode, create_if_missing=True, mode = "EDIT") hair_bone_prefix = props.hair_rig_group_name if hair_rig: parent_bone = hair_rig bone_name = f"{hair_bone_prefix}_{loop_index}_0" edit_bone : bpy.types.EditBone = bones.new_edit_bone(arm, bone_name, parent_bone.name) new_bones.append(bone_name) edit_bone.select = True edit_bone.select_head = True edit_bone.select_tail = True world_origin = arm.matrix_world @ hair_rig.head world_pos = world_origin + Vector((0, 0.05, 0.15)) while is_nearby_bone(arm, world_pos): world_pos += Vector((0, 0.0175, 0)) world_head = world_pos world_tail = world_pos + Vector((0, 0, bone_length)) edit_bone.head = arm.matrix_world.inverted() @ world_head edit_bone.tail = arm.matrix_world.inverted() @ world_tail bone_z = (((world_head + world_tail) * 0.5) - world_origin).normalized() edit_bone.align_roll(bone_z) # set bone layer to 25, so we can show only the added hair bones 'in front' bones.set_bone_collection(arm, edit_bone, "Spring (Edit)", None, vars.SPRING_EDIT_LAYER) # don't directly connect first bone in a chain edit_bone.use_connect = False return True return False def get_linked_bones(edit_bone, bone_list): if edit_bone.name not in bone_list: bone_list.append(edit_bone.name) for child_bone in edit_bone.children: get_linked_bones(child_bone, bone_list) return bone_list def bone_chains_match(arm, bone_list_a, bone_list_b, tolerance = 0.001): tolerance /= ((arm.scale[0] + arm.scale[1] + arm.scale[2]) / 3.0) for bone_name_a in bone_list_a: edit_bone_a = arm.data.edit_bones[bone_name_a] has_match = False for bone_name_b in bone_list_b: edit_bone_b = arm.data.edit_bones[bone_name_b] delta = (edit_bone_a.head - edit_bone_b.head).length + (edit_bone_a.tail - edit_bone_b.tail).length if (delta < tolerance): has_match = True if not has_match: return False return True def bone_chain_matches_loop(arm, bone_list, loop, threshold = 0.001): for bone_name in bone_list: if bone_name in arm.data.edit_bones: edit_bone = arm.data.edit_bones[bone_name] if not is_on_loop(arm.matrix_world @ edit_bone.head, loop, threshold): return False if not is_on_loop(arm.matrix_world @ edit_bone.tail, loop, threshold): return False else: return False return True def remove_existing_loop_bones(chr_cache, arm, smoothed_loops): """Removes any bone chains in the hair rig that align with the loops""" props = vars.props() bone_selection_mode = props.hair_rig_bind_bone_mode if bone_selection_mode == "SELECTED": # select all linked bones utils.edit_mode_to(arm) bpy.ops.armature.select_linked() utils.object_mode_to(arm) utils.edit_mode_to(arm) hair_rigs = springbones.get_spring_rigs(chr_cache, arm, ["HEAD", "JAW"], mode="EDIT") remove_bone_list = [] remove_loop_set_list = [] removed_roots = [] for parent_mode in hair_rigs: hair_rig = hair_rigs[parent_mode]["bone"] if hair_rig: for chain_root in hair_rig.children: chain_root: bpy.types.EditBone if chain_root not in removed_roots: chain_bones = get_linked_bones(chain_root, []) for smoothed_loop_set in smoothed_loops: bone_smooth_level = 0 if BONE_SMOOTH_LEVEL_CUSTOM_PROP in chain_root: bone_smooth_level = chain_root[BONE_SMOOTH_LEVEL_CUSTOM_PROP] bone_smooth_loop = smoothed_loop_set[bone_smooth_level] # compare the bone chain with the loop at it's generated smoothing level if bone_chain_matches_loop(arm, chain_bones, bone_smooth_loop, 0.001): remove_bones = False remove_loop = False if bone_selection_mode == "SELECTED": if bones.get_bone_selected(arm, chain_root): # if the chain is selected, then it is to be replaced, so remove it. remove_bones = True else: # otherwise remove the loop, so it won't generate new bones over the existing bones. remove_loop = True else: remove_bones = True if remove_bones: utils.log_info(f"Existing bone chain starting: {chain_root.name} is to be re-generated.") remove_bone_list.extend(chain_bones) removed_roots.append(chain_root) if remove_loop: utils.log_info(f"Existing bone chain starting: {chain_root.name} will not be replaced.") remove_loop_set_list.append(smoothed_loop_set) if remove_bone_list: for bone_name in remove_bone_list: if bone_name in arm.data.edit_bones: utils.log_info(f"Removing bone on generating loop: {bone_name}") arm.data.edit_bones.remove(arm.data.edit_bones[bone_name]) else: utils.log_info(f"Already deleted: {bone_name} ?") if remove_loop_set_list: for smoothed_loop_set in remove_loop_set_list: if smoothed_loop_set in smoothed_loops: smoothed_loops.remove(smoothed_loop_set) utils.log_info(f"Removing loop from generation list") return def remove_duplicate_bones(chr_cache, arm): """Remove any duplicate bone chains""" remove_list = [] removed_roots = [] utils.edit_mode_to(arm) hair_rigs = springbones.get_spring_rigs(chr_cache, arm, ["HEAD", "JAW"], mode = "EDIT") for parent_mode in hair_rigs: hair_rig = hair_rigs[parent_mode]["bone"] if hair_rig: for chain_root in hair_rig.children: if chain_root not in removed_roots: chain_bones = get_linked_bones(chain_root, []) for i in range(len(hair_rig.children)-1, 0, -1): test_chain_root = hair_rig.children[i] if test_chain_root not in removed_roots: test_chain_bones = get_linked_bones(test_chain_root, []) if chain_root == test_chain_root: break if bone_chains_match(arm, test_chain_bones, chain_bones, 0.001): remove_list.extend(test_chain_bones) removed_roots.append(test_chain_root) if remove_list: for bone_name in remove_list: if bone_name in arm.data.edit_bones: utils.log_info(f"Removing duplicate bone: {bone_name}") arm.data.edit_bones.remove(arm.data.edit_bones[bone_name]) else: utils.log_info(f"Already deleted: {bone_name} ?") # object mode to save changes to edit bones utils.object_mode_to(arm) return def loop_to_bones(chr_cache, arm, parent_mode, loop, loop_index, bone_length, skip_length, trunc_length, smooth_level, new_bones): """Generate hair rig bones from vertex loops. Must be in edit mode on armature.""" props = vars.props() if len(loop) < 2: return False length = loop_length(loop) # maximum skip length of half the length skip_length = min(skip_length, length / 2.0) # maximum trunc length of half the remaining length trunc_length = min(trunc_length, (length - skip_length) / 2.0) # skip zero length loops if length < 0.001: return False segments = max(1, round((length - skip_length - trunc_length) / bone_length)) fac = skip_length / length max_fac = (length - trunc_length) / length df = (max_fac - fac) / segments chain = [] first = True hair_rig = springbones.get_spring_rig(chr_cache, arm, parent_mode, create_if_missing=True, mode = "EDIT") hair_bone_prefix = props.hair_rig_group_name if hair_rig: parent_bone = hair_rig for s in range(0, segments): bone_name = f"{hair_bone_prefix}_{loop_index}_{s}" edit_bone : bpy.types.EditBone = bones.new_edit_bone(arm, bone_name, parent_bone.name) edit_bone[BONE_SMOOTH_LEVEL_CUSTOM_PROP] = smooth_level new_bones.append(bone_name) edit_bone.select = True edit_bone.select_head = True edit_bone.select_tail = True world_head = eval_loop_at(loop, length, fac) world_tail = eval_loop_at(loop, length, fac + df) edit_bone.head = arm.matrix_world.inverted() @ world_head edit_bone.tail = arm.matrix_world.inverted() @ world_tail world_origin = arm.matrix_world @ hair_rig.head bone_z = (((world_head + world_tail) * 0.5) - world_origin).normalized() edit_bone.align_roll(bone_z) parent_bone = edit_bone # set bone layer to 25, so we can show only the added hair bones 'in front' bones.set_bone_collection(arm, edit_bone, "Spring (Edit)", None, vars.SPRING_EDIT_LAYER) chain.append(bone_name) if first: edit_bone.use_connect = False first = False else: edit_bone.use_connect = True first = False fac += df return True return False def get_smoothed_loops_set(loops): smoothed_loops_set = [] for loop in loops: smoothed_loops_set.append(generate_smoothed_loop_levels(loop)) return smoothed_loops_set def selected_cards_to_bones(chr_cache, arm, obj, parent_mode, card_dirs, one_loop_per_card = True, bone_length = 0.075, skip_length = 0.075, trunc_length = 0.0, smooth_level = 0): """Lengths in world space units (m).""" props = vars.props() mode_selection = utils.store_mode_selection_state() arm_pose = set_rest_pose(arm) springbones.realign_spring_bones_axis(chr_cache, arm) springbones.show_spring_bone_edit_layer(chr_cache, arm, True) hair_bone_prefix = props.hair_rig_group_name # check anchor bone exists... anchor_bone_name = springbones.get_spring_anchor_name(chr_cache, arm, parent_mode) anchor_bone = bones.get_pose_bone(arm, anchor_bone_name) if anchor_bone: cards, bm = selected_cards_to_length_loops(chr_cache, obj, card_dirs, one_loop_per_card) utils.edit_mode_to(arm) smoothed_loops_set = [] for card in cards: loops = card["loops"] if loops: card_smoothed_loops_set = get_smoothed_loops_set(loops) smoothed_loops_set.extend(card_smoothed_loops_set) remove_existing_loop_bones(chr_cache, arm, smoothed_loops_set) for edit_bone in arm.data.edit_bones: edit_bone.select_head = False edit_bone.select_tail = False edit_bone.select = False loop_index = 1 new_bones = [] for smoothed_loop in smoothed_loops_set: loop = smoothed_loop[smooth_level] loop_index = find_unused_hair_bone_index(arm, loop_index, hair_bone_prefix) if loop_to_bones(chr_cache, arm, parent_mode, loop, loop_index, bone_length, skip_length, trunc_length, smooth_level, new_bones): loop_index += 1 remove_duplicate_bones(chr_cache, arm) utils.object_mode_to(arm) restore_pose(arm, arm_pose) utils.restore_mode_selection_state(mode_selection) utils.try_select_object(arm) def get_hair_cards_lateral(chr_cache, obj, card_dirs, card_selection_mode): prefs = vars.prefs() props = vars.props() # select linked and set to edge mode utils.edit_mode_to(obj, only_this=True) if card_selection_mode == "ALL": bpy.ops.mesh.select_all(action='SELECT') else: bpy.ops.mesh.select_linked(delimit={'UV'}) bpy.ops.mesh.select_mode(use_extend=False, use_expand=False, type='FACE') # object mode to save edit changes utils.object_mode_to(obj) deselect_invalid_materials(chr_cache, obj) # get the bmesh mesh = obj.data bm = geom.get_bmesh(mesh) # get lists of the faces in each selected island islands = geom.get_uv_islands(bm, 0, use_selected=True) utils.log_info(f"{len(islands)} islands selected.") cards = [] for island in islands: utils.log_info(f"Processing island, faces: {len(island)}") utils.log_indent() # each island has a unique UV map uv_map = geom.get_uv_island_map(bm, 0, island) card_dir = card_dir_from_uv_map(card_dirs, uv_map) # get all edges NOT aligned with the card dir in the island, i.e. the lateral edges edges = geom.get_uv_aligned_edges(bm, island, card_dir, uv_map, get_non_aligned=True, dir_threshold=props.hair_card_dir_threshold) utils.log_info(f"{len(edges)} non-aligned edges.") # map connected edges edge_map = geom.get_linked_edge_map(bm, edges) # separate into lateral vertex loops vert_loops = get_vert_loops(obj, bm, edges, edge_map) utils.log_info(f"{len(vert_loops)} lateral loops.") # generate hair card info # a median coordinate loop representing the median positions of the hair card card = sort_lateral_card(obj, bm, vert_loops, uv_map, card_dir) cards.append(card) utils.log_recess() return bm, cards def distance_from_line(co, start, end): """Returns the distance from the line and where along the line it is closest.""" line = end - start dir = line.normalized() length = line.length from_start : Vector = co - start from_end : Vector = co - end if line.dot(from_start) <= 0: return (co - start).length, 0.0 elif line.dot(from_end) >= 0: return (co - end).length, 1.0 else: return (line.cross(from_start) / length).length, min(1.0, max(0.0, dir.dot(from_start) / length)) def get_distance_to_bone_def(bone_def, co : Vector): #bone_def = { "name": pose_bone.name, "head": head, "tail": tail, "line": line, "dir": dir } head : Vector = bone_def["head"] tail : Vector = bone_def["tail"] return distance_from_line(co, head, tail) def get_closest_bone_def(bone_chain, co, max_radius): least_distance = max_radius * 2.0 least_bone_def = bone_chain[0] least_fac = 0 for bone_def in bone_chain: d, f = get_distance_to_bone_def(bone_def, co) if d < least_distance: least_distance = d least_bone_def = bone_def least_fac = f return least_bone_def, least_distance, least_fac def get_weighted_bone_distance(bone_chain, max_radius, median_loop, median_length): weighted_distance = 0 co_length = 0 last_co = median_loop[0] for co in median_loop: co_length += (co - last_co).length factor = co_length / median_length bone_def, distance, fac = get_closest_bone_def(bone_chain, co, max_radius) weighted_distance += distance * factor * 2.0 return weighted_distance / len(median_loop) def weight_card_to_bones(obj, bm : bmesh.types.BMesh, card, sorted_bones, max_radius, max_bones, max_weight, curve, variance): props = vars.props() CC4_SPRING_RIG = props.hair_rig_target == "CC4" bm.verts.layers.deform.verify() # vertex weights are in the deform layer of the BMesh verts dl = bm.verts.layers.deform.active card_loop = card["loops"][0] card_loop_length = loop_length(card_loop) if len(sorted_bones) < max_bones: max_bones = len(sorted_bones) min_weight = 0.01 if CC4_SPRING_RIG else 0.0 acc_root_weight = (1.0 - max_weight) / max_bones bone_weight_variance_mods = [] for i in range(0, max_bones): bone_weight_variance_mods.append(random.uniform(max_weight * (1 - variance), max_weight)) first_bone_groups = [] if CC4_SPRING_RIG: for b in range(0, max_bones): bone_chain = sorted_bones[b]["bones"] bone_def = bone_chain[0] bone_name = bone_def["name"] vg = meshutils.add_vertex_group(obj, bone_name) first_bone_groups.append(vg) for vert_index in card["verts"]: vertex : bmesh.types.BMVert = bm.verts[vert_index] if vertex.is_valid: co = obj.matrix_world @ vertex.co proj_point, proj_length = get_projection_on_loop(card_loop, co) card_length_fac = math.pow(proj_length / card_loop_length, curve) for b in range(0, max_bones): bone_chain = sorted_bones[b]["bones"] bone_def, bone_distance, bone_fac = get_closest_bone_def(bone_chain, co, max_radius) weight_distance = min(max_radius, max(0, max_radius - bone_distance)) weight = bone_weight_variance_mods[b] * (weight_distance / max_radius) / max_bones # bone_fac is used to scale the weights on the very first bone in the chain, from 0 to 1 # (unless it's for a CC4 accessory) if CC4_SPRING_RIG: bone_fac = 1.0 elif bone_def != bone_chain[0]: bone_fac = 1.0 weight *= max(0, min(bone_fac, card_length_fac)) weight = max(min_weight, weight) bone_name = bone_def["name"] vg = meshutils.add_vertex_group(obj, bone_name) if vg: vertex[dl][vg.index] = weight # if the weight's are scaled back, they need to be scaled back # against the root bone's weights, unless this is for the root bone # in which case we need to add the root weight if CC4_SPRING_RIG: first_vg = first_bone_groups[b] if vg.index != first_vg.index: vertex[dl][first_vg.index] = acc_root_weight else: vertex[dl][first_vg.index] = weight + acc_root_weight def sort_func_weighted_distance(bone_weight_distance): return bone_weight_distance["distance"] def assign_bones(obj, bm, cards, bone_chains, max_radius, max_bones, max_weight, curve, variance): for i, card in enumerate(cards): loops = card["loops"] if loops: card_loop = loops[0] card_loop_length = loop_length(card_loop) sorted_bones = [] for bone_chain in bone_chains: weighted_distance = get_weighted_bone_distance(bone_chain, max_radius, card_loop, card_loop_length) bone_weight_distance = { "distance": weighted_distance, "bones": bone_chain } sorted_bones.append(bone_weight_distance) sorted_bones.sort(reverse=False, key=sort_func_weighted_distance) weight_card_to_bones(obj, bm, card, sorted_bones, max_radius, max_bones, max_weight, curve, variance) def remove_hair_bone_weights(obj, hair_bone_list, card_mode): """Remove vertex groups for the given bones""" if card_mode == "ALL": # remove all hair_bone_list vertex groups from object utils.object_mode_to(obj) vg : bpy.types.VertexGroup for vg in obj.vertex_groups: if vg.name in hair_bone_list: meshutils.remove_vertex_group(obj, vg.name) utils.edit_mode_to(obj) utils.object_mode_to(obj) else: # select linked and set to edge mode utils.edit_mode_to(obj, only_this=True) bpy.ops.mesh.select_linked(delimit={'UV'}) bpy.ops.mesh.select_mode(use_extend=False, use_expand=False, type='VERT') # object mode to save edit changes utils.object_mode_to(obj) # remove weights from selected verts geom.remove_vertex_groups_from_selected(obj, hair_bone_list) def reset_weights_to_bones(obj, bone_names, card_mode): if card_mode == "ALL": utils.object_mode_to(obj) while obj.vertex_groups: obj.vertex_groups.remove(obj.vertex_groups[0]) for bone_name in bone_names: meshutils.add_vertex_group(obj, bone_name) meshutils.set_vertex_group(obj, bone_name, 1.0 / len(bone_names)) utils.edit_mode_to(obj) utils.object_mode_to(obj) else: # select linked and set to edge mode utils.edit_mode_to(obj, only_this=True) bpy.ops.mesh.select_linked(delimit={'UV'}) bpy.ops.mesh.select_mode(use_extend=False, use_expand=False, type='VERT') # object mode to save edit changes utils.object_mode_to(obj) # remove weights from selected verts bone_list = [ vg.name for vg in obj.vertex_groups if vg.name != bone_names ] geom.remove_vertex_groups_from_selected(obj, bone_list) geom.add_vertex_groups_to_selected(obj, bone_names, 1.0 / len(bone_names)) def scale_existing_weights(obj, bm, scale, exclude_bone_names: list = None): bm.verts.ensure_lookup_table() bm.verts.layers.deform.verify() dl = bm.verts.layers.deform.active min_weight = 1.0 max_weight = 0.0 for vert in bm.verts: weight = 0 for vg in obj.vertex_groups: if exclude_bone_names and vg.name in exclude_bone_names: continue if vg.index in vert[dl].keys(): weight += vert[dl][vg.index] min_weight = min(weight, min_weight) max_weight = max(weight, max_weight) if max_weight < 0.00001: # nothing left to scale return normalizing_scale = 1.0 / max_weight for vg in obj.vertex_groups: if exclude_bone_names and vg.name in exclude_bone_names: continue for vert in bm.verts: if vg.index in vert[dl].keys(): vert[dl][vg.index] *= normalizing_scale * scale def add_bone_chain_def(arm, edit_bone : bpy.types.EditBone, chain): if edit_bone.children and len(edit_bone.children) > 1: return False head = arm.matrix_world @ edit_bone.head tail = arm.matrix_world @ edit_bone.tail line = tail - head dir = line.normalized() # extend the last bone def in the chain to ensure full overlap with hair mesh if not edit_bone.children: line *= 4 tail = head + line bone_def = { "name": edit_bone.name, "head": head, "tail": tail, "line": line, "dir": dir, "length": line.length } chain.append(bone_def) if edit_bone.children and len(edit_bone.children) == 1: return add_bone_chain_def(arm, edit_bone.children[0], chain) return True def get_bone_chain_defs(chr_cache, arm, bone_selection_mode, parent_mode): """Get each bone chain from the spring bone rig.""" utils.edit_mode_to(arm) if bone_selection_mode == "SELECTED": # select all linked bones utils.edit_mode_to(arm) bpy.ops.armature.select_linked() utils.object_mode_to(arm) utils.edit_mode_to(arm) # NOTE: remember edit bones do not survive mode changes... bone_chains = [] hair_rig = springbones.get_spring_rig(chr_cache, arm, parent_mode, mode = "EDIT") if hair_rig: for child_bone in hair_rig.children: if bones.get_bone_selected(arm, child_bone.name) or bone_selection_mode == "ALL": chain = [] if not add_bone_chain_def(arm, child_bone, chain): continue bone_chains.append(chain) utils.object_mode_to(arm) return bone_chains def add_child_spring_bone_names(bone, names): for child in bone.children: names.append(child.name) add_child_spring_bone_names(child, names) def get_all_spring_bone_names(chr_cache, arm): bone_names = [] spring_rigs = springbones.get_spring_rigs(chr_cache, arm) for parent_mode in spring_rigs: spring_rig_def = spring_rigs[parent_mode] spring_root = spring_rig_def["bone"] add_child_spring_bone_names(spring_root, bone_names) return bone_names def smooth_hair_bone_weights(arm, obj, bone_chains, iterations): props = vars.props() if iterations == 0: return bones.select_all_bones(arm, False) # select all the bones involved for bone_chain in bone_chains: for bone_def in bone_chain: bone_name = bone_def["name"] if bone_name in arm.data.bones: bones.select_bone(arm, bone_name, True) # Note: BONE_SELECT group select mode is only available if the armature is also selected with the active mesh # (otherwise it doesn't even exist as an enum option) utils.object_mode() utils.try_select_objects([arm, obj], True) utils.set_active_object(obj) utils.set_mode("WEIGHT_PAINT") try: bpy.ops.object.vertex_group_smooth(group_select_mode='BONE_SELECT', factor = 1.0, repeat = iterations) except: utils.log_error("Unable to smooth spring bone vertex groups: No armature modifier on hair mesh?") utils.object_mode_to(obj) # for CC4 rigs, lock rotation and position of the first bone in each chain for bone_chain in bone_chains: bone_def = bone_chain[0] bone_name = bone_def["name"] if bone_name in arm.data.bones: bone : bpy.types.Bone = arm.data.bones[bone_name] pose_bone : bpy.types.PoseBone = arm.pose.bones[bone_name] if props.hair_rig_target == "CC4": pose_bone.lock_location = [True, True, True] pose_bone.lock_rotation = [True, True, True] pose_bone.lock_scale = [True, True, True] pose_bone.lock_rotation_w = True else: pose_bone.lock_location = [False, False, False] pose_bone.lock_rotation = [False, False, False] pose_bone.lock_rotation_w = False pose_bone.lock_scale = [False, False, False] def find_stroke_set_root(stroke_set, stroke, done : list): done.append(stroke) next_strokes, prev_strokes = stroke_set[stroke] if not prev_strokes: return stroke elif prev_strokes not in done: return find_stroke_set_root(stroke_set, prev_strokes[0], done) else: return None def combine_strokes(strokes): stroke_set = {} for stroke in strokes: # if the last position is near the first position of another stroke... first = stroke.points[0].co last = stroke.points[-1].co next_strokes = [] prev_strokes = [] stroke_set[stroke] = [next_strokes, prev_strokes] for s in strokes: if s != stroke: if (s.points[0].co - last).length < STROKE_JOIN_THRESHOLD: next_strokes.append(s) if (s.points[-1].co - first).length < STROKE_JOIN_THRESHOLD: prev_strokes.append(s) stroke_roots = set() for stroke in strokes: root = find_stroke_set_root(stroke_set, stroke, []) if root: stroke_roots.add(root) return stroke_set, stroke_roots def stroke_root_to_loop(stroke_set, stroke, loop : list): next_strokes, prev_strokes = stroke_set[stroke] for p in stroke.points: loop.append(p.co) if next_strokes: stroke_root_to_loop(stroke_set, next_strokes[0], loop) def subdivide_loop(loop): subd = [] for i in range(0, len(loop) - 1): l0 = loop[i] l2 = loop[i + 1] l1 = (l0 + l2) * 0.5 subd.append(l0) subd.append(l1) subd.append(loop[-1]) loop.clear() for co in subd: loop.append(co) def generate_smoothed_loop_levels(loop, strength = 1.0, max_iterations = 10): """Returns a dictionary { iteration_level: smoothed_loop, ... } of loops smoothed by iteration level (0 to max_iterations+1)""" smoothed_levels = {} for i in range(0, max_iterations + 1): smooth_level = loop.copy() smoothed_levels[i] = smooth_level if i > 0: for l in range(1, len(loop)-1): smoothed = (loop[l - 1] + loop[l] + loop[l + 1]) / 3.0 original = loop[l] smooth_level[l] = (smoothed - original) * strength + original loop = smooth_level return smoothed_levels def grease_pencil_to_length_loops(bone_length): current_frame = bpy.context.scene.frame_current grease_pencil_layer = get_active_grease_pencil_layer() if not grease_pencil_layer: return frame = grease_pencil_layer.active_frame stroke_set, stroke_roots = combine_strokes(frame.strokes) loops = [] for root in stroke_roots: loop = [] stroke_root_to_loop(stroke_set, root, loop) if len(loop) > 1 and loop_length(loop) >= bone_length / 2: while(len(loop) < 25): subdivide_loop(loop) loops.append(loop) return loops def grease_pencil_to_bones(chr_cache, arm, parent_mode, bone_length = 0.05, skip_length = 0.0, trunc_length = 0.0, smooth_level = 0): props = vars.props() grease_pencil_layer = get_active_grease_pencil_layer() if not grease_pencil_layer: return # turn off grease pencil on current object / mode (including object mode) # (this is expected to be object mode on a hair mesh) tool_idname = utils.get_current_tool_idname(bpy.context) if "builtin.annotate" in tool_idname: bpy.ops.wm.tool_set_by_id(name="builtin.select_box") mode = utils.get_mode() if mode != "OBJECT": utils.object_mode() bpy.ops.wm.tool_set_by_id(name="builtin.select_box") #mode_selection = utils.store_mode_selection_state() arm_pose = set_rest_pose(arm) springbones.realign_spring_bones_axis(chr_cache, arm) springbones.show_spring_bone_edit_layer(chr_cache, arm, True) hair_bone_prefix = props.hair_rig_group_name # check root bone exists... anchor_bone_name = springbones.get_spring_anchor_name(chr_cache, arm, parent_mode) anchor_bone = bones.get_pose_bone(arm, anchor_bone_name) if anchor_bone: loops = grease_pencil_to_length_loops(bone_length) utils.edit_mode_to(arm) smoothed_loops_set = get_smoothed_loops_set(loops) remove_existing_loop_bones(chr_cache, arm, smoothed_loops_set) for edit_bone in arm.data.edit_bones: edit_bone.select_head = False edit_bone.select_tail = False edit_bone.select = False loop_index = 1 new_bones = [] for smoothed_loop in smoothed_loops_set: loop = smoothed_loop[smooth_level] loop_index = find_unused_hair_bone_index(arm, loop_index, hair_bone_prefix) if loop_to_bones(chr_cache, arm, parent_mode, loop, loop_index, bone_length, skip_length, trunc_length, smooth_level, new_bones): loop_index += 1 remove_duplicate_bones(chr_cache, arm) utils.object_mode_to(arm) # turn OFF grease pencil on armature : object mode bpy.ops.wm.tool_set_by_id(name="builtin.select_box") restore_pose(arm, arm_pose) #utils.restore_mode_selection_state(mode_selection) utils.edit_mode_to(arm) # turn ON grease pencil on armature : edit mode # (hopefully at this point grease pencil will only be on the armature in edit mode) bpy.ops.wm.tool_set_by_id(name="builtin.annotate") def get_active_grease_pencil_layer(): #current_frame = bpy.context.scene.frame_current #note_layer = bpy.data.grease_pencils['Annotations'].layers.active #frame = note_layer.active_frame try: layer_index = bpy.context.scene.grease_pencil.layers.active_index layer = bpy.context.scene.grease_pencil.layers[layer_index] return layer except: try: return bpy.context.scene.grease_pencil.layers.active except: return None def clear_grease_pencil(): active_layer = get_active_grease_pencil_layer() if active_layer: active_frame = active_layer.active_frame try: active_layer.frames.remove(active_frame) except: try: active_layer.active_frame.clear() except: utils.log_error("Unable to remove active grease pencil frame!") def add_custom_bone(chr_cache, arm, parent_mode, bone_length = 0.05, skip_length = 0.0): props = vars.props() arm_pose = set_rest_pose(arm) springbones.realign_spring_bones_axis(chr_cache, arm) springbones.show_spring_bone_edit_layer(chr_cache, arm, True) hair_bone_prefix = props.hair_rig_group_name # check root bone exists... anchor_bone_name = springbones.get_spring_anchor_name(chr_cache, arm, parent_mode) anchor_bone = bones.get_pose_bone(arm, anchor_bone_name) if anchor_bone: utils.edit_mode_to(arm) bones.select_all_bones(arm, False) loop_index = 1 new_bones = [] loop_index = find_unused_hair_bone_index(arm, loop_index, hair_bone_prefix) if custom_bone(chr_cache, arm, parent_mode, loop_index, bone_length, new_bones): loop_index += 1 utils.object_mode_to(arm) restore_pose(arm, arm_pose) remove_duplicate_bones(chr_cache, arm) utils.edit_mode_to(arm) def bind_to_first_bones(chr_cache, arm, objects, card_mode, parent_mode): bone_chains = get_bone_chain_defs(chr_cache, arm, "ALL", parent_mode) first_bones = [] for chain in bone_chains: if chain: first_bones.append(chain[0]["name"]) for obj in objects: reset_weights_to_bones(obj, first_bones, card_mode) def bind_cards_to_bones(chr_cache, arm, objects, card_dirs, max_radius, max_bones, max_weight, curve, variance, existing_scale, card_mode, bone_mode, smoothing, parent_mode): utils.object_mode_to(arm) set_rest_pose(arm) remove_duplicate_bones(chr_cache, arm) springbones.realign_spring_bones_axis(chr_cache, arm) bone_chain_defs = get_bone_chain_defs(chr_cache, arm, bone_mode, parent_mode) all_spring_bone_names = get_all_spring_bone_names(chr_cache, arm) if bone_chain_defs: hair_bones = [] for bone_chain in bone_chain_defs: for bone_def in bone_chain: hair_bones.append(bone_def["name"]) # if no meshes selected, get a list of all hair objects if not objects: objects = [] chr_objects = chr_cache.get_cache_objects() for obj in chr_objects: obj_cache = chr_cache.get_object_cache(obj) if obj_cache and not obj_cache.disabled and obj not in objects: for mat in obj.data.materials: mat_cache = chr_cache.get_material_cache(mat) if mat_cache and mat_cache.material_type == "HAIR": objects.append(obj) break for obj in objects: # ensure an armature modifier with this armature (otherwise weight smooth fails) arm_mod = modifiers.get_armature_modifier(obj, create=True, armature=arm) # remove_hair_bone_weights(obj, hair_bones, card_mode) cards, bm = selected_cards_to_length_loops(chr_cache, obj, card_dirs, one_loop_per_card=True, card_selection_mode=card_mode) scale_existing_weights(obj, bm, existing_scale, all_spring_bone_names) assign_bones(obj, bm, cards, bone_chain_defs, max_radius, max_bones, max_weight, curve, variance) bm.to_mesh(obj.data) geom.clean_empty_vertex_groups(obj, bm) smooth_hair_bone_weights(arm, obj, bone_chain_defs, smoothing) else: utils.log_error("No bones selected!") arm.data.pose_position = "POSE" utils.pose_mode_to(arm) def deselect_invalid_materials(chr_cache, obj): """Mesh polygon selection only works in OBJECT mode""" if utils.object_exists_is_mesh(obj): for slot in obj.material_slots: mat = slot.material if mat is None: continue mat_cache = chr_cache.get_material_cache(mat) if mat_cache: if mat_cache.material_type == "SCALP": meshutils.select_material_faces(obj, mat, False) def set_rest_pose(arm): arm_pose = arm.data.pose_position arm.data.pose_position = "REST" return arm_pose def restore_pose(arm, arm_pose): arm.data.pose_position = arm_pose class CC3OperatorHair(bpy.types.Operator): """Hair Spring Rigging""" bl_idname = "cc3.hair" bl_label = "Hair Spring Rigging" #bl_options = {"REGISTER", "UNDO", "INTERNAL"} param: bpy.props.StringProperty( name = "param", default = "" ) def execute(self, context): props = vars.props() prefs = vars.prefs() mode_selection = utils.store_mode_selection_state() chr_cache = props.get_context_character_cache(context) if not chr_cache: self.report({"ERROR"}, "No current character!") return {"FINISHED"} arm = chr_cache.get_armature() hair_mesh = utils.get_selected_mesh() if self.param == "CARDS_TO_CURVES": if hair_mesh: selected_cards_to_curves(chr_cache, utils.get_active_object(), props.hair_dir_vectors(), one_loop_per_card = props.hair_curve_merge_loops == "MERGE") if self.param == "ADD_BONES": if arm and hair_mesh: utils.unhide(arm) selected_cards_to_bones(chr_cache, arm, hair_mesh, props.hair_rig_bone_root, props.hair_dir_vectors(), one_loop_per_card = True, bone_length = props.hair_rig_bone_length / 100.0, skip_length = props.hair_rig_bind_skip_length / 100.0, trunc_length = props.hair_rig_bind_trunc_length / 100.0, smooth_level = props.hair_rig_bone_smoothing) else: self.report({"ERROR"}, "Active Object must be a mesh!") if self.param == "ADD_BONES_GREASE": if arm: utils.unhide(arm) grease_pencil_to_bones(chr_cache, arm, props.hair_rig_bone_root, bone_length = props.hair_rig_bone_length / 100.0, skip_length = props.hair_rig_bind_skip_length / 100.0, trunc_length = props.hair_rig_bind_trunc_length / 100.0, smooth_level = props.hair_rig_bone_smoothing) else: self.report({"ERROR"}, "Active Object be part of the character!") if self.param == "ADD_BONES_CUSTOM": if arm: utils.unhide(arm) add_custom_bone(chr_cache, arm, props.hair_rig_bone_root, bone_length = props.hair_rig_bone_length / 100.0) else: self.report({"ERROR"}, "Active Object be part of the character!") if self.param == "REMOVE_HAIR_BONES": if arm: utils.unhide(arm) remove_hair_bones(chr_cache, arm, props.hair_rig_bind_bone_mode, props.hair_rig_bone_root) utils.restore_mode_selection_state(mode_selection) if self.param == "RESET_ACCESSORY_WEIGHTS": objects = utils.get_selected_meshes(context) if arm and objects: bind_to_first_bones(chr_cache, arm, objects, props.hair_rig_bind_card_mode, props.hair_rig_bone_root) if self.param == "BIND_TO_BONES": objects = utils.get_selected_meshes(context) seed = props.hair_rig_bind_seed random.seed(seed) existing_scale = props.hair_rig_bind_existing_scale if props.hair_rig_target != "CC4" else 0.0 if arm and objects: utils.unhide(arm) bind_cards_to_bones(chr_cache, arm, objects, props.hair_dir_vectors(), props.hair_rig_bind_bone_radius / 100.0, props.hair_rig_bind_bone_count, props.hair_rig_bind_bone_weight, props.hair_rig_bind_weight_curve, props.hair_rig_bind_bone_variance, existing_scale, props.hair_rig_bind_card_mode, props.hair_rig_bind_bone_mode, props.hair_rig_bind_smoothing, props.hair_rig_bone_root) else: self.report({"ERROR"}, "Selected Object(s) to bind must be Meshes!") if self.param == "CLEAR_WEIGHTS": objects = utils.get_selected_meshes(context) if arm: utils.unhide(arm) clear_hair_bone_weights(chr_cache, arm, objects, props.hair_rig_bind_card_mode, props.hair_rig_bind_bone_mode, props.hair_rig_bone_root) utils.restore_mode_selection_state(mode_selection) if self.param == "CLEAR_GREASE_PENCIL": clear_grease_pencil() if self.param == "MAKE_ACCESSORY": objects = utils.get_selected_meshes(context) if arm and objects: utils.unhide(arm) accessory_name = springbones.convert_spring_rig_to_accessory(chr_cache, arm, objects, props.hair_rig_bone_root) if accessory_name: self.report({'INFO'}, f"Accesssory: {accessory_name} created!") if self.param == "GROUP_NAME_BONES": group_name = props.hair_rig_group_name parent_mode = props.hair_rig_bone_root objects = utils.get_selected_meshes(context) if arm: utils.unhide(arm) rename_hair_bones(chr_cache, arm, group_name, parent_mode) utils.restore_mode_selection_state(mode_selection) if self.param == "SPRING_BONES_TOGGLE": if arm: springbones.show_spring_bone_edit_layer(chr_cache, arm, False) if self.param == "SPRING_BONES_SHOW": if arm: springbones.show_spring_bone_edit_layer(chr_cache, arm, True) if self.param == "ARMATURE_SHOW_POSE": if arm: arm.data.pose_position = "POSE" if self.param == "ARMATURE_SHOW_REST": if arm: arm.data.pose_position = "REST" if self.param == "CYCLE_BONE_STYLE": if arm: if arm.data.display_type == 'WIRE': arm.data.display_type = 'OCTAHEDRAL' arm.display_type = 'SOLID' elif arm.data.display_type == 'OCTAHEDRAL' and arm.display_type == 'SOLID': arm.data.display_type = 'OCTAHEDRAL' arm.display_type = 'WIRE' elif arm.data.display_type == 'OCTAHEDRAL' and arm.display_type == 'WIRE': arm.data.display_type = 'STICK' arm.display_type = 'SOLID' elif arm.data.display_type == 'STICK': arm.data.display_type = 'WIRE' arm.display_type = 'SOLID' else: arm.data.display_type = 'OCTAHEDRAL' arm.display_type = 'SOLID' if self.param == "TOGGLE_GREASE_PENCIL": tool_idname = utils.get_current_tool_idname(context) if "builtin.annotate" in tool_idname: mode = utils.get_mode() if mode != "OBJECT": utils.object_mode() bpy.ops.wm.tool_set_by_id(name="builtin.select_box") utils.set_mode(mode) bpy.ops.wm.tool_set_by_id(name="builtin.select_box") if arm: arm.data.pose_position = "POSE" else: mode = utils.get_mode() if mode != "OBJECT": utils.object_mode() bpy.ops.wm.tool_set_by_id(name="builtin.annotate") utils.set_mode(mode) bpy.ops.wm.tool_set_by_id(name="builtin.annotate") bpy.context.scene.tool_settings.annotation_stroke_placement_view3d = 'SURFACE' try: props = bpy.context.workspace.tools["builtin.annotate"].operator_properties("gpencil.annotate") props.use_stabilizer = True except: pass # only use rest position to draw grease pencil on surface of hair if arm: arm.data.pose_position = "REST" return {"FINISHED"} @classmethod def description(cls, context, properties): props = vars.props() if properties.param == "ADD_BONES": return "Add bones to the hair rig, generated from the selected hair cards in the active mesh" elif properties.param == "ADD_BONES_CUSTOM": return "Add a single custom bone to the hair rig" elif properties.param == "ADD_BONES_GREASE": return "Add bones generated from grease pencil lines drawn in the current annotation layer.\n\n" \ "Note: For best results draw lines onto the hair in Surface placement mode." elif properties.param == "REMOVE_HAIR_BONES": if props.hair_rig_bind_bone_mode == "ALL": return "Remove all bones from the hair rig.\n\n" \ "All associated vertex weights will also be removed from the hair meshes" else: return "Remove only the selected bones from the hair rig.\n\n" \ "The vertex weights for the removed bones will also be removed from the hair meshes\n\n" \ "Note: Selecting any bone in a chain will use the entire chain of bones" elif properties.param == "BIND_TO_BONES": if props.hair_rig_bind_card_mode == "ALL": if props.hair_rig_bind_bone_mode == "ALL": return "Bind the selected hair meshes to all of the hair rig bones.\n\n" \ "If no meshes are selected then *all* meshes in the character will be considered" else: return "Bind the selected hair meshes to only the selected hair rig bones.\n\n" \ "If no meshes are selected then *all* meshes in the character will be considered.\n\n" \ "Note: Selecting any bone in a chain will use the entire chain of bones" else: if props.hair_rig_bind_bone_mode == "ALL": return "Bind only the selected hair cards in the selected hair meshes to all of the hair rig bones.\n\n" \ "If no meshes are selected then *all* meshes in the character will be considered.\n\n" \ "Note: Selecting any part of a hair card will use the entire card island" else: return "Bind only the selected hair cards in the selected hair meshes to only the selected hair rig bones.\n\n" \ "If no meshes are selected then *all* meshes in the character will be considered.\n\n" \ "Note: Selecting any bone in a chain will use the entire chain of bones and selecting any part of a hair card will select the whole har card island" elif properties.param == "CLEAR_WEIGHTS": if props.hair_rig_bind_card_mode == "ALL": if props.hair_rig_bind_bone_mode == "ALL": return "Clear all the hair rig bone vertex weights from the selected hair meshes.\n\n" \ "If no meshes are selected then *all* meshes in the character will be considered" else: return "Clear only the selected hair rig bone vertex weights from the selected hair meshes.\n\n" \ "If no meshes are selected then *all* meshes in the character will be considered.\n\n" \ "Note: Selecting any bone in a chain will use the entire chain of bones" else: if props.hair_rig_bind_bone_mode == "ALL": return "Clear all the hair rig bone vertex weights from only the selected hair cards in the selected meshes.\n\n" \ "If no meshes are selected then *all* meshes in the character will be considered.\n\n" \ "Note: Selecting any part of a hair card will select the whole har card island" else: return "Clear only the selected hair rig bone vertex weights from only the selected hair cards in the selected meshes.\n\n" \ "If no meshes are selected then *all* meshes in the character will be considered.\n\n" \ "Note: Selecting any bone in a chain will use the entire chain of bones and selecting any part of a hair card will select the whole har card island" elif properties.param == "CLEAR_GREASE_PENCIL": return "Remove all grease pencil lines from the current annotation layer" elif properties.param == "CARDS_TO_CURVES": return "Convert all the hair cards into curves" elif properties.param == "RESET_ACCESSORY_WEIGHTS": return "Resets the weights on the mesh to the base weights needed by an accesssory, i.e. weighted only to the first bone in each bone chain.\n" \ "Useful for setting a base weighting for further selective mesh binding" elif properties.param == "MAKE_ACCESSORY": return "Converts the hair spring rig and selected objects into an accessory named after the active object.\n" \ "Accessories are categorized by:\n" \ " 1. A bone representing the accessory parented to a CC Base bone.\n" \ " 2. Child accessory deformation bone(s) parented to the accessory bone in 1.\n" \ " 3. Object(s) with vertex weights ONLY to these accessory deformation bones in 2.\n" \ " 4. All vertices in the accessory must be weighted" elif properties.param == "GROUP_NAME_BONES": return "Rename the bones in the selected chain so they all belong to the same group name" elif properties.param == "TOGGLE_GREASE_PENCIL": return "Quick toggle grease pencil mode with surface draw and stabilze stroke" elif properties.param == "CYCLE_BONE_STYLE": return "Cycle through armature bone styles" return "" class CC3ExportHair(bpy.types.Operator): """Export Hair Curves""" bl_idname = "cc3.export_hair" bl_label = "Export Hair" bl_options = {"REGISTER"} filepath: bpy.props.StringProperty( name="File Path", description="Base filepath used for exporting the hair curves", maxlen=1024, subtype='FILE_PATH', ) filename_ext = "" # ExportHelper mixin class uses this #filter_glob: bpy.props.StringProperty( # default="*.fbx;*.obj;*.blend", # options={"HIDDEN"}, # ) def execute(self, context): props = vars.props() prefs = vars.prefs() objects = bpy.context.selected_objects.copy() chr_cache = props.get_character_cache_from_objects(objects, True) export_blender_hair(self, chr_cache, objects, self.filepath) return {"FINISHED"} def invoke(self, context, event): context.window_manager.fileselect_add(self) return {"RUNNING_MODAL"} @classmethod def description(cls, context, properties): return "Export the hair curves to Alembic."