font3d_blender_addon/butils.py

626 lines
23 KiB
Python
Raw Normal View History

2024-05-21 18:00:49 +02:00
import bpy
2024-06-27 14:56:43 +02:00
import mathutils
import queue
2024-06-27 14:56:43 +02:00
import importlib
2024-08-04 12:52:37 +02:00
import os
2024-05-21 18:00:49 +02:00
2024-06-27 14:56:43 +02:00
# then import dependencies for our addon
if "Font" in locals():
importlib.reload(Font)
else:
from .common import Font
2024-07-01 10:25:17 +02:00
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
2024-06-27 14:56:43 +02:00
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()
2024-05-21 18:00:49 +02:00
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
2024-06-27 14:56:43 +02:00
# Ensure it's a curve object
# TODO: no raising, please
2024-07-01 14:39:07 +02:00
def get_curve_length(curve_obj, resolution = -1):
2024-06-27 14:56:43 +02:00
total_length = 0
2024-07-01 10:25:17 +02:00
curve = curve_obj.data
2024-06-27 14:56:43 +02:00
# Loop through all splines in the curve
for spline in curve.splines:
2024-07-01 14:39:07 +02:00
total_length = total_length + spline.calc_length(resolution=resolution)
2024-06-27 14:56:43 +02:00
return total_length
2024-07-02 12:22:24 +02:00
def get_curve_line_lengths(curve_obj, resolution = -1):
2024-08-05 10:19:51 +02:00
lengths = []
2024-07-02 12:22:24 +02:00
for spline in curve_obj.data.splines:
2024-08-05 10:19:51 +02:00
lengths.append(spline.calc_length(resolution=resolution))
return lengths
2024-07-02 12:22:24 +02:00
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
2024-07-01 10:25:17 +02:00
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
2024-08-05 12:54:26 +02:00
# 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
2024-07-01 10:25:17 +02:00
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()
2024-07-01 10:25:17 +02:00
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
2024-08-04 15:13:05 +02:00
for i in range(-1, resolution):
2024-07-01 10:25:17 +02:00
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]
2024-07-01 14:39:07 +02:00
# print(f"i: {i}, d: {d}, distance_on_bezier: {distance_on_bezier}, distance: {distance}")
2024-07-01 10:25:17 +02:00
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?
2024-06-27 14:56:43 +02:00
# 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]
2024-05-21 18:00:49 +02:00
def turn_collection_hierarchy_into_path(obj):
2024-06-27 14:56:43 +02:00
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
2024-08-04 12:52:37 +02:00
def move_in_fontcollection(obj, fontcollection, allow_duplicates=False):
2024-06-27 14:56:43 +02:00
# 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)
2024-06-27 14:56:43 +02:00
# 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"]
2024-08-04 12:52:37 +02:00
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
2024-06-27 14:56:43 +02:00
# and now parent it!
if obj.parent != glyphs_obj:
obj.parent = glyphs_obj
2024-05-28 14:11:32 +02:00
2024-08-04 12:52:37 +02:00
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)
print("all objects", len(all_objects))
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()):
print("adding glyph", o)
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)
print("add to remove list", o)
for o in remove_list:
bpy.data.objects.remove(o, do_unlink=True)
print("removed",o)
2024-08-05 12:54:01 +02:00
print(f"{__name__}: loaded fonts")
2024-08-04 12:52:37 +02:00
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)
2024-05-28 14:11:32 +02:00
def ShowMessageBox(title = "Message Box", icon = 'INFO', message=""):
"""Show a simple message box
taken from `Link here <https://blender.stackexchange.com/questions/169844/multi-line-text-box-with-popup-menu>`_
: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 <https://docs.blender.org/api/current/bpy_types_enum_items/icon_items.html>`_ for icons you can use
TIP: Or even better, check `Link this addons <https://docs.blender.org/manual/en/latest/addons/development/icon_viewer.html>`_ 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)
2024-06-27 14:56:43 +02:00
def set_text_on_curve(text_properties):
mom = text_properties.text_object
if mom.type != "CURVE":
return False
regenerate = False
2024-06-27 14:56:43 +02:00
glyph_objects = []
for g in 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) != 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()
2024-06-27 14:56:43 +02:00
#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
2024-07-02 12:22:24 +02:00
glyph_advance = 0
is_command = False
2024-06-27 14:56:43 +02:00
for i, c in enumerate(text_properties.text):
2024-07-02 12:22:24 +02:00
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:
2024-08-04 12:52:37 +02:00
# 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")
2024-07-02 12:22:24 +02:00
advance = next_line_advance
continue
is_command = False
2024-06-27 14:56:43 +02:00
glyph_id = c
2024-06-27 14:56:43 +02:00
glyph = Font.get_glyph(text_properties.font_name,
text_properties.font_face,
glyph_id)
ob = None
if regenerate:
if glyph == None:
2024-08-04 12:52:37 +02:00
# self.report({'ERROR'}, f"Glyph not found for {font_name} {font_face} {glyph_id}")
print(f"Glyph not found for {font_name} {font_face} {glyph_id}")
continue
2024-06-27 14:56:43 +02:00
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']
2024-06-27 14:56:43 +02:00
2024-07-01 14:39:07 +02:00
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
mask = [0]
input_rotations = [mathutils.Vector((0.0, 0.0, 0.0))]
2024-07-01 14:39:07 +02:00
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()
2024-08-04 12:52:37 +02:00
q.rotate(text_properties.orientation)
ob.rotation_quaternion = (mom.matrix_world @ motor[0] @ q.to_matrix().to_4x4()).to_quaternion()
2024-06-27 14:56:43 +02:00
scalor = 0.001
glyph_advance = (-1 * glyph.bound_box[0][0] + glyph.bound_box[4][0]) * scalor + text_properties.letter_spacing
2024-08-05 12:56:30 +02:00
# 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)
2024-06-27 14:56:43 +02:00
ob.scale = (scalor, scalor, scalor)
2024-08-05 12:56:30 +02:00
advance = advance + glyph_advance + curve_compensation
2024-06-27 14:56:43 +02:00
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')
2024-06-27 14:56:43 +02:00
return True