From 14d1b7a160dada43e89357035a5a0ee40edbaa18 Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Sun, 1 Jun 2025 16:32:22 +0200 Subject: [PATCH 1/7] [fix] glyph receives text_id --- __init__.py | 54 +++++++++++++++++++++++------------------------------ butils.py | 2 ++ 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/__init__.py b/__init__.py index 9ed5a48..748d638 100644 --- a/__init__.py +++ b/__init__.py @@ -660,42 +660,34 @@ class ABC3D_PT_TextPropertiesPanel(bpy.types.Panel): # and bpy.context.object.select_get(): a_o = bpy.context.active_object if a_o is not None: - if f"{utils.prefix()}_text_id" in a_o: - text_index = a_o[f"{utils.prefix()}_text_id"] - return bpy.context.scene.abc3d_data.available_texts[text_index] - elif a_o.parent is not None and f"{utils.prefix()}_text_id" in a_o.parent: - text_index = a_o.parent[f"{utils.prefix()}_text_id"] - return bpy.context.scene.abc3d_data.available_texts[text_index] - else: - for t in bpy.context.scene.abc3d_data.available_texts: - if butils.is_or_has_parent( - bpy.context.active_object, t.text_object, max_depth=4 - ): - return t + # if f"{utils.prefix()}_text_id" in a_o: + # text_id = a_o[f"{utils.prefix()}_text_id"] + # return butils.get_text_properties(text_id) + # # elif a_o.parent is not None and f"{utils.prefix()}_text_id" in a_o.parent: + # # text_id = a_o.parent[f"{utils.prefix()}_text_id"] + # # return butils.get_text_properties(text_id) + # else: + for t in bpy.context.scene.abc3d_data.available_texts: + if butils.is_or_has_parent( + bpy.context.active_object, t.text_object, max_depth=4 + ): + return t return None - def get_active_glyph_properties(self): + def get_active_glyph_properties(self, text_properties): + if text_properties is None: + return None a_o = bpy.context.active_object if a_o is not None: - if ( - f"{utils.prefix()}_text_id" in a_o - and f"{utils.prefix()}_glyph_index" in a_o - ): - text_index = a_o[f"{utils.prefix()}_text_id"] + if f"{utils.prefix()}_glyph_index" in a_o: glyph_index = a_o[f"{utils.prefix()}_glyph_index"] - return bpy.context.scene.abc3d_data.available_texts[text_index].glyphs[ - glyph_index - ] + if len(text_properties.glyphs) <= glyph_index: + return None + return text_properties.glyphs[glyph_index] else: - for t in bpy.context.scene.abc3d_data.available_texts: - if butils.is_or_has_parent( - a_o, t.text_object, if_is_parent=False, max_depth=4 - ): - for g in t.glyphs: - if butils.is_or_has_parent( - a_o, g.glyph_object, max_depth=4 - ): - return g + for g in text_properties.glyphs: + if butils.is_or_has_parent(a_o, g.glyph_object, max_depth=4): + return g return None @classmethod @@ -709,7 +701,7 @@ class ABC3D_PT_TextPropertiesPanel(bpy.types.Panel): layout = self.layout props = self.get_active_text_properties() - glyph_props = self.get_active_glyph_properties() + glyph_props = self.get_active_glyph_properties(props) if props is None or props.text_object is None: # this should not happen diff --git a/butils.py b/butils.py index 28dbd0f..fba0ef4 100644 --- a/butils.py +++ b/butils.py @@ -1301,6 +1301,8 @@ def transfer_text_object_to_text_properties( transfer_glyph_object_to_glyph_properties(glyph_object, glyph_properties) glyph_properties["glyph_object"] = glyph_object glyph_properties["glyph_index"] = glyph_index + glyph_properties["text_id"] = text_properties.text_id + glyph_object["text_id"] = text_properties.text_id inner_node = None for c in glyph_object.children: if c.name.startswith(f"{glyph_id}_mesh"): From 7de8fcc5d115510c3857aa81fff44453564793ea Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Wed, 4 Jun 2025 14:47:09 +0200 Subject: [PATCH 2/7] refactor ensure glyphs + alternates --- __init__.py | 85 ++++++++++-- butils.py | 353 ++++++++++++++++++++++++++++++++----------------- common/Font.py | 23 +++- 3 files changed, 332 insertions(+), 129 deletions(-) diff --git a/__init__.py b/__init__.py index 748d638..8d9a59d 100644 --- a/__init__.py +++ b/__init__.py @@ -143,13 +143,40 @@ class ABC3D_glyph_properties(bpy.types.PropertyGroup): t = butils.get_text_properties(self.text_id) if t is not None: butils.set_text_on_curve(t) + return None + + def alternate_get_callback(self): + return self["alternate"] if "alternate" in self else 0 + + def alternate_set_callback(self, value): + min_value = 0 + new_value = max(value, min_value) + + if self.text_id >= 0: + text_properties = butils.get_text_properties(self.text_id) + max_value = ( + len( + Font.get_glyphs( + text_properties.font_name, + text_properties.face_name, + self.glyph_id, + ) + ) + - 1 + ) + new_value = min(new_value, max_value) + + self["alternate"] = new_value + return None glyph_id: bpy.props.StringProperty(maxlen=1) text_id: bpy.props.IntProperty( default=-1, ) alternate: bpy.props.IntProperty( - default=-1, + default=0, # also change in alternate_get_callback + get=alternate_get_callback, + set=alternate_set_callback, update=update_callback, ) glyph_object: bpy.props.PointerProperty(type=bpy.types.Object) @@ -267,6 +294,7 @@ class ABC3D_data(bpy.types.PropertyGroup): available_texts: bpy.props.CollectionProperty( type=ABC3D_text_properties, name="Available texts" ) + texts: bpy.props.CollectionProperty(type=ABC3D_text_properties, name="texts") def active_text_index_update(self, context): lock_depsgraph_updates() @@ -713,6 +741,22 @@ class ABC3D_PT_TextPropertiesPanel(bpy.types.Panel): layout.label(text="props.text_object is none") return + # TODO: put this at a better place + # here we set the font if it is not correct + # this is a fix for a UI glitch, perhaps it could be fixed + # rather where it is not set properly + # if ( + # butils.get_key("font_name") in props.text_object + # and butils.get_key("face_name") in props.text_object + # ): + # font = f"{props.text_object[butils.get_key('font_name')]} {props.text_object[butils.get_key('face_name')]}" + # if font != props.font: + # + # def setfont(): + # props.font = font + # + # butils.run_in_main_thread(setfont) + # layout.label(text=f"Mom: {props.text_object.name}") layout.row().prop(props, "font") layout.row().prop(props, "text") @@ -729,8 +773,26 @@ class ABC3D_PT_TextPropertiesPanel(bpy.types.Panel): if glyph_props is None: return box = layout.box() - box.label(text=f"{glyph_props.glyph_id}") + box.label(text=f"selected character: {glyph_props.glyph_id}") box.row().prop(glyph_props, "letter_spacing") + # if True: + # font_name = props.font_name + # face_name = props.face_name + # glyph_id = glyph_props.glyph_id + # glyphs_n = len(Font.get_glyphs(font_name, face_name, glyph_id)) + # glyph_props.alternate.hard_min = -1 + # glyph_props.alternate.hard_max = glyphs_n - 1 + n_alternates = len( + Font.get_glyphs( + props.font_name, + props.face_name, + glyph_props.glyph_id, + ) + ) + if n_alternates > 1: + box.row().prop(glyph_props, "alternate", text=f"alternate ({n_alternates})") + # if glyph_props.glyph_object.preview is not None: + # box.row().template_preview(glyph_props.glyph_object.preview.icon_id) class ABC3D_OT_RefreshAvailableFonts(bpy.types.Operator): @@ -1586,9 +1648,10 @@ class ABC3D_OT_CreateFontFromObjects(bpy.types.Operator): def do_autodetect_names(self, name: str): ifxsplit = name.split("_") if len(ifxsplit) < 4: - print(f"name could not be autodetected {name}") - print("split:") - print(ifxsplit) + print( + f"{utils.prefix()}::CreateFontFromObjects: name could not be autodetected {name}" + ) + print(f"{utils.prefix()}::CreateFontFromObjects: split: {ifxsplit=}") return self.font_name, self.face_name detected_font_name = f"{ifxsplit[1]}_{ifxsplit[2]}" detected_face_name = ifxsplit[3] @@ -1663,9 +1726,11 @@ class ABC3D_OT_CreateFontFromObjects(bpy.types.Operator): row.label(text=f"{k} → {Font.known_misspellings[k]}{character}") def execute(self, context): - print(f"executing {self.bl_idname}") + print(f"{utils.prefix()}::CreateFontFromObjects: executing {self.bl_idname}") if len(context.selected_objects) == 0: - print(f"cancelled {self.bl_idname} - no objects selected") + print( + f"{utils.prefix()}::CreateFontFromObjects: cancelled {self.bl_idname} - no objects selected" + ) return {"CANCELLED"} global shared scene = bpy.context.scene @@ -1682,7 +1747,7 @@ class ABC3D_OT_CreateFontFromObjects(bpy.types.Operator): currentObjects = [] for o in context.selected_objects: if o.name not in currentObjects: - print(f"processing {o.name}") + print(f"{utils.prefix()}::CreateFontFromObjects: processing {o.name}") process_object = True if self.autodetect_names: font_name, face_name = self.do_autodetect_names(o.name) @@ -1719,7 +1784,9 @@ class ABC3D_OT_CreateFontFromObjects(bpy.types.Operator): f.face_name = face_name else: - print(f"import warning: did not understand glyph {name}") + print( + f"{utils.prefix()}::CreateFontFromObjects: import warning: did not understand glyph {name}" + ) self.report({"INFO"}, f"did not understand glyph {name}") return {"FINISHED"} diff --git a/butils.py b/butils.py index fba0ef4..e7aa72d 100644 --- a/butils.py +++ b/butils.py @@ -663,7 +663,7 @@ def clean_fontcollection(fontcollection=None): fontcollection = bpy.data.collections.get("ABC3D") if fontcollection is None: print( - f"{utils.prefix()}::clean_fontcollection: failed beacause fontcollection is none" + f"{utils.prefix()}::clean_fontcollection: failed because fontcollection is none" ) return False @@ -1011,9 +1011,11 @@ def predict_actual_text(text_properties): ) t_text = text_properties.text for c in availability.missing: - t_text = t_text.replace(c, "") - for c in AVAILABILITY.missing: - t_text = t_text.replace(c, "") + C = c.swapcase() + if C in AVAILABILITY.missing: + t_text = t_text.replace(c, "") + else: + t_text = t_text.replace(c, C) return t_text @@ -1184,6 +1186,7 @@ def get_text_properties(text_id, scene=None): return t return None + def get_text_properties_by_index(text_index, scene=None): if scene is None: scene = bpy.context.scene @@ -1299,10 +1302,7 @@ def transfer_text_object_to_text_properties( glyph_properties = text_properties.glyphs.add() transfer_glyph_object_to_glyph_properties(glyph_object, glyph_properties) - glyph_properties["glyph_object"] = glyph_object - glyph_properties["glyph_index"] = glyph_index - glyph_properties["text_id"] = text_properties.text_id - glyph_object["text_id"] = text_properties.text_id + # glyph_properties["glyph_object"] = glyph_object inner_node = None for c in glyph_object.children: if c.name.startswith(f"{glyph_id}_mesh"): @@ -1311,6 +1311,9 @@ def transfer_text_object_to_text_properties( fail_after_all = True pass glyph_properties["glyph_object"] = glyph_object + glyph_properties["glyph_index"] = glyph_index + glyph_properties["text_id"] = text_properties.text_id + glyph_object["text_id"] = text_properties.text_id if not fail_after_all: found_reconstructable_glyphs = True @@ -1421,10 +1424,21 @@ def transfer_glyph_object_to_glyph_properties(glyph_object, glyph_properties): glyph_properties["text_id"] = glyph_object[get_key("text_id")] +def get_text_difference_index(text_a, text_b): + len_a = len(text_a) + len_b = len(text_b) + len_min = min(len_a, len_b) + len_max = max(len_a, len_b) + for i in range(0, len_max): + if i >= len_min or text_a[i] != text_b[i]: + return i + return False + + def would_regenerate(text_properties): predicted_text = predict_actual_text(text_properties) if text_properties.actual_text != predicted_text: - return True + return get_text_difference_index(text_properties.actual_text, predicted_text) if len(text_properties.glyphs) == 0: return True @@ -1476,6 +1490,7 @@ def is_or_has_parent(o, parent, if_is_parent=True, max_depth=10): def parent_to_curve(o, c): + # https://projects.blender.org/blender/blender/issues/100661 o.parent_type = "OBJECT" o.parent = c # o.matrix_parent_inverse = c.matrix_world.inverted() @@ -1491,6 +1506,184 @@ def parent_to_curve(o, c): o.matrix_parent_inverse.translation = p * -1.0 +def get_original_glyph(text_properties, glyph_properties): + glyph_tmp = Font.get_glyph( + text_properties.font_name, + text_properties.face_name, + glyph_properties.glyph_id, + glyph_properties.alternate, + ) + if glyph_tmp is None: + return None + return glyph_tmp.original + + +def ensure_glyph_object(text_properties, glyph_properties): + glyph_index = glyph_properties["glyph_index"] + # First, let's see if there was ever a glyph object constructed + if ( + glyph_properties.glyph_object is None + or not isinstance(glyph_properties.glyph_object, bpy_types.Object) + or not is_glyph_object(glyph_properties.glyph_object) + ): + # we do need a text_object though + # if there is not, let's give up for this iteration + if not isinstance(text_properties.text_object, bpy_types.Object): + print( + f"{utils.prefix()}::ensure_glyph_object: failed! text object is not an object" + ) + return False + + outer_node = bpy.data.objects.new(f"{glyph_properties.glyph_id}", None) + inner_node = bpy.data.objects.new( + f"{glyph_properties.glyph_id}_mesh", + get_original_glyph(text_properties, glyph_properties).data, + ) + transfer_properties_to_glyph_object( + text_properties, glyph_properties, outer_node + ) + + # Add into the scene. + text_properties.text_object.users_collection[0].objects.link(outer_node) + text_properties.text_object.users_collection[0].objects.link(inner_node) + + # Parenting is hard. + inner_node.parent_type = "OBJECT" + inner_node.parent = outer_node + inner_node.matrix_parent_inverse = outer_node.matrix_world.inverted() + parent_to_curve(outer_node, text_properties.text_object) + + # outer_node["inner_node"] = bpy.types.PointerProperty(inner_node) + # for some funny reason we cannot set 'glyph_object' by key, but need to set the attribute + glyph_properties.glyph_object = outer_node + outer_node[f"{utils.prefix()}_glyph_index"] = glyph_index + else: + outer_node = glyph_properties.glyph_object + outer_node[f"{utils.prefix()}_glyph_index"] = glyph_index + + # we might just want to update the data + # imagine a different font, letter or alternate + # this way we keep all manual transforms + if ( + glyph_properties.glyph_object[get_key("glyph_id")] != glyph_properties.glyph_id + or glyph_properties.glyph_object[get_key("alternate")] + != glyph_properties.alternate + or glyph_properties.glyph_object[get_key("font_name")] + != text_properties.font_name + or glyph_properties.glyph_object[get_key("face_name")] + != text_properties.face_name + ): + + inner_node = None + old_font_name = glyph_properties.glyph_object[get_key("font_name")] + old_face_name = glyph_properties.glyph_object[get_key("face_name")] + old_face = Font.get_font_face(old_font_name, old_face_name) + face = Font.get_font_face(text_properties.font_name, text_properties.face_name) + ratio = old_face.unit_factor / face.unit_factor + # try: + # inner_node = glyph_properties["inner_node"].original + # inner_node.location = inner_node.location * ratio + # except KeyError: + old_glyph_id = glyph_properties.glyph_object[get_key("glyph_id")] + for c in glyph_properties.glyph_object.children: + if c.name.startswith(f"{old_glyph_id}_mesh"): + inner_node = c + inner_node.location = inner_node.location * ratio + inner_node.name = f"{glyph_properties.glyph_id}_mesh" + # outer_node["inner_node"] = bpy.types.PointerProperty(inner_node) + if inner_node is None: + print(f"{utils.prefix()}::ensure_glyph_object: failed! no inner_node found") + return False + inner_node.data = get_original_glyph(text_properties, glyph_properties).data + glyph_properties.glyph_object[get_key("glyph_id")] = glyph_properties.glyph_id + glyph_properties.glyph_object[get_key("alternate")] = glyph_properties.alternate + glyph_properties.glyph_object[get_key("font_name")] = text_properties.font_name + glyph_properties.glyph_object[get_key("face_name")] = text_properties.face_name + + glyph_properties.glyph_object.hide_set(True) + + return True + + +def ensure_glyphs(text_properties, predicted_text: str): + + ######### REQUIREMENTS + + # turns out this is not a requirement + # and can be a case we want to tackle + # + # if not text_properties.get("glyphs"): + # ShowMessageBox( + # title="text_properties has no glyphs", message="well, what I said" + # ) + # return False + + ######### SETUP + + n_glyphs = len(text_properties.glyphs) + n_predicted = len(predicted_text) + + ########## ENSURE AMOUNT + + if n_glyphs == n_predicted: + # same amount of glyphs + # this is the most common case + # don't do anything + pass + + elif n_glyphs > n_predicted: + # more glyphs than predicted + # it's a shorter word, or letters were deleted + count = n_glyphs - n_predicted + for i in range(0, count): + reverse_i = n_glyphs - (i + 1) + # let's attempt to remove the glyph_object first + # so we avoid dangling data + if isinstance( + text_properties.glyphs[reverse_i].glyph_object, bpy_types.Object + ): + # bam! + completely_delete_objects( + [text_properties.glyphs[reverse_i].glyph_object] + ) + # else: + # # nothing to do, if there is no blender object + # # possibly we could do a 'del', but we can also + # # just comment out the whole conditional fork + # pass + + # now that blender data is gone, we can remove the glyph + text_properties.glyphs.remove(reverse_i) + + elif n_glyphs < n_predicted: + # less glyphs than predicted + # it's a longer word, or letters were added + while n_glyphs < n_predicted: + glyph_id = predicted_text[n_glyphs] + glyph_properties = text_properties.glyphs.add() + glyph_properties["glyph_id"] = predicted_text[n_glyphs] + glyph_properties["glyph_index"] = n_glyphs + glyph_properties["text_id"] = text_properties.text_id + glyph_properties["letter_spacing"] = 0 + n_glyphs += 1 + + ######### ENSURE VALUES + + for i, glyph_properties in enumerate(text_properties.glyphs): + glyph_properties["glyph_index"] = i + glyph_properties["text_id"] = text_properties.text_id + glyph_properties["glyph_id"] = predicted_text[i] + if not ensure_glyph_object(text_properties, glyph_properties): + print(f"{utils.prefix()}::ensure_glyphs: could not ensure glyph_object") + + return True + + +# C.scene.abc3d_data.available_texts[0] +# import abc3d +# abc3d.butils.ensure_glyphs(C.scene.abc3d_data.available_texts[0], "whatever") + + def set_text_on_curve( text_properties, reset_timeout_s=0.1, reset_depsgraph_n=4, can_regenerate=False ): @@ -1519,27 +1712,8 @@ def set_text_on_curve( distribution_type = "CALCULATE" if is_bezier(mom) else "FOLLOW_PATH" - # NOTE: following not necessary anymore - # as we fixed data_path with parent_to_curve trick - # - # use_path messes with parenting - # however, we need it for follow_path - # https://projects.blender.org/blender/blender/issues/100661 - # previous_use_path = mom.data.use_path - # if distribution_type == "CALCULATE": - # mom.data.use_path = False - # elif distribution_type == "FOLLOW_PATH": - # mom.data.use_path = True - - regenerate = can_regenerate and would_regenerate(text_properties) - - # if we regenerate.... delete objects - if regenerate and text_properties.get("glyphs"): - glyph_objects = [g["glyph_object"] for g in text_properties["glyphs"]] - completely_delete_objects(glyph_objects, True) - text_properties.glyphs.clear() - - transfer_text_properties_to_text_object(text_properties, mom) + predicted_text = predict_actual_text(text_properties) + ensure_glyphs(text_properties, predicted_text) curve_length = get_curve_length(mom) advance = text_properties.offset @@ -1549,6 +1723,9 @@ def set_text_on_curve( previous_spline_index = -1 actual_text = "" + # we need to iterate over the original text, as we want commands + # however, ideally it could be an array of glyphs, commands and spaces + # now we need to handle non existing characters etc everytime in the loop for i, c in enumerate(text_properties.text): face = Font.get_font_face(text_properties.font_name, text_properties.face_name) scalor = face.unit_factor * text_properties.font_size @@ -1572,90 +1749,31 @@ def set_text_on_curve( spline_index = 0 - ############### GET GLYPH + ############### HANDLE SPACES - glyph_tmp = Font.get_glyph( - text_properties.font_name, text_properties.face_name, glyph_id, -1 - ) - if glyph_tmp is None: + if glyph_id not in predicted_text: space_width = Font.is_space(glyph_id) if space_width: advance = advance + space_width * text_properties.font_size - continue - - message = f"Glyph not found for font_name='{text_properties.font_name}' face_name='{text_properties.face_name}' glyph_id='{glyph_id}'" - replaced = False - if glyph_id.isalpha(): - possible_replacement = glyph_id.swapcase() - glyph_tmp = Font.get_glyph( - text_properties.font_name, - text_properties.face_name, - possible_replacement, - -1, - ) - if glyph_tmp is not None: - message = message + f" (replaced with '{possible_replacement}')" - replaced = True - - if can_regenerate: - ShowMessageBox( - title="Glyph replaced" if replaced else "Glyph missing", - icon="INFO" if replaced else "ERROR", - message=message, - prevent_repeat=True, - ) - if not replaced: - continue - - glyph = glyph_tmp.original + continue ############### GLYPH PROPERTIES - glyph_properties = ( - text_properties.glyphs[glyph_index] - if not regenerate - else text_properties.glyphs.add() - ) + glyph_properties = text_properties.glyphs[glyph_index] + # ensure_glyph_object(text_properties, glyph_properties) - if regenerate: - glyph_properties["glyph_id"] = glyph_id - glyph_properties["text_id"] = text_properties.text_id - glyph_properties["letter_spacing"] = 0 - actual_text += glyph_id + ############### ACTUAL TEXT + + actual_text += glyph_id ############### NODE SCENE MANAGEMENT - inner_node = None - outer_node = None - if regenerate: - outer_node = bpy.data.objects.new(f"{glyph_id}", None) - inner_node = bpy.data.objects.new(f"{glyph_id}_mesh", glyph.data) - transfer_properties_to_glyph_object( - text_properties, glyph_properties, outer_node - ) - - # Add into the scene. - mom.users_collection[0].objects.link(outer_node) - mom.users_collection[0].objects.link(inner_node) - - # Parenting is hard. - inner_node.parent_type = "OBJECT" - inner_node.parent = outer_node - inner_node.matrix_parent_inverse = outer_node.matrix_world.inverted() - parent_to_curve(outer_node, mom) - outer_node.hide_set(True) - - glyph_properties["glyph_object"] = outer_node - outer_node[f"{utils.prefix()}_glyph_index"] = glyph_index - else: - outer_node = glyph_properties.glyph_object - outer_node[f"{utils.prefix()}_glyph_index"] = glyph_index - for c in outer_node.children: - if c.name.startswith(f"{glyph_id}_mesh"): - inner_node = c + # outsourced to ensure_glyph_object ############### TRANSFORMS + glyph = get_original_glyph(text_properties, glyph_properties) + # origins could be shifted # so we need to apply a pre_advance glyph_pre_advance, glyph_post_advance = get_glyph_prepost_advances(glyph) @@ -1683,14 +1801,12 @@ def set_text_on_curve( outer_node.constraints["Follow Path"].up_axis = "UP_Y" spline_index = 0 elif distribution_type == "CALCULATE": - previous_outer_node_rotation_mode = None - previous_inner_node_rotation_mode = None - if outer_node.rotation_mode != "QUATERNION": - outer_node.rotation_mode = "QUATERNION" - previous_outer_node_rotation_mode = outer_node.rotation_mode - if inner_node.rotation_mode != "QUATERNION": - inner_node.rotation_mode = "QUATERNION" - previous_inner_node_rotation_mode = inner_node.rotation_mode + previous_glyph_object_rotation_mode = None + if glyph_properties.glyph_object.rotation_mode != "QUATERNION": + previous_glyph_object_rotation_mode = ( + glyph_properties.glyph_object.rotation_mode + ) + glyph_properties.glyph_object.rotation_mode = "QUATERNION" # get info from bezier location, tangent, spline_index = calc_point_on_bezier_curve( @@ -1702,7 +1818,9 @@ def set_text_on_curve( is_newline = True # position - outer_node.location = location + text_properties.translation + glyph_properties.glyph_object.location = ( + location + text_properties.translation + ) # orientation / rotation mask = [0] @@ -1720,21 +1838,21 @@ def set_text_on_curve( q = mathutils.Quaternion() q.rotate(text_properties.orientation) - outer_node.rotation_quaternion = ( + glyph_properties.glyph_object.rotation_quaternion = ( motor[0].to_3x3() @ q.to_matrix() ).to_quaternion() # # NOTE: supercool but out of scope, as we wouldhave to update it everytime the curve object rotates, # # but this would ignore the curve objects orientation: - # outer_node.rotation_quaternion = (mom.matrix_world.inverted().to_3x3() @ motor[0].to_3x3() @ q.to_matrix()).to_quaternion() + # glyph_properties.glyph_object.rotation_quaternion = (mom.matrix_world.inverted().to_3x3() @ motor[0].to_3x3() @ q.to_matrix()).to_quaternion() # # scale - outer_node.scale = (scalor, scalor, scalor) + glyph_properties.glyph_object.scale = (scalor, scalor, scalor) - if previous_outer_node_rotation_mode: - outer_node.rotation_mode = previous_outer_node_rotation_mode - if previous_inner_node_rotation_mode: - inner_node.rotation_mode = previous_inner_node_rotation_mode + if previous_glyph_object_rotation_mode: + glyph_properties.glyph_object.rotation_mode = ( + previous_glyph_object_rotation_mode + ) # outer_node.hide_viewport = True @@ -1805,8 +1923,7 @@ def set_text_on_curve( glyph_index += 1 previous_spline_index = spline_index - if regenerate: - text_properties["actual_text"] = actual_text + text_properties["actual_text"] = actual_text return True diff --git a/common/Font.py b/common/Font.py index d68518c..aebf702 100644 --- a/common/Font.py +++ b/common/Font.py @@ -266,7 +266,13 @@ def get_glyphs(font_name, face_name, glyph_id): return glyphs_for_id -def get_glyph(font_name, face_name, glyph_id, alternate=0): +def get_glyph( + font_name: str, + face_name: str, + glyph_id: str, + alternate: int = 0, + alternate_tolerant: bool = True, +): """add_glyph adds a glyph to a FontFace it creates the :class:`Font` and :class:`FontFace` if it does not exist yet @@ -276,6 +282,10 @@ def get_glyph(font_name, face_name, glyph_id, alternate=0): :type face_name: str :param glyph_id: The ``glyph_id`` from the glyph you want :type glyph_id: str + :param alternate: The ``alternate`` from the glyph you want + :type alternate: int + :param alternate_tolerant: Fetch an existing alternate if requested is out of bounds + :type glyph_id: bool ... :return: returns the glyph object, or ``None`` if it does not exist :rtype: `Object` @@ -283,12 +293,21 @@ def get_glyph(font_name, face_name, glyph_id, alternate=0): glyphs = get_glyphs(font_name, face_name, glyph_id) - if len(glyphs) <= alternate or len(glyphs) == 0: + if len(glyphs) == 0: print( f"ABC3D::get_glyph: font({font_name}) face({face_name}) glyph({glyph_id})[{alternate}] not found" ) return None + if len(glyphs) <= alternate: + if alternate_tolerant: + alternate = 0 + else: + print( + f"ABC3D::get_glyph: font({font_name}) face({face_name}) glyph({glyph_id})[{alternate}] not found" + ) + return None + return glyphs[alternate] From 58e0df34275ce4373fc96b675b0984e50d8ed848 Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Wed, 4 Jun 2025 21:28:08 +0200 Subject: [PATCH 3/7] first step spline animation --- __init__.py | 14 ++++++ butils.py | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 152 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index 8d9a59d..a4021ab 100644 --- a/__init__.py +++ b/__init__.py @@ -2040,13 +2040,27 @@ def on_depsgraph_update(scene, depsgraph): elif butils.is_text_object_legit(u.id.original): # must be duplicate link_text_object_with_new_text_properties(u.id.original, scene) + # butils.prepare_text( + # text_properties.font_name, + # text_properties.face_name, + # text_properties.text, + # ) elif ( butils.is_text_object_legit(u.id.original) and len(u.id.original.users_collection) > 0 ): # must be a new thing, maybe manually created or so link_text_object_with_new_text_properties(u.id.original, scene) + # butils.prepare_text( + # text_properties.font_name, + # text_properties.face_name, + # text_properties.text, + # ) butils.clean_text_properties() + try: + butils.set_text_on_curve(text_properties) + except: + pass update_active_text_index() unlock_depsgraph_updates() diff --git a/butils.py b/butils.py index e7aa72d..67367df 100644 --- a/butils.py +++ b/butils.py @@ -217,6 +217,118 @@ def calc_bezier_length(bezier_point_1, bezier_point_2, resolution=20): return length +def get_hook_modifiers(blender_object: bpy.types.Object): + return [m for m in blender_object.modifiers if m.type == "HOOK"] + + +class BezierSplinePoint: + def __init__( + self, + co: mathutils.Vector, + handle_left: mathutils.Vector, + handle_right: mathutils.Vector, + ): + self.co: mathutils.Vector = co + self.handle_left: mathutils.Vector = handle_left + self.handle_right: mathutils.Vector = handle_right + + +class BezierSpline: + def __init__( + self, + n: int, + use_cyclic_u: bool, + resolution_u: int, + ): + self.bezier_points = [BezierSplinePoint] * n + self.use_cyclic_u: int = use_cyclic_u + self.resolution_u: int = resolution_u + self.beziers: [] + self.lengths: [float] + self.total_length: float + + def calc_length(self, resolution) -> float: + # ignore resolution when accessing length to imitate blender function + print(f"{self.total_length=}") + return self.total_length + + +class BezierData: + def __init__(self, n): + self.splines = [BezierSpline] * n + + +class BezierCurve: + def __init__(self, blender_curve: bpy.types.Object, resolution_factor=1.0): + self.data = BezierData(len(blender_curve.data.splines)) + i = 0 + hooks = get_hook_modifiers(blender_curve) + print(f"{blender_curve.name=} =============================================") + for si, blender_spline in enumerate(blender_curve.data.splines): + self.data.splines[si] = BezierSpline( + len(blender_spline.bezier_points), + blender_spline.use_cyclic_u, + blender_spline.resolution_u, + ) + for pi, blender_bezier_point in enumerate(blender_spline.bezier_points): + self.data.splines[si].bezier_points[pi] = BezierSplinePoint( + blender_bezier_point.co, + blender_bezier_point.handle_left, + blender_bezier_point.handle_right, + ) + print(pi) + for hook in hooks: + hook_co = False + hook_handle_left = False + hook_handle_right = False + for vi in hook.vertex_indices: + if vi == i * 3: + hook_handle_left = True + elif vi == i * 3 + 1: + hook_co = True + elif vi == i * 3 + 2: + hook_handle_right = True + if hook_co: + location = ( + blender_curve.matrix_world.inverted() + @ hook.object.matrix_world.translation + ) + print(f"co {location=}") + self.data.splines[si].bezier_points[pi].co = ( + self.data.splines[si] + .bezier_points[pi] + .co.lerp(location, hook.strength) + ) + # if hook_handle_left: + # location = ( + # hook.object.matrix_world.translation + # - blender_curve.matrix_world.translation + # ) + mathutils.Vector((-1, 0, 0)) + # print(f"handle_left {location=}") + # self.data.splines[si].bezier_points[pi].handle_left = ( + # self.data.splines[si] + # .bezier_points[pi] + # .handle_left.lerp(location, hook.strength) + # ) + # if hook_handle_right: + # location = ( + # hook.object.matrix_world.translation + # - blender_curve.matrix_world.translation + # ) + # self.data.splines[si].bezier_points[pi].handle_right = ( + # self.data.splines[si] + # .bezier_points[pi] + # .handle_right.lerp(location, hook.strength) + # ) + i += 1 + ( + self.data.splines[si].beziers, + self.data.splines[si].lengths, + self.data.splines[si].total_length, + ) = get_real_beziers_and_lengths(self.data.splines[si], resolution_factor) + print(f"total length {self.data.splines[si].total_length}") + + def get_real_beziers_and_lengths(bezier_spline_obj, resolution_factor): beziers = [] lengths = [] @@ -277,8 +389,14 @@ def calc_point_on_bezier_spline( # if the bezier points sit on each other we have same issue # but that is then to be fixed in the bezier if p.handle_left == p.co and len(bezier_spline_obj.bezier_points) > 1: - beziers, lengths, total_length = get_real_beziers_and_lengths( - bezier_spline_obj, resolution_factor + beziers, lengths, total_length = ( + get_real_beziers_and_lengths(bezier_spline_obj, resolution_factor) + if not isinstance(bezier_spline_obj, BezierSpline) + else ( + bezier_spline_obj.beziers, + bezier_spline_obj.lengths, + bezier_spline_obj.total_length, + ) ) travel_point = calc_point_on_bezier(beziers[0][1], beziers[0][0], 0.001) travel = travel_point.normalized() * distance @@ -291,8 +409,14 @@ def calc_point_on_bezier_spline( else: return location - beziers, lengths, total_length = get_real_beziers_and_lengths( - bezier_spline_obj, resolution_factor + beziers, lengths, total_length = ( + get_real_beziers_and_lengths(bezier_spline_obj, resolution_factor) + if not isinstance(bezier_spline_obj, BezierSpline) + else ( + bezier_spline_obj.beziers, + bezier_spline_obj.lengths, + bezier_spline_obj.total_length, + ) ) iterated_distance = 0 @@ -336,6 +460,8 @@ def calc_point_on_bezier_curve( output_spline_index=False, resolution_factor=1.0, ): + # bezier_curve = BezierCurve(bezier_curve_obj) + # curve = bezier_curve.data curve = bezier_curve_obj.data # Loop through all splines in the curve @@ -343,6 +469,7 @@ def calc_point_on_bezier_curve( for i, spline in enumerate(curve.splines): resolution = int(spline.resolution_u * resolution_factor) length = spline.calc_length(resolution=resolution) + print(f"{utils.LINE()} {length=}") if total_length + length > distance or i == len(curve.splines) - 1: if output_spline_index and output_tangent: # return value from c_p_o_b_s is a tuple @@ -1704,7 +1831,11 @@ def set_text_on_curve( # global lock_depsgraph_update_n_times # starttime = time.perf_counter_ns() + if text_properties is None: + return False mom = text_properties.text_object + if mom is None: + return False if mom.type != "CURVE": return False if len(mom.users_collection) < 1: @@ -1812,6 +1943,9 @@ def set_text_on_curve( location, tangent, spline_index = calc_point_on_bezier_curve( mom, applied_advance, True, True ) + # location, tangent, spline_index = calc_point_on_bezier_curve( + # mom_hooked, applied_advance, True, True + # ) # check if we are on a new line if spline_index != previous_spline_index: From e95266afc94a2b1769c9ef5e00094f14f25957cb Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Thu, 5 Jun 2025 10:56:33 +0200 Subject: [PATCH 4/7] [fix] transfer text properties --- butils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/butils.py b/butils.py index 67367df..bb3bc1d 100644 --- a/butils.py +++ b/butils.py @@ -1802,6 +1802,9 @@ def ensure_glyphs(text_properties, predicted_text: str): glyph_properties["glyph_id"] = predicted_text[i] if not ensure_glyph_object(text_properties, glyph_properties): print(f"{utils.prefix()}::ensure_glyphs: could not ensure glyph_object") + transfer_text_properties_to_text_object( + text_properties, text_properties.text_object + ) return True From f02f8fc2f0283337c548e4d61245aaeafa775a74 Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Thu, 5 Jun 2025 10:57:53 +0200 Subject: [PATCH 5/7] depsgraph update set text this updates on animation --- __init__.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/__init__.py b/__init__.py index a4021ab..697e857 100644 --- a/__init__.py +++ b/__init__.py @@ -2036,31 +2036,21 @@ def on_depsgraph_update(scene, depsgraph): if text_properties is not None: if text_properties.text_object == u.id.original: # nothing to do + try: + butils.set_text_on_curve(text_properties) + except: + pass pass elif butils.is_text_object_legit(u.id.original): # must be duplicate link_text_object_with_new_text_properties(u.id.original, scene) - # butils.prepare_text( - # text_properties.font_name, - # text_properties.face_name, - # text_properties.text, - # ) elif ( butils.is_text_object_legit(u.id.original) and len(u.id.original.users_collection) > 0 ): # must be a new thing, maybe manually created or so link_text_object_with_new_text_properties(u.id.original, scene) - # butils.prepare_text( - # text_properties.font_name, - # text_properties.face_name, - # text_properties.text, - # ) butils.clean_text_properties() - try: - butils.set_text_on_curve(text_properties) - except: - pass update_active_text_index() unlock_depsgraph_updates() From cd99362bb1c9b3ceef9f334dff224e18d516120c Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Thu, 5 Jun 2025 10:59:14 +0200 Subject: [PATCH 6/7] [feature] spline animation --- butils.py | 105 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 59 insertions(+), 46 deletions(-) diff --git a/butils.py b/butils.py index bb3bc1d..30b0a47 100644 --- a/butils.py +++ b/butils.py @@ -221,26 +221,26 @@ def get_hook_modifiers(blender_object: bpy.types.Object): return [m for m in blender_object.modifiers if m.type == "HOOK"] -class BezierSplinePoint: +class HookBezierSplinePoint: def __init__( self, - co: mathutils.Vector, handle_left: mathutils.Vector, + co: mathutils.Vector, handle_right: mathutils.Vector, ): - self.co: mathutils.Vector = co - self.handle_left: mathutils.Vector = handle_left - self.handle_right: mathutils.Vector = handle_right + self.handle_left: mathutils.Vector = mathutils.Vector(handle_left) + self.co: mathutils.Vector = mathutils.Vector(co) + self.handle_right: mathutils.Vector = mathutils.Vector(handle_right) -class BezierSpline: +class HookBezierSpline: def __init__( self, n: int, use_cyclic_u: bool, resolution_u: int, ): - self.bezier_points = [BezierSplinePoint] * n + self.bezier_points = [HookBezierSplinePoint] * n self.use_cyclic_u: int = use_cyclic_u self.resolution_u: int = resolution_u self.beziers: [] @@ -249,34 +249,31 @@ class BezierSpline: def calc_length(self, resolution) -> float: # ignore resolution when accessing length to imitate blender function - print(f"{self.total_length=}") return self.total_length -class BezierData: +class HookBezierData: def __init__(self, n): - self.splines = [BezierSpline] * n + self.splines = [HookBezierSpline] * n -class BezierCurve: +class HookBezierCurve: def __init__(self, blender_curve: bpy.types.Object, resolution_factor=1.0): - self.data = BezierData(len(blender_curve.data.splines)) + self.data = HookBezierData(len(blender_curve.data.splines)) i = 0 hooks = get_hook_modifiers(blender_curve) - print(f"{blender_curve.name=} =============================================") for si, blender_spline in enumerate(blender_curve.data.splines): - self.data.splines[si] = BezierSpline( + self.data.splines[si] = HookBezierSpline( len(blender_spline.bezier_points), blender_spline.use_cyclic_u, blender_spline.resolution_u, ) for pi, blender_bezier_point in enumerate(blender_spline.bezier_points): - self.data.splines[si].bezier_points[pi] = BezierSplinePoint( - blender_bezier_point.co, + self.data.splines[si].bezier_points[pi] = HookBezierSplinePoint( blender_bezier_point.handle_left, + blender_bezier_point.co, blender_bezier_point.handle_right, ) - print(pi) for hook in hooks: hook_co = False hook_handle_left = False @@ -293,40 +290,57 @@ class BezierCurve: blender_curve.matrix_world.inverted() @ hook.object.matrix_world.translation ) - print(f"co {location=}") self.data.splines[si].bezier_points[pi].co = ( self.data.splines[si] .bezier_points[pi] .co.lerp(location, hook.strength) ) - # if hook_handle_left: - # location = ( - # hook.object.matrix_world.translation - # - blender_curve.matrix_world.translation - # ) + mathutils.Vector((-1, 0, 0)) - # print(f"handle_left {location=}") - # self.data.splines[si].bezier_points[pi].handle_left = ( - # self.data.splines[si] - # .bezier_points[pi] - # .handle_left.lerp(location, hook.strength) - # ) - # if hook_handle_right: - # location = ( - # hook.object.matrix_world.translation - # - blender_curve.matrix_world.translation - # ) - # self.data.splines[si].bezier_points[pi].handle_right = ( - # self.data.splines[si] - # .bezier_points[pi] - # .handle_right.lerp(location, hook.strength) - # ) + if hook_handle_left: + location_left = location + ( + self.data.splines[si].bezier_points[pi].co + - self.data.splines[si].bezier_points[pi].handle_left + ) + self.data.splines[si].bezier_points[pi].handle_left = ( + self.data.splines[si] + .bezier_points[pi] + .co.lerp(location_left, hook.strength) + ) + if hook_handle_right: + location_right = location + ( + self.data.splines[si].bezier_points[pi].co + - self.data.splines[si].bezier_points[pi].handle_right + ) + self.data.splines[si].bezier_points[pi].handle_right = ( + self.data.splines[si] + .bezier_points[pi] + .co.lerp(location, hook.strength) + ) + elif hook_handle_left: + location = ( + blender_curve.matrix_world.inverted() + @ hook.object.matrix_world.translation + ) + self.data.splines[si].bezier_points[pi].handle_left = ( + self.data.splines[si] + .bezier_points[pi] + .handle_left.lerp(location, hook.strength) + ) + elif hook_handle_right: + location = ( + blender_curve.matrix_world.inverted() + @ hook.object.matrix_world.translation + ) + self.data.splines[si].bezier_points[pi].handle_right = ( + self.data.splines[si] + .bezier_points[pi] + .handle_right.lerp(location, hook.strength) + ) i += 1 ( self.data.splines[si].beziers, self.data.splines[si].lengths, self.data.splines[si].total_length, ) = get_real_beziers_and_lengths(self.data.splines[si], resolution_factor) - print(f"total length {self.data.splines[si].total_length}") def get_real_beziers_and_lengths(bezier_spline_obj, resolution_factor): @@ -391,7 +405,7 @@ def calc_point_on_bezier_spline( if p.handle_left == p.co and len(bezier_spline_obj.bezier_points) > 1: beziers, lengths, total_length = ( get_real_beziers_and_lengths(bezier_spline_obj, resolution_factor) - if not isinstance(bezier_spline_obj, BezierSpline) + if not isinstance(bezier_spline_obj, HookBezierSpline) else ( bezier_spline_obj.beziers, bezier_spline_obj.lengths, @@ -411,7 +425,7 @@ def calc_point_on_bezier_spline( beziers, lengths, total_length = ( get_real_beziers_and_lengths(bezier_spline_obj, resolution_factor) - if not isinstance(bezier_spline_obj, BezierSpline) + if not isinstance(bezier_spline_obj, HookBezierSpline) else ( bezier_spline_obj.beziers, bezier_spline_obj.lengths, @@ -460,16 +474,15 @@ def calc_point_on_bezier_curve( output_spline_index=False, resolution_factor=1.0, ): - # bezier_curve = BezierCurve(bezier_curve_obj) - # curve = bezier_curve.data - curve = bezier_curve_obj.data + bezier_curve = HookBezierCurve(bezier_curve_obj) + curve = bezier_curve.data + # 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) - print(f"{utils.LINE()} {length=}") if total_length + length > distance or i == len(curve.splines) - 1: if output_spline_index and output_tangent: # return value from c_p_o_b_s is a tuple From a5602a6095a583720fbb06e5c0d55ad285956bae Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Thu, 5 Jun 2025 13:20:39 +0200 Subject: [PATCH 7/7] bump version number to v0.0.12 --- README.md | 2 +- __init__.py | 2 +- common/utils.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee15c47..c4fa035 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ / ___ \| |_) | |___ ___) | |_| | /_/ \_\____/ \____|____/|____/ ``` -v0.0.11 +v0.0.12 Convenience addon to work with 3D typography in Blender and Cinema4D. diff --git a/__init__.py b/__init__.py index 697e857..c4fc090 100644 --- a/__init__.py +++ b/__init__.py @@ -16,7 +16,7 @@ from .common import Font, utils bl_info = { "name": "ABC3D", "author": "Jakob Schlötter, Studio Pointer*", - "version": (0, 0, 11), + "version": (0, 0, 12), "blender": (4, 1, 0), "location": "VIEW3D", "description": "Convenience addon for 3D fonts", diff --git a/common/utils.py b/common/utils.py index 4a036f8..7798a3c 100644 --- a/common/utils.py +++ b/common/utils.py @@ -8,7 +8,7 @@ def get_version_minor(): def get_version_patch(): - return 11 + return 12 def get_version_string():