# SPDX-License-Identifier: GPL-2.0-only """ A 3D font helper """ bl_info = { "name": "Font3D", "author": "Jakob Schlötter, Studio Pointer*", "version": (0, 0, 1), "blender": (4, 1, 0), "location": "wherever it may be", "description": "Does Font3D stuff", "category": "Typography", } # make sure that modules are reloadable # when registering # handy for development # first import dependencies for the method import importlib # then import dependencies for our addon if "bpy" in locals(): importlib.reload(Font) importlib.reload(utils) importlib.reload(butils) else: from .common import Font from .common import utils from . import butils import bpy import queue import math import mathutils import io import functools from bpy.types import Panel from bpy.app.handlers import persistent from random import uniform import time import datetime import os import re class SharedVariables(): fonts = Font.fonts def __init__(self, **kv): self.__dict__.update(kv) def getPreferences(context): preferences = context.preferences return preferences.addons[__name__].preferences shared = SharedVariables() class FONT3D_addonPreferences(bpy.types.AddonPreferences): """Font3D Addon Preferences These are the preferences at Edit/Preferences/Add-ons""" bl_idname = __name__ def get_default_assets_dir(): return bpy.utils.user_resource( 'DATAFILES', path=f"{__name__}", create=True) def on_change_assets_dir(self, context): if not os.path.isdir(self.assets_dir): butils.ShowMessageBox( title=f"{__name__} Warning", icon="ERROR", message=("Chosen directory does not exist.", "Please, reset to default, create it or chose another one.")) elif not os.access(self.assets_dir, os.W_OK): butils.ShowMessageBox( title=f"{__name__} Warning", icon="ERROR", message=("Chosen directory is not writable.", "Please reset to default or chose another one.")) else: shared.paths["assets"] = self.assets_dir print(f"{__name__}: change assets_dir to {self.assets_dir}") assets_dir: bpy.props.StringProperty( name="Assets Folder", subtype='DIR_PATH', default=get_default_assets_dir(), update=on_change_assets_dir, ) def draw(self, context): layout = self.layout layout.label(text="Directory for storage of fonts and other assets:") layout.prop(self, "assets_dir") class FONT3D_OT_Font3D(bpy.types.Operator): """Font 3D""" bl_idname = "font3d.font3d" bl_label = "Font 3D" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): global shared print("Font3d execute()") scene = bpy.context.scene file_dir = scene.font3d.file_dir print(f"file_dir: {file_dir}") return {'FINISHED'} class FONT3D_settings(bpy.types.PropertyGroup): font_path: bpy.props.StringProperty( name="Font path", description="Where is the font", default="", maxlen=1024, subtype="FILE_PATH") import_infix: bpy.props.StringProperty( name="Font name import infix", description="The infix which all font objects to import have", default="_NM_", maxlen=1024, ) test_text: bpy.props.StringProperty( name="Test Text", description="the text to test with", default="HELLO", maxlen=1024, ) the_mother_of_typography: bpy.props.PointerProperty( name="The Mother Of Typography", description="The target, which will be populated by character children of text.", type=bpy.types.Object, ) letter_spacing: bpy.props.FloatProperty( name="Letter Spacing", description="Letter Spacing", default=0.0, options={'ANIMATABLE'}, ) class FONT3D_available_font(bpy.types.PropertyGroup): font_name: bpy.props.StringProperty(name="") class FONT3D_glyph_properties(bpy.types.PropertyGroup): glyph_id: bpy.props.StringProperty(maxlen=1) glyph_object: bpy.props.PointerProperty(type=bpy.types.Object) letter_spacing: bpy.props.FloatProperty( name="Letter Spacing", description="Letter Spacing", ) class FONT3D_text_properties(bpy.types.PropertyGroup): def update_callback(self, context): butils.set_text_on_curve(self) # TODO: update when animate # does not work like this, somehow it does not run in main thread when the text is actually being set # def get_float(self): # return self["letter_spacingor"] # def set_float(self, value): # print(f"{utils.get_timestamp()} setting float to {value}") # self["letter_spacingor"] = value # def fun(text_properties : FONT3D_text_properties): # # print(text_properties) # # print(type(text_properties)) # # print(text_properties.letter_spacing) # print(f"is running ---------------------------------->>>>>>> {text_properties.letter_spacing} and {text_properties.get('letter_spacingor')}") # # butils.set_text_on_curve(text_properties) # run_in_main_thread(lambda: fun(self)) text_id: bpy.props.IntProperty() font_name: bpy.props.StringProperty() font_face: bpy.props.StringProperty() text_object: bpy.props.PointerProperty(type=bpy.types.Object) text: bpy.props.StringProperty( update=update_callback ) letter_spacing: bpy.props.FloatProperty( # get=get_float, # set=set_float, update=update_callback, name="Letter Spacing", description="Letter Spacing", step=0.01, ) distribution_type: bpy.props.StringProperty() glyphs: bpy.props.CollectionProperty(type=FONT3D_glyph_properties) #TODO: simply, merge, cut cut cut class FONT3D_data(bpy.types.PropertyGroup): available_fonts: bpy.props.CollectionProperty(type=FONT3D_available_font, name="name of the collection property") active_font_index: bpy.props.IntProperty() available_texts: bpy.props.CollectionProperty(type=FONT3D_text_properties, name="") active_text_index: bpy.props.IntProperty() class FONT3D_UL_fonts(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): split = layout.split(factor=0.3) split.label(text="Index: %d" % (index)) # custom_icon = "OUTLINER_OB_%s" % item.obj_type # split.prop(item, "name", text="", emboss=False, translate=False) split.label(text=f"{item.font_name}") # avoids renaming the item by accident def invoke(self, context, event): pass class FONT3D_UL_texts(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): split = layout.split(factor=0.3) split.label(text="Id: %d" % (item.text_id)) # custom_icon = "OUTLINER_OB_%s" % item.obj_type # split.prop(item, "name", text="", emboss=False, translate=False) split.label(text=f"{item.text}") # avoids renaming the item by accident def invoke(self, context, event): pass # TODO: TODO: TODO: TODO: TODO # >>>>>>>>>>>>>>>> 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 # <<<<<<<<<<<<<<<<< TODO: TODO: TODO: TODO: TODO # class FONT3D_PT_panel(bpy.types.Panel): bl_label = "Panel for Font3D" bl_category = "Font3D" bl_space_type = "VIEW_3D" bl_region_type = "UI" @classmethod def poll(self, context): scene = context.scene font3d = scene.font3d font3d_data = scene.font3d_data # TODO: properly include this def update(): font3d_data.active_text_index = -1 remove_list = [] for i, t in enumerate(font3d_data.available_texts): if type(t.text_object) == type(None): remove_list.append(i) continue remove_me = True for c in t.text_object.children: if len(c.users_collection) > 0 and (c.get('linked_textobject')) != type(None) and c.get('linked_textobject') == t.text_id: remove_me = False # not sure how to solve this reliably atm, # we need to reassign the glyph, but also get the proper properties from glyph_properties # these might be there in t.glyphs, but linked to removed objects # or they might be lost if type(next((g for g in t.glyphs if type(g.glyph_object) == type(None)), None)) == type(None): g = next((g for g in t.glyphs if type(g.glyph_object) == type(None)), None) for g in t.glyphs: if type(g) == type(None): print("IS NONE") if type(g.glyph_object) == type(None): print("go IS NONE") else: if g.glyph_object == c: # print(g.glyph_object.name) pass if remove_me: remove_list.append(i) for i in remove_list: font3d_data.available_texts.remove(i) for i, t in enumerate(font3d_data.available_texts): if context.active_object == t.text_object: font3d_data.active_text_index = i if (hasattr(context.active_object, "parent") and context.active_object.parent == t.text_object): font3d_data.active_text_index = i run_in_main_thread(update) return True def draw(self, context): global shared layout = self.layout wm = context.window_manager scene = context.scene font3d = scene.font3d font3d_data = scene.font3d_data layout.label(text="Load FontFile:") layout.row().prop(font3d, "font_path") layout.row().operator('font3d.loadfont', text='Load Font') layout.label(text="Available Fonts") layout.template_list("FONT3D_UL_fonts", "", font3d_data, "available_fonts", font3d_data, "active_font_index") layout.label(text='DEBUG') layout.row().prop(font3d, "test_text") layout.row().prop(font3d, "the_mother_of_typography") layout.row().operator('font3d.testfont', text='Distribute') layout.label(text="Text Objects") layout.template_list("FONT3D_UL_texts", "", font3d_data, "available_texts", font3d_data, "active_text_index") layout.label(text="font properties") layout.row().prop(font3d, "letter_spacing") layout.label(text="font creation") layout.row().prop(font3d, "import_infix") layout.row().operator('font3d.create_font_from_objects', text='Create Font') layout.row().separator() layout.row().operator('font3d.save_font_to_file', text='Save Font To File') layout.row().operator('font3d.toggle_font3d_collection', text='Toggle Collection') layout.row().operator('font3d.temporaryhelper', text='Temporary Helper') layout.label(text='DEBUG END') class FONT3D_PT_TextPropertiesPanel(bpy.types.Panel): bl_label = "Text Properties" bl_parent_id = "FONT3D_PT_panel" bl_category = "Font3D" bl_space_type = "VIEW_3D" bl_region_type = "UI" def get_active_text_properties(self): if type(bpy.context.object) != type(None) and bpy.context.object.select_get(): for t in bpy.context.scene.font3d_data.available_texts: if bpy.context.object == t.text_object: return t if bpy.context.object.parent == t.text_object: return t return None @classmethod def poll(self,context): return type(self.get_active_text_properties(self)) != type(None) def draw(self, context): global shared layout = self.layout wm = context.window_manager scene = context.scene font3d = scene.font3d font3d_data = scene.font3d_data props = self.get_active_text_properties() if type(props) == type(None) or type(props.text_object) == type(None): # this should not happen print("debug: this should not happen") layout.label(text="AAAAH") return layout.label(text=f"Mom: {props.text_object.name}") layout.row().prop(props, "letter_spacing") class FONT3D_OT_LoadFont(bpy.types.Operator): """Load Font 3D""" bl_idname = "font3d.loadfont" bl_label = "Load Font" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): global shared scene = bpy.context.scene # print(f"loading da font at path {scene.font3d.font_path}") if not os.path.exists(scene.font3d.font_path): butils.ShowMessageBox( title=f"{__name__} Warning", icon="ERROR", message=f"We believe the font path ({scene.font3d.font_path}) does not exist.", ) return {'CANCELLED'} currentObjects = [] for ob in bpy.data.objects: currentObjects.append(ob.name) bpy.ops.import_scene.gltf(filepath=scene.font3d.font_path) newObjects = [] fontcollection = bpy.data.collections.new("Font3D") scene.collection.children.link(fontcollection) font = { "name": "", "glyphs": [] } for o in bpy.data.objects: if o.name not in currentObjects: if (o.parent == None): font['name'] = o.name elif o.parent.name.startswith("glyphs"): font['glyphs'].append(o) newObjects.append(o.name) scene.collection.objects.unlink(o) fontcollection.objects.link(o) try: shared.fonts except: shared.fonts = {} shared.fonts[font['name']] = Font.Font() shared.fonts[font['name']]['faces']['regular'] = font return {'FINISHED'} class FONT3D_OT_TemporaryHelper(bpy.types.Operator): """Temp Font 3D""" bl_idname = "font3d.temporaryhelper" bl_label = "Temp Font" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): global shared scene = bpy.context.scene font3d_data = scene.font3d_data font3d_data.available_texts.clear() return {'FINISHED'} class FONT3D_OT_TestFont(bpy.types.Operator): """Test Font 3D""" bl_idname = "font3d.testfont" bl_label = "Test Font" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): global shared scene = bpy.context.scene selected = bpy.context.view_layer.objects.active font3d = scene.font3d font3d_data = scene.font3d_data if font3d.the_mother_of_typography: selected = font3d.the_mother_of_typography if selected: font_name = "NM_Origin" font_face = "Tender" distribution_type = 'DEFAULT' text_id = 0 for i, tt in enumerate(font3d_data.available_texts): while text_id == tt.text_id: text_id = text_id + 1 t = font3d_data.available_texts.add() t.text_id = text_id t.font_name = font_name t.font_face = font_face t.text_object = selected t.text = scene.font3d.test_text t.letter_spacing = 0.0 t.distribution_type = distribution_type else: butils.ShowMessageBox( title="No object selected", message=( "Please select an object.", "It will be used to put the type on.", "You little piece of shit :)"), icon='GHOST_ENABLED') return {'FINISHED'} class FONT3D_OT_ToggleFont3DCollection(bpy.types.Operator): """Toggle Font3D Collection""" bl_idname = "font3d.toggle_font3d_collection" bl_label = "Toggle Collection visibility" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): scene = context.scene fontcollection = bpy.data.collections.get("Font3D") if fontcollection is None: self.report({'INFO'}, f"{bl_info['name']}: There is no collection") elif scene.collection.children.find(fontcollection.name) < 0: scene.collection.children.link(fontcollection) self.report({'INFO'}, f"{bl_info['name']}: show collection") else: scene.collection.children.unlink(fontcollection) self.report({'INFO'}, f"{bl_info['name']}: hide collection") return {'FINISHED'} class FONT3D_OT_SaveFontToFile(bpy.types.Operator): """Save font to file""" bl_idname = f"{__name__}.save_font_to_file" bl_label = "Save Font" bl_options = {'REGISTER', 'UNDO'} file_path = "whoopwhoop" def execute(self, context): global shared scene = bpy.context.scene font3d_data = scene.font3d_data font3d = scene.font3d fontcollection = bpy.data.collections.get("Font3D") # check if all is good to proceed if fontcollection is None: self.report({'INFO'}, f"{bl_info['name']}: There is no collection") return {'CANCELLED'} if font3d_data.active_font_index < 0: self.report({'INFO'}, f"{bl_info['name']}: There is no active font") return {'CANCELLED'} if len(font3d_data.available_fonts) <= font3d_data.active_font_index: self.report({'INFO'}, f"{bl_info['name']}: Active font is not available") return {'CANCELLED'} # save state to restore later was_fontcollection_linked = scene.collection.children.find(fontcollection.name) >= 0 was_selection = [] for obj in bpy.context.selected_objects: was_selection.append(obj) was_active_object = bpy.context.view_layer.objects.active bpy.ops.object.select_all(action="DESELECT") # get save data selected_font = font3d_data.available_fonts[font3d_data.active_font_index] # print(selected_font.font_name) self.report({'INFO'}, f"{bl_info['name']}: {selected_font.font_name}") preferences = getPreferences(context) print(f"assets folder: {preferences.assets_dir}") bpy.ops.scene.new(type='FULL_COPY') linked_collections = bpy.context.scene.collection.children.values() for c in linked_collections: bpy.context.scene.collection.children.unlink(c) bpy.context.scene.collection.children.link(fontcollection) # select what needs to be selected export_objects = [] for obj in fontcollection.objects: if obj["font_name"] == selected_font.font_name: obj.select_set(True) export_objects.append(obj) context_override = bpy.context.copy() context_override["selected_objects"] = list(export_objects) # context_override["scene"] = bpy.context.scene.copy() with bpy.context.temp_override(**context_override): filepath = f"{preferences.assets_dir}/fonts/{selected_font.font_name}.gltf" # get rid of scene extra data before export for k in bpy.context.scene.keys(): del bpy.context.scene[k] # save as gltf bpy.ops.export_scene.gltf( filepath=filepath, check_existing=False, export_format='GLTF_SEPARATE', export_extras=True, # export_hierarchy_full_collections=True, # use_active_collection_with_nested=True, use_selection=True, use_active_scene=True, ) # bpy.app.timers.register(lambda: bpy.ops.scene.delete(), first_interval=5) bpy.ops.scene.delete() # restore() return {'FINISHED'} # keep = ['io_anim_bvh', 'io_curve_svg', 'io_mesh_stl', 'io_mesh_uv_layout', 'io_scene_fbx', 'io_scene_gltf2', 'io_scene_x3d', 'cycles', 'pose_library', 'font3d'] # for addon in keep: # bpy.ops.preferences.addon_enable(module=addon) class FONT3D_OT_CreateFontFromObjects(bpy.types.Operator): """Create Font from open objects""" bl_idname = "font3d.create_font_from_objects" bl_label = "Create Font" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): global shared scene = bpy.context.scene font3d = scene.font3d font3d_data = scene.font3d_data fontcollection = bpy.data.collections.get("Font3D") if fontcollection is None: fontcollection = bpy.data.collections.new("Font3D") ifxsplit = font3d.import_infix.split('_') font_name = f"{ifxsplit[1]}_{ifxsplit[2]}" face_name = ifxsplit[3] added_font = False font3d_data.available_fonts.clear() Font.fonts = {} currentObjects = [] for o in bpy.data.objects: if o.name not in currentObjects: if font3d.import_infix in o.name: uc = o.users_collection regex = f"{font3d.import_infix}(.)*" name = re.sub(regex, "", o.name) glyph_id = "unknown" if len(name) == 1: glyph_id = name elif name in Font.name_to_glyph_d: glyph_id = Font.name_to_glyph_d[name] if glyph_id != "unknown": o["glyph"] = glyph_id o["font_name"] = font_name o["face_name"] = face_name # butils.apply_all_transforms(o) butils.move_in_fontcollection( o, fontcollection) Font.add_glyph( font_name, face_name, glyph_id, o) added_font = True #TODO: is there a better way to iterate over a CollectionProperty? 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 else: self.report({'INFO'}, f"did not understand glyph {name}") return {'FINISHED'} class FONT3D_PT_RightPropertiesPanel(bpy.types.Panel): """Creates a Panel in the Object properties window""" bl_label = f"{__name__}" bl_idname = "FONT3D_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.font3d_data.available_texts if t.text_object == context.active_object), None)) != type(None) is_glyph = type(next((t for t in context.scene.font3d_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 font3d = scene.font3d font3d_data = scene.font3d_data obj = context.active_object def is_text(): return type(next((t for t in context.scene.font3d_data.available_texts if t.text_object == context.active_object), None)) != type(None) def is_glyph(): return type(next((t for t in context.scene.font3d_data.available_texts if t.text_object == context.active_object.parent), None)) != type(None) textobject = obj if is_text() else obj.parent if is_glyph() else obj available_text = font3d_data.available_texts[font3d_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: {font3d_data.active_text_index}") row = layout.row() row.prop(available_text, "text") row = layout.row() row.prop(available_text, "letter_spacing") classes = ( FONT3D_addonPreferences, FONT3D_OT_Font3D, FONT3D_available_font, FONT3D_glyph_properties, FONT3D_text_properties, FONT3D_data, FONT3D_settings, FONT3D_UL_fonts, FONT3D_UL_texts, FONT3D_PT_panel, FONT3D_PT_TextPropertiesPanel, FONT3D_OT_TemporaryHelper, FONT3D_OT_TestFont, FONT3D_OT_LoadFont, FONT3D_OT_ToggleFont3DCollection, FONT3D_OT_SaveFontToFile, FONT3D_OT_CreateFontFromObjects, FONT3D_PT_RightPropertiesPanel, ) @persistent def load_handler(self, dummy): bpy.app.timers.register(execute_queued_functions) def load_handler_unload(): bpy.app.timers.unregister(execute_queued_functions) def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.Scene.font3d = bpy.props.PointerProperty(type=FONT3D_settings) bpy.types.Scene.font3d_data = bpy.props.PointerProperty(type=FONT3D_data) # bpy.types.Object.__del__ = lambda self: print(f"Bye {self.name}") print(f"REGISTER {bl_info['name']}") # auto start if load_handler not in bpy.app.handlers.load_post: bpy.app.handlers.load_post.append(load_handler) # clear available fonts def clear_available_fonts(): bpy.context.scene.font3d_data.available_fonts.clear() def load_available_fonts(): global shared 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"): font_path = os.path.join(font_dir, file) bpy.ops.import_scene.gltf(filepath=font_path) 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 currentObjects: # 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"] butils.move_in_fontcollection( o, fontcollection) Font.add_glyph( font_name, face_name, glyph_id, o) font3d_data = bpy.context.scene.font3d_data 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"font3d added {font_name}") else: remove_list.append(o) for o in remove_list: bpy.data.objects.remove(o, do_unlink=True) run_in_main_thread(clear_available_fonts) run_in_main_thread(load_available_fonts) def unregister(): for cls in classes: bpy.utils.unregister_class(cls) if load_handler in bpy.app.handlers.load_post: bpy.app.handlers.load_post.remove(load_handler) load_handler_unload() del bpy.types.Scene.font3d del bpy.types.Scene.font3d_data print(f"UNREGISTER {bl_info['name']}") if __name__ == '__main__': register()