import bpy import mathutils import queue import importlib import os # import time # for debugging performance # then import dependencies for our addon if "Font" in locals(): importlib.reload(Font) else: from .common import Font if "utils" in locals(): importlib.reload(utils) else: from .common import utils execution_queue = queue.Queue() # This function can safely be called in another thread. # The function will be executed when the timer runs the next time. def run_in_main_thread(function): execution_queue.put(function) def execute_queued_functions(): while not execution_queue.empty(): function = execution_queue.get() function() return 1.0 def apply_all_transforms(obj): mb = obj.matrix_basis if hasattr(obj.data, "transform"): obj.data.transform(mb) for c in obj.children: c.matrix_local = mb @ c.matrix_local obj.matrix_basis.identity() def get_parent_collection_names(collection, parent_names): for parent_collection in bpy.data.collections: if collection.name in parent_collection.children.keys(): parent_names.append(parent_collection.name) get_parent_collection_names(parent_collection, parent_names) return # Ensure it's a curve object # TODO: no raising, please def get_curve_length(curve_obj, resolution = -1): total_length = 0 curve = curve_obj.data # Loop through all splines in the curve for spline in curve.splines: total_length = total_length + spline.calc_length(resolution=resolution) return total_length def get_curve_line_lengths(curve_obj, resolution = -1): lengths = [] for spline in curve_obj.data.splines: lengths.append(spline.calc_length(resolution=resolution)) return lengths def get_next_line_advance(curve_obj, current_advance, previous_glyph_advance, resolution = -1): curve_line_lengths = get_curve_line_lengths(curve_obj, resolution) total_length = 0 for cll in curve_line_lengths: total_length += cll if current_advance - previous_glyph_advance < total_length: return total_length return current_advance def calc_point_on_bezier(bezier_point_1, bezier_point_2, t): p1 = bezier_point_1.co h1 = bezier_point_1.handle_right p2 = bezier_point_2.co h2 = bezier_point_2.handle_left return ((1 - t)**3) * p1 + (3 * t * (1 - t)**2) * h1 + (3 * (t**2) * (1 - t)) * h2 + (t**3) * p2 # same in slightly more lines # result is equal, performance minimally better perhaps? # def calc_point_on_bezier(bezier_point_1, bezier_point_2, ratio): # startPoint = bezier_point_1.co # controlPoint1 = bezier_point_1.handle_right # controlPoint2 = bezier_point_2.handle_left # endPoint = bezier_point_2.co # remainder = 1 - ratio # ratioSquared = ratio * ratio # remainderSquared = remainder * remainder # startPointMultiplier = remainderSquared * remainder # controlPoint1Multiplier = remainderSquared * ratio * 3 # controlPoint2Multiplier = ratioSquared * remainder * 3 # endPointMultiplier = ratioSquared * ratio # return startPoint * startPointMultiplier + controlPoint1 * controlPoint1Multiplier + controlPoint2 * controlPoint2Multiplier + endPoint * endPointMultiplier def calc_tangent_on_bezier(bezier_point_1, bezier_point_2, t): p1 = bezier_point_1.co h1 = bezier_point_1.handle_right p2 = bezier_point_2.co h2 = bezier_point_2.handle_left return ( (-3 * (1 - t)**2) * p1 + (-6 * t * (1 - t) + 3 * (1 - t)**2) * h1 + (-3 * (t**2) + 6 * t * (1 - t)) * h2 + (3 * t**2) * p2 ).normalized() from math import radians, sqrt, pi, acos def align_rotations_auto_pivot(mask, input_rotations, vectors, factors, local_main_axis): output_rotations = [mathutils.Matrix().to_3x3() for _ in range(len(input_rotations))] for i in mask: vector = mathutils.Vector(vectors[i]).normalized() input_rotation = mathutils.Euler(input_rotations[i]) if vector.length < 1e-6: output_rotations[i] = input_rotation.to_matrix() continue old_rotation = input_rotation.to_matrix() old_axis = (old_rotation @ local_main_axis).normalized() new_axis = vector # rotation_axis = (-(old_axis) + new_axis).normalized() rotation_axis = old_axis.cross(new_axis).normalized() if rotation_axis.length < 1e-6: # Vectors are linearly dependent, fallback to another axis rotation_axis = (old_axis + mathutils.Matrix().col[2]).normalized() if rotation_axis.length < 1e-6: # This is now guaranteed to not be zero rotation_axis = (-(old_axis) + mathutils.Matrix().col[1]).normalized() # full_angle = radians(sqrt((4 * pow(input_rotation.to_quaternion().dot(mathutils.Quaternion(vectors[i].normalized())), 2) - 3))) # dot = old_axis.dot(new_axis) # normalized_diff = (old_axis - new_axis).normalized() # full_angle = acos(min((old_axis * new_axis + normalized_diff.dot(2)).length, 1)) full_angle = old_axis.angle(new_axis) angle = factors[i] * full_angle rotation = mathutils.Quaternion(rotation_axis, angle).to_matrix() new_rotation_matrix = old_rotation @ rotation output_rotations[i] = new_rotation_matrix return [mat.to_4x4() for mat in output_rotations] def calc_bezier_length(bezier_point_1, bezier_point_2, resolution=20): step = 1/resolution previous_p = bezier_point_1.co length = 0 for i in range(-1, resolution): t = (i + 1) * step p = calc_point_on_bezier(bezier_point_1, bezier_point_2, t) length += (p - previous_p).length previous_p = p return length def calc_point_on_bezier_spline(bezier_spline_obj, distance, output_tangent = False, resolution_factor = 1.0): # what's the point of just one point # assert len(bezier_spline_obj.bezier_points) >= 2 # however, maybe let's have it not crash and do this if len(bezier_spline_obj.bezier_points) < 1: print("butils::calc_point_on_bezier_spline: whoops, no points. panicking. return 0,0,0") if output_tangent: return mathutils.Vector((0,0,0)), mathutils.Vector((1,0,0)) else: return mathutils.Vector((0,0,0)) if len(bezier_spline_obj.bezier_points) == 1: p = bezier_spline_obj.bezier_points[0] travel = (p.handle_left - p.co).normalized() * distance if output_tangent: tangent = mathutils.Vector((1,0,0)) return travel, tangent else: return travel if distance <= 0: p = bezier_spline_obj.bezier_points[0] travel = (p.co - p.handle_left).normalized() * distance location = p.co + travel if output_tangent: p2 = bezier_spline_obj.bezier_points[1] tangent = calc_tangent_on_bezier(p, p2, 0) return location, tangent else: return location beziers = [] lengths = [] total_length = 0 n_bezier_points = len(bezier_spline_obj.bezier_points) for i in range(0, len(bezier_spline_obj.bezier_points) - 1): bezier = [ bezier_spline_obj.bezier_points[i], bezier_spline_obj.bezier_points[i + 1] ] length = calc_bezier_length(bezier[0], bezier[1], int(bezier_spline_obj.resolution_u * resolution_factor)) total_length += length beziers.append(bezier) lengths.append(length) # if total_length > distance: # break iterated_distance = 0 for i in range(0, len(beziers)): if iterated_distance + lengths[i] > distance: distance_on_bezier = (distance - iterated_distance) d = distance_on_bezier / lengths[i] # print(f"i: {i}, d: {d}, distance_on_bezier: {distance_on_bezier}, distance: {distance}") location = calc_point_on_bezier(beziers[i][0], beziers[i][1], d) if output_tangent: tangent = calc_tangent_on_bezier(beziers[i][0], beziers[i][1], d) return location, tangent else: return location iterated_distance += lengths[i] # if we are here, the point is outside the spline last_i = len(beziers) - 1 p = beziers[last_i][1] travel = (p.handle_right - p.co).normalized() * (distance - total_length) location = p.co + travel if output_tangent: tangent = calc_tangent_on_bezier(beziers[last_i][0], p, 1) return location, tangent else: return location def calc_point_on_bezier_curve(bezier_curve_obj, distance, output_tangent = False, resolution_factor = 1.0): curve = bezier_curve_obj.data # Loop through all splines in the curve total_length = 0 for i, spline in enumerate(curve.splines): resolution = int(spline.resolution_u * resolution_factor) length = spline.calc_length(resolution=resolution) if total_length + length > distance or i == len(curve.splines) - 1: return calc_point_on_bezier_spline(spline, (distance - total_length), output_tangent, resolution_factor) total_length += length # TODO: can this fail? # def get_objects_by_name(name, startswith="", endswith=""): # return [obj for obj in bpy.context.scene.objects if obj.name.startswith(startswith) and if obj.name.endswith(endswith)] def find_objects_by_name( objects, equals="", contains="", startswith="", endswith=""): # handle equals if equals != "": index = objects.find(equals) if index >= 0: return [objects[index]] return [] # handle others is more permissive return [obj for obj in objects if obj.name.startswith(startswith) and obj.name.endswith(endswith) and obj.name.find(contains) >= 0] def find_objects_by_custom_property( objects, property_name="", property_value=""): return [obj for obj in objects if property_name in obj and obj[property_name] == property_value] def turn_collection_hierarchy_into_path(obj): parent_collection = obj.users_collection[0] parent_names = [] parent_names.append(parent_collection.name) get_parent_collection_names(parent_collection, parent_names) parent_names.reverse() return '\\'.join(parent_names) def find_font_object(fontcollection, font_name): fonts = find_objects_by_custom_property(fontcollection.objects, "is_font", True) for font in fonts: if font["font_name"] == font_name and font.parent == None: return font return None def find_font_face_object(font_obj, face_name): faces = find_objects_by_custom_property(font_obj.children, "is_face", True) for face in faces: if face["face_name"] == face_name: return face return None def move_in_fontcollection(obj, fontcollection, allow_duplicates=False): # parent nesting structure # the font object font_obj = find_font_object(fontcollection, obj["font_name"]) if font_obj == None: font_obj = bpy.data.objects.new(obj["font_name"], None) font_obj.empty_display_type = 'PLAIN_AXES' fontcollection.objects.link(font_obj) # ensure custom properties are set font_obj["font_name"] = obj["font_name"] font_obj["is_font"] = True # the face object as a child of font object face_obj = find_font_face_object(font_obj, obj["face_name"]) if face_obj == None: face_obj = bpy.data.objects.new(obj["face_name"], None) face_obj.empty_display_type = 'PLAIN_AXES' face_obj["is_face"] = True fontcollection.objects.link(face_obj) # ensure custom properties are set face_obj["face_name"] = obj["face_name"] face_obj["font_name"] = obj["font_name"] if face_obj.parent != font_obj: face_obj.parent = font_obj # create glyphs if it does not exist glyphs_objs = find_objects_by_name(face_obj.children, startswith="glyphs") if len(glyphs_objs) <= 0: glyphs_obj = bpy.data.objects.new("glyphs", None) glyphs_obj.empty_display_type = 'PLAIN_AXES' fontcollection.objects.link(glyphs_obj) glyphs_obj.parent = face_obj elif len(glyphs_objs) > 1: print(f"found more glyphs objects than expected") # now it must exist glyphs_obj = find_objects_by_name(face_obj.children, startswith="glyphs")[0] glyphs_obj["face_name"] = obj["face_name"] glyphs_obj["font_name"] = obj["font_name"] def get_hash(o): return hash(tuple(tuple(v.co) for v in o.data.vertices )) for other_obj in find_objects_by_custom_property(glyphs_obj.children, "glyph", obj["glyph"]): if get_hash(other_obj) == get_hash(obj) and not allow_duplicates: return other_obj # and now parent it! if obj.parent != glyphs_obj: obj.parent = glyphs_obj for c in obj.users_collection: c.objects.unlink(obj) if fontcollection.objects.find(obj.name) < 0: fontcollection.objects.link(obj) return obj def load_font_from_filepath(filepath): if not filepath.endswith(".glb") and not filepath.endswith(".gltf"): ShowMessageBox(f"{bl_info['name']} Font loading error", 'ERROR', f"Filepath({filepath}) is not a *.glb or *.gltf file") return False font3d_data = bpy.context.scene.font3d_data allObjectsBefore = [] for ob in bpy.data.objects: allObjectsBefore.append(ob.name) bpy.ops.import_scene.gltf(filepath=filepath) fontcollection = bpy.data.collections.get("Font3D") if fontcollection is None: fontcollection = bpy.data.collections.new("Font3D") remove_list = [] all_objects = [] for o in bpy.data.objects: all_objects.append(o) for o in all_objects: if o.name not in allObjectsBefore: # must be new if ("glyph" in o.keys() and "face_name" in o.keys() and "font_name" in o.keys()): glyph_id = o["glyph"] font_name = o["font_name"] face_name = o["face_name"] glyph_obj = move_in_fontcollection( o, fontcollection) Font.add_glyph( font_name, face_name, glyph_id, glyph_obj) if glyph_obj != o: remove_list.append(o) found = False for f in font3d_data.available_fonts.values(): if f.font_name == font_name: found = True break if not found: f = font3d_data.available_fonts.add() f.font_name = font_name print(f"{__name__} added {font_name}") else: remove_list.append(o) for o in remove_list: bpy.data.objects.remove(o, do_unlink=True) print(f"{__name__}: loaded fonts") def getPreferences(context): preferences = context.preferences return preferences.addons['font3d'].preferences # clear available fonts def clear_available_fonts(): bpy.context.scene.font3d_data.available_fonts.clear() def load_available_fonts(): preferences = getPreferences(bpy.context) currentObjects = [] for ob in bpy.data.objects: currentObjects.append(ob.name) print(f"assets folder: {preferences.assets_dir}") font_dir = f"{preferences.assets_dir}/fonts" for file in os.listdir(font_dir): if file.endswith(".glb") or file.endswith(".gltf"): font_path = os.path.join(font_dir, file) load_font_from_filepath(font_path) def ShowMessageBox(title = "Message Box", icon = 'INFO', message=""): """Show a simple message box taken from `Link here `_ :param title: The title shown in the message top bar :type title: str :param icon: The icon to be shown in the message top bar :type icon: str :param message: lines of text to display, a.k.a. the message :type message: str or (str, str, ..) TIP: Check `Link blender icons `_ for icons you can use TIP: Or even better, check `Link this addons `_ to also see the icons. usage: .. code-block:: python myLines=("line 1","line 2","line 3") butils.ShowMessageBox(message=myLines) or: .. code-block:: python butils.ShowMessageBox(title="",message=("AAAAAH","NOOOOO"),icon=) """ myLines=message def draw(self, context): if isinstance(myLines, str): self.layout.label(text=myLines) elif hasattr(myLines, "__iter__"): for n in myLines: self.layout.label(text=n) bpy.context.window_manager.popup_menu(draw, title = title, icon = icon) def completely_delete_objs(objs): context_override = bpy.context.copy() context_override["selected_objects"] = list(objs) with bpy.context.temp_override(**context_override): bpy.ops.object.delete() # remove deleted objects # this is necessary for g in objs: if type(g) != type(None): try: bpy.data.objects.remove(g, do_unlink=True) except ReferenceError as e: # not important pass def set_text_on_curve(text_properties): # starttime = time.perf_counter_ns() mom = text_properties.text_object if mom.type != "CURVE": return False regenerate = False glyph_objects = [] for i, g in enumerate(text_properties.glyphs): glyph_objects.append(g.glyph_object) # check if perhaps one glyph was deleted if (type(g.glyph_object) == type(None) or type(g.glyph_object.parent) == type(None) or g.glyph_object.parent.users_collection != g.glyph_object.users_collection): regenerate = True if len(text_properties.text) > i and g.glyph_id != text_properties.text[i]: regenerate = True if len(text_properties.text) != len(text_properties.glyphs): regenerate = True # if we regenerate.... delete objects if regenerate: context_override = bpy.context.copy() context_override["selected_objects"] = list(glyph_objects) with bpy.context.temp_override(**context_override): bpy.ops.object.delete() # remove deleted objects # this is necessary for g in glyph_objects: if type(g) != type(None): bpy.data.objects.remove(g, do_unlink=True) text_properties.glyphs.clear() #TODO: fix selection with context_override previous_selection = bpy.context.selected_objects bpy.ops.object.select_all(action='DESELECT') selected_objects = [] curve_length = get_curve_length(mom) advance = 0 glyph_advance = 0 is_command = False for i, c in enumerate(text_properties.text): if c == '\\': is_command = True continue if is_command: if c == 'n': next_line_advance = get_next_line_advance(mom, advance, glyph_advance) if advance == next_line_advance: # self.report({'INFO'}, f"would like to add new line for {text_properties.text} please") print(f"would like to add new line for {text_properties.text} please") advance = next_line_advance continue is_command = False glyph_id = c glyph = Font.get_glyph(text_properties.font_name, text_properties.font_face, glyph_id) if glyph == None: # self.report({'ERROR'}, f"Glyph not found for {font_name} {font_face} {glyph_id}") print(f"Glyph not found for {text_properties.font_name} {text_properties.font_face} {glyph_id}") continue ob = None if regenerate: ob = bpy.data.objects.new(f"{glyph_id}", glyph.data) ob['linked_textobject'] = text_properties.text_id else: ob = text_properties.glyphs[i]['glyph_object'] distribution_type = 'CALCULATE' if distribution_type == 'FOLLOW_PATH': ob.constraints.new(type='FOLLOW_PATH') ob.constraints["Follow Path"].target = mom ob.constraints["Follow Path"].use_fixed_location = True ob.constraints["Follow Path"].offset_factor = advance / curve_length ob.constraints["Follow Path"].use_curve_follow = True ob.constraints["Follow Path"].forward_axis = "FORWARD_X" ob.constraints["Follow Path"].up_axis = "UP_Y" elif distribution_type == 'CALCULATE': location, tangent = calc_point_on_bezier_curve(mom, advance, True) ob.location = mom.matrix_world @ (location + text_properties.translation) mask = [0] input_rotations = [mathutils.Vector((0.0, 0.0, 0.0))] vectors = [tangent] factors = [1.0] local_main_axis = mathutils.Vector((1.0, 0.0, 0.0)) motor = align_rotations_auto_pivot(mask, input_rotations, vectors, factors, local_main_axis) if ob.rotation_mode != 'QUATERNION': ob.rotation_mode = 'QUATERNION' q = mathutils.Quaternion() q.rotate(text_properties.orientation) ob.rotation_quaternion = (mom.matrix_world @ motor[0] @ q.to_matrix().to_4x4()).to_quaternion() scalor = 0.001 * text_properties.font_size glyph_advance = (-1 * glyph.bound_box[0][0] + glyph.bound_box[4][0]) * scalor + text_properties.letter_spacing # now we need to compensate for curvature # otherwise letters will be closer together the curvier the bezier is # this could be done more efficiently, but whatever previous_location = calc_point_on_bezier_curve(mom, advance, False) new_location = calc_point_on_bezier_curve(mom, advance + glyph_advance, False) curve_compensation = 0 while (previous_location - new_location).length < glyph_advance: curve_compensation = curve_compensation + glyph_advance * 0.1 new_location = calc_point_on_bezier_curve(mom, advance + glyph_advance + curve_compensation, False) ob.scale = (scalor, scalor, scalor) advance = advance + glyph_advance + curve_compensation if regenerate: mom.users_collection[0].objects.link(ob) glyph_data = text_properties.glyphs.add() glyph_data.glyph_id = glyph_id glyph_data.glyph_object = ob glyph_data.letter_spacing = 0 ob.select_set(True) if regenerate: mom.select_set(True) bpy.context.view_layer.objects.active = mom bpy.ops.object.parent_set(type='OBJECT') # endtime = time.perf_counter_ns() # elapsedtime = endtime - starttime return True