From bae49a234645a408e0d207a283ef12e571c94b39 Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Thu, 21 Nov 2024 14:59:26 +0100 Subject: [PATCH 1/5] remove right panel --- __init__.py | 60 ----------------------------------------------------- 1 file changed, 60 deletions(-) diff --git a/__init__.py b/__init__.py index c8575d2..0943139 100644 --- a/__init__.py +++ b/__init__.py @@ -1417,65 +1417,6 @@ class ABC3D_OT_CreateFontFromObjects(bpy.types.Operator): return {'FINISHED'} -class ABC3D_PT_RightPropertiesPanel(bpy.types.Panel): - """Creates a Panel in the Object properties window""" - bl_label = f"{bl_info['name']}" - bl_idname = "ABC3D_PT_RightPropertiesPanel" - bl_space_type = 'PROPERTIES' - bl_region_type = 'WINDOW' - bl_context = "object" - - @classmethod - def poll(self, context): - # only show the panel, if it's a textobject or a glyph - is_text = type(next((t for t in context.scene.abc3d_data.available_texts if t.text_object == - context.active_object), None)) != type(None) - is_glyph = type(next((t for t in context.scene.abc3d_data.available_texts if t.text_object == - context.active_object.parent), None)) != type(None) - return is_text or is_glyph - - def draw(self, context): - layout = self.layout - scene = context.scene - abc3d_data = scene.abc3d_data - - obj = context.active_object - - def is_it_text(): - return type(next((t for t in context.scene.abc3d_data.available_texts if t.text_object == context.active_object), None)) != type(None) - - def is_it_glyph(): - return type(next((t for t in context.scene.abc3d_data.available_texts if t.text_object == context.active_object.parent), None)) != type(None) - - is_text = is_it_text() - is_glyph = is_it_glyph() - - textobject = obj if is_text else obj.parent if is_glyph else obj - available_text = abc3d_data.available_texts[abc3d_data.active_text_index] - - # row = layout.row() - # row.label(text="Hello world!", icon='WORLD_DATA') - # row = layout.row() - # row.label(text="Active object is: " + obj.name) - # row = layout.row() - # row.label(text="text object is: " + textobject.name) - row = layout.row() - row.label(text=f"active text index is: {abc3d_data.active_text_index}") - - layout.row().label(text="Text Properties:") - layout.row().prop(available_text, "text") - layout.row().prop(available_text, "letter_spacing") - layout.row().prop(available_text, "font_size") - layout.row().prop(available_text, "offset") - layout.row().prop(available_text, "compensate_curvature") - layout.row().prop(available_text, "ignore_orientation") - layout.column().prop(available_text, "translation") - layout.column().prop(available_text, "orientation") - - if is_glyph: - layout.row().label(text="Glyph Properties:") - - class ABC3D_OT_Reporter(bpy.types.Operator): bl_idname = f"{__name__}.reporter" bl_label = "Report" @@ -1528,7 +1469,6 @@ classes = ( ABC3D_OT_ToggleABC3DCollection, ABC3D_OT_SaveFontToFile, ABC3D_OT_CreateFontFromObjects, - ABC3D_PT_RightPropertiesPanel, ABC3D_OT_Reporter, ) From 9b20b703dc03e824450f119d455954cb323e9dc2 Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Thu, 21 Nov 2024 15:04:08 +0100 Subject: [PATCH 2/5] cleanup --- __init__.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/__init__.py b/__init__.py index 0943139..935fc2b 100644 --- a/__init__.py +++ b/__init__.py @@ -311,7 +311,6 @@ class ABC3D_UL_texts(bpy.types.UIList): def invoke(self, context, event): pass - class ABC3D_PT_Panel(bpy.types.Panel): bl_label = f"{__name__} panel" bl_category = "ABC3D" @@ -333,27 +332,6 @@ class ABC3D_PT_Panel(bpy.types.Panel): text="open asset directory", icon='FILEBROWSER') -# class ABC3D_PT_LoadFontPanel(bpy.types.Panel): - # bl_label = "Install a new font" - # bl_parent_id = "ABC3D_PT_Panel" - # bl_category = "ABC3D" - # bl_space_type = "VIEW_3D" - # bl_region_type = "UI" - - # def draw(self, context): - # layout = self.layout - # wm = context.window_manager - # scene = context.scene - - # abc3d_data = scene.abc3d_data - - # box = layout.box() - # box.row().label(text="1. Select fontfile") - # box.row().prop(context.scene.abc3d_data, "font_path") - # box.row().label(text="2. Install it:") - # box.row().operator(f"{__name__}.install_font", text='Install new font') - - class ABC3D_PT_FontList(bpy.types.Panel): bl_label = "Font List" bl_parent_id = "ABC3D_PT_Panel" @@ -1449,7 +1427,6 @@ classes = ( ABC3D_UL_fonts, ABC3D_UL_texts, ABC3D_PT_Panel, - # ABC3D_PT_LoadFontPanel, ABC3D_PT_FontList, ABC3D_PT_TextPlacement, ABC3D_PT_TextManagement, From b9bd72f97939a4b2785f1c4a48a0d2caf0179b54 Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Sat, 7 Dec 2024 14:28:37 +0100 Subject: [PATCH 3/5] remove outdated comments --- common/Font.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/common/Font.py b/common/Font.py index ef34499..aa936db 100644 --- a/common/Font.py +++ b/common/Font.py @@ -120,9 +120,6 @@ class Font: self.faces = faces -# TODO: better class structure? -# TODO: get fonts and faces directly - def register_font(font_name, face_name, glyphs_in_fontfile, filepath): if not fonts.keys().__contains__(font_name): fonts[font_name] = Font({}) From 20fb69465bb37ac7b070710ed52247ae61b88e91 Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Sat, 7 Dec 2024 14:57:33 +0100 Subject: [PATCH 4/5] manual positioning closes #1 --- __init__.py | 35 +++++--- addon_updater.py | 1 - butils.py | 204 +++++++++++++++++++++++++++++++++-------------- 3 files changed, 168 insertions(+), 72 deletions(-) diff --git a/__init__.py b/__init__.py index 935fc2b..5e40091 100644 --- a/__init__.py +++ b/__init__.py @@ -383,10 +383,10 @@ class ABC3D_PT_FontList(bpy.types.Panel): row.scale_y = scale_y row.label(text=text) row = layout.row() - oper = row.operator(f"{__name__}.load_font", + oper_lf = row.operator(f"{__name__}.load_font", text='Load all glyphs in memory') - oper.font_name = font_name - oper.face_name = face_name + oper_lf.font_name = font_name + oper_lf.face_name = face_name class ABC3D_PT_TextPlacement(bpy.types.Panel): @@ -914,6 +914,7 @@ class ABC3D_OT_RemoveText(bpy.types.Operator): def delif(o, p): if p in o: del o[p] + delif(mom, f"{utils.prefix()}_type") delif(mom, f"{utils.prefix()}_linked_textobject") delif(mom, f"{utils.prefix()}_font_name") delif(mom, f"{utils.prefix()}_face_name") @@ -1046,7 +1047,7 @@ class ABC3D_OT_PlaceText(bpy.types.Operator): # t.text) # or this: # butils.set_text_on_curve(t) - # else: + else: butils.ShowMessageBox( title="No object selected", message=( @@ -1372,7 +1373,7 @@ class ABC3D_OT_CreateFontFromObjects(bpy.types.Operator): font_name, face_name, glyph_id, - o) + bpy.types.PointerProperty(o)) # TODO: is there a better way to iterate over a CollectionProperty? found = False @@ -1484,7 +1485,6 @@ def detect_text(): def load_used_glyphs(): - print("LOAD USED GLYPHS") scene = bpy.context.scene abc3d_data = scene.abc3d_data for t in abc3d_data.available_texts: @@ -1528,19 +1528,33 @@ def on_frame_changed(self, dummy): # TODO PERFORMANCE: only on demand butils.set_text_on_curve(t) +depsgraph_updates_locked = False +def unlock_depsgraph_updates(): + global depsgraph_updates_locked + depsgraph_updates_locked = False + +def lock_depsgraph_updates(): + global depsgraph_updates_locked + depsgraph_updates_locked = True + if bpy.app.timers.is_registered(unlock_depsgraph_updates): + bpy.app.timers.unregister(unlock_depsgraph_updates) + bpy.app.timers.register(unlock_depsgraph_updates, first_interval=1) + +import time @persistent def on_depsgraph_update(scene, depsgraph): - if not bpy.context.mode.startswith("EDIT"): + global depsgraph_updates_locked + if not bpy.context.mode.startswith("EDIT") and not depsgraph_updates_locked: for u in depsgraph.updates: if f"{utils.prefix()}_linked_textobject" in u.id.keys() \ and f"{utils.prefix()}_type" in u.id.keys() \ and u.id[f"{utils.prefix()}_type"] == 'textobject': linked_textobject = u.id[f"{utils.prefix()}_linked_textobject"] if u.is_updated_geometry and len(scene.abc3d_data.available_texts) > linked_textobject: + lock_depsgraph_updates() def later(): if not "lock_depsgraph_update_ntimes" in scene.abc3d_data \ - or scene.abc3d_data["lock_depsgraph_update_ntimes"] == 0: - print("******* not yet") + or scene.abc3d_data["lock_depsgraph_update_ntimes"] <= 0: butils.set_text_on_curve( scene.abc3d_data.available_texts[linked_textobject]) elif scene.abc3d_data["lock_depsgraph_update_ntimes"] > 0: @@ -1596,6 +1610,9 @@ def unregister(): if on_frame_changed in bpy.app.handlers.frame_change_post: bpy.app.handlers.frame_change_post.remove(on_frame_changed) + if on_depsgraph_update in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(on_depsgraph_update) + del bpy.types.Scene.abc3d_data print(f"UNREGISTER {bl_info['name']}") diff --git a/addon_updater.py b/addon_updater.py index ca9e6d1..3ca5a3e 100644 --- a/addon_updater.py +++ b/addon_updater.py @@ -1782,7 +1782,6 @@ class ForgejoEngine: "zipball_url": self.get_zip_url(tag["commit"]["sha"], updater) } for tag in response] - # ----------------------------------------------------------------------------- # The module-shared class instance, # should be what's imported to other files diff --git a/butils.py b/butils.py index 00e5b14..b3b5e00 100644 --- a/butils.py +++ b/butils.py @@ -1,11 +1,12 @@ -import bpy -import mathutils -import queue import importlib import os +import queue import re from multiprocessing import Process +import bpy +import mathutils + # import time # for debugging performance # then import dependencies for our addon @@ -111,7 +112,8 @@ def calc_tangent_on_bezier(bezier_point_1, bezier_point_2, t): (-3 * (t**2) + 6 * t * (1 - t)) * h2 + (3 * t**2) * p2 ).normalized() -from math import radians, sqrt, pi, acos +from math import acos, pi, radians, sqrt + 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))] @@ -449,6 +451,7 @@ def load_font_from_filepath(filepath, glyphs="", font_name="", face_name=""): glyph_obj = move_in_fontcollection( o, fontcollection) + glyph_obj_pointer = bpy.types.PointerProperty(glyph_obj) if glyph_obj == o: del o[marker_property] @@ -457,7 +460,7 @@ def load_font_from_filepath(filepath, glyphs="", font_name="", face_name=""): font_name, face_name, glyph_id, - glyph_obj) + glyph_obj_pointer) for c in o.children: if is_metrics_object(c): add_metrics_obj_from_bound_box(glyph_obj, @@ -472,7 +475,7 @@ def load_font_from_filepath(filepath, glyphs="", font_name="", face_name=""): for g in face.glyphs: # iterate alternates for glyph in face.glyphs[g]: - glyphs.append(glyph) + glyphs.append(get_original(glyph)) if len(glyphs) > 0: add_default_metrics_to_objects(glyphs) # calculate unit factor @@ -586,18 +589,19 @@ def ShowMessageBox(title = "Message Box", icon = 'INFO', message=""): bpy.context.window_manager.popup_menu(draw, title = title, icon = icon) def simply_delete_objects(objs): - context_override = bpy.context.copy() - context_override["selected_objects"] = list(objs) - with bpy.context.temp_override(**context_override): - bpy.ops.object.delete() + completely_delete_objects(objs) -def completely_delete_objects(objs): - simply_delete_objects(objs) - - # remove deleted objects - # this is necessary +def completely_delete_objects(objs, recursive=True): for g in objs: if type(g) != type(None): + if recursive: + try: + if hasattr(g, "children") and len(g.children) > 0: + completely_delete_objects(g.children) + except ReferenceError as e: + # not important + pass + try: bpy.data.objects.remove(g, do_unlink=True) except ReferenceError as e: @@ -672,51 +676,80 @@ def prepare_text(font_name, face_name, text): return True def is_bezier(curve): + if curve.type != 'CURVE': + return False if len(curve.data.splines) < 1: return False - return curve.data.splines[0].type == 'BEZIER' + for spline in curve.data.splines: + if spline.type != 'BEZIER': + return False + return True + +def will_regenerate(text_properties): + mom = text_properties.text_object + + if len(text_properties.text) != len(text_properties.glyphs): + return True + + for i, g in enumerate(text_properties.glyphs): + if not hasattr(g.glyph_object, "type"): + return True + elif g.glyph_object.type != 'EMPTY': + return True + # check if perhaps one glyph was deleted + elif type(g.glyph_object) == type(None): + return True + elif type(g.glyph_object.parent) == type(None): + return True + elif g.glyph_object.parent.users_collection != g.glyph_object.users_collection: + return True + elif len(text_properties.text) > i and g.glyph_id != text_properties.text[i]: + return True + elif len(text_properties.text) > i and (g.glyph_object[f"{utils.prefix()}_font_name"] != text_properties.font_name + or g.glyph_object[f"{utils.prefix()}_face_name"] != text_properties.face_name): + return True + + return False + + +def set_text_on_curve(text_properties, reset_timeout_s=0.1, reset_depsgraph_n=4): + """set_text_on_curve + + An earlier reset cancels the other. + To disable reset, set both to false. + + :param text_properties: all information necessary to set text on a curve + :type text_properties: ABC3D_text_properties + :param reset_timeout_s: reset external parameters after timeout. (<= 0) = immediate, (> 0) = non-blocking reset timeout in seconds, (False) = no timeout reset + :type reset_timeout_s: float + :param reset_depsgraph_n: reset external parameters after n-th depsgraph update. (<= 0) = immediate, (> 0) = reset after n-th depsgraph update, (False) = no depsgraph reset + :type reset_depsgraph_n: int + """ -def set_text_on_curve(text_properties, recursive=True): # 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) + distribution_type = 'CALCULATE' if is_bezier(mom) else 'FOLLOW_PATH' - # 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 - elif len(text_properties.text) > i and g.glyph_id != text_properties.text[i]: - regenerate = True - elif len(text_properties.text) > i and (g.glyph_object[f"{utils.prefix()}_font_name"] != text_properties.font_name - or g.glyph_object[f"{utils.prefix()}_face_name"] != text_properties.face_name): - regenerate = True - - if len(text_properties.text) != len(text_properties.glyphs): - regenerate = True - - # blender bug + # use_path messes with parenting + # however, we need it for follow_path # https://projects.blender.org/blender/blender/issues/100661 - if mom.data.use_path: - regenerate = True + 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 = will_regenerate(text_properties) # if we regenerate.... delete objects - if regenerate: - completely_delete_objects(glyph_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() -#TODO: fix selection with context_override - previous_selection = bpy.context.selected_objects if hasattr(bpy.context, "selected_objects") else [ o for o in bpy.context.scene.objects if o.select_get() ] - bpy.ops.object.select_all(action='DESELECT') - selected_objects = [] - curve_length = get_curve_length(mom) advance = text_properties.offset glyph_advance = 0 @@ -747,7 +780,7 @@ def set_text_on_curve(text_properties, recursive=True): glyph = Font.get_glyph(text_properties.font_name, text_properties.face_name, - glyph_id) + glyph_id).original if glyph == None: # self.report({'ERROR'}, f"Glyph not found for {font_name} {face_name} {glyph_id}") @@ -755,16 +788,20 @@ def set_text_on_curve(text_properties, recursive=True): continue ob = None + obg = None if regenerate: - ob = bpy.data.objects.new(f"{glyph_id}", glyph.data) + ob = bpy.data.objects.new(f"{glyph_id}", None) + obg = bpy.data.objects.new(f"{glyph_id}_mesh", glyph.data) ob[f"{utils.prefix()}_type"] = "glyph" ob[f"{utils.prefix()}_linked_textobject"] = text_properties.text_id ob[f"{utils.prefix()}_font_name"] = text_properties.font_name ob[f"{utils.prefix()}_face_name"] = text_properties.face_name else: ob = text_properties.glyphs[i].glyph_object + for c in ob.children: + if c.name.startswith(f"{glyph_id}_mesh"): + obg = c - distribution_type = 'CALCULATE' if is_bezier(mom) else 'FOLLOW_PATH' if distribution_type == 'FOLLOW_PATH': ob.constraints.new(type='FOLLOW_PATH') ob.constraints["Follow Path"].target = mom @@ -773,6 +810,7 @@ def set_text_on_curve(text_properties, recursive=True): ob.constraints["Follow Path"].use_curve_follow = True ob.constraints["Follow Path"].forward_axis = "FORWARD_X" ob.constraints["Follow Path"].up_axis = "UP_Y" + spline_index = 0 elif distribution_type == 'CALCULATE': location, tangent, spline_index = calc_point_on_bezier_curve(mom, advance, True, True) if spline_index != previous_spline_index: @@ -780,6 +818,11 @@ def set_text_on_curve(text_properties, recursive=True): if regenerate: ob.location = mom.matrix_world @ (location + text_properties.translation) + mom.users_collection[0].objects.link(obg) + mom.users_collection[0].objects.link(ob) + ob.parent = mom + obg.parent = ob + obg.location = mathutils.Vector((0.0, 0.0, 0.0)) else: ob.location = (location + text_properties.translation) @@ -796,16 +839,20 @@ def set_text_on_curve(text_properties, recursive=True): local_main_axis) if ob.rotation_mode != 'QUATERNION': ob.rotation_mode = 'QUATERNION' + if obg.rotation_mode != 'QUATERNION': + obg.rotation_mode = 'QUATERNION' q = mathutils.Quaternion() q.rotate(text_properties.orientation) if regenerate: - ob.rotation_quaternion = (mom.matrix_world @ motor[0] @ q.to_matrix().to_4x4()).to_quaternion() + obg.rotation_quaternion = q + ob.rotation_quaternion = (mom.matrix_world @ motor[0]).to_quaternion() else: - ob.rotation_quaternion = (motor[0] @ q.to_matrix().to_4x4()).to_quaternion() + ob.rotation_quaternion = motor[0].to_quaternion() else: q = mathutils.Quaternion() q.rotate(text_properties.orientation) - ob.rotation_quaternion = q + # obg.rotation_quaternion = q + obg.rotation_quaternion = (mom.matrix_world @ q.to_matrix().to_4x4()).to_quaternion() # ob.rotation_quaternion = (mom.matrix_world @ q.to_matrix().to_4x4()).to_quaternion() @@ -839,17 +886,12 @@ def set_text_on_curve(text_properties, recursive=True): previous_spline_index = spline_index 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) - # https://projects.blender.org/blender/blender/issues/100661 - mom.data.use_path = False mom[f"{utils.prefix()}_type"] = "textobject" mom[f"{utils.prefix()}_linked_textobject"] = text_properties.text_id mom[f"{utils.prefix()}_font_name"] = text_properties.font_name @@ -858,10 +900,42 @@ def set_text_on_curve(text_properties, recursive=True): mom[f"{utils.prefix()}_letter_spacing"] = text_properties.letter_spacing mom[f"{utils.prefix()}_orientation"] = text_properties.orientation mom[f"{utils.prefix()}_translation"] = text_properties.translation - bpy.context.view_layer.objects.active = mom - bpy.ops.object.parent_set(type='OBJECT') - bpy.context.scene.abc3d_data["lock_depsgraph_update_ntimes"] = len(bpy.context.selected_objects) - mom["lock_depsgraph_update_ntimes"] = len(bpy.context.selected_objects) + + if "lock_depsgraph_update_ntimes" in bpy.context.scene.abc3d_data: + bpy.context.scene.abc3d_data["lock_depsgraph_update_ntimes"] += len(bpy.context.selected_objects) + else: + bpy.context.scene.abc3d_data["lock_depsgraph_update_ntimes"] = len(bpy.context.selected_objects) + + # NOTE: we reset with a timeout, as setting and resetting certain things + # in fast succession will cause visual glitches (e.g. {}.data.use_path). + def reset(): + mom.data.use_path = previous_use_path + if counted_reset in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(counted_reset) + if bpy.app.timers.is_registered(reset): + bpy.app.timers.unregister(reset) + + molotov = reset_depsgraph_n + 0 + def counted_reset(scene, depsgraph): + nonlocal molotov + if molotov == 0: + reset() + else: + molotov -= 1 + + # unregister previous resets to avoid multiple execution + if bpy.app.timers.is_registered(reset): + bpy.app.timers.unregister(reset) + if counted_reset in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(counted_reset) + + if not isinstance(reset_timeout_s, bool): + if reset_timeout_s > 0: + bpy.app.timers.register(reset, first_interval=reset_timeout_s) + elif reset_timeout <= 0: + reset() + + bpy.app.handlers.depsgraph_update_post.append(counted_reset) # endtime = time.perf_counter_ns() # elapsedtime = endtime - starttime @@ -1095,6 +1169,12 @@ def get_metrics_object(o): return c return None +def get_original(o): + if hasattr(o, "original"): + return o.original + else: + return o + def add_default_metrics_to_objects(objects=None, overwrite_existing=False): if type(objects) == type(None): objects=bpy.context.selected_objects From 648d4a6dee0b938be8859a1aed65784d534110ca Mon Sep 17 00:00:00 2001 From: themancalledjakob Date: Sat, 7 Dec 2024 15:07:11 +0100 Subject: [PATCH 5/5] bump version v0.0.3 --- __init__.py | 3 ++- common/utils.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index 5e40091..3fe519a 100644 --- a/__init__.py +++ b/__init__.py @@ -15,12 +15,13 @@ import importlib bl_info = { "name": "ABC3D", "author": "Jakob Schlötter, Studio Pointer*", - "version": (0, 0, 2), + "version": (0, 0, 3), "blender": (4, 1, 0), "location": "VIEW3D", "description": "Convenience addon for 3D fonts", "category": "Typography", } +# NOTE: also change version in common/utils.py # make sure that modules are reloadable # when registering diff --git a/common/utils.py b/common/utils.py index e9757b4..1e815e8 100644 --- a/common/utils.py +++ b/common/utils.py @@ -1,10 +1,10 @@ - +# NOTE: also change version in ../__init__.py def get_version_major(): return 0 def get_version_minor(): return 0 def get_version_patch(): - return 2 + return 3 def get_version_string(): return f"{get_version_major()}.{get_version_minor()}.{get_version_patch}" def prefix():