font3d_blender_addon/butils.py

2562 lines
85 KiB
Python

import importlib
import os
import queue
import re
import bpy
import bpy_types
import mathutils
# import time # for debugging performance
# then import dependencies for our addon
if "Font" in locals():
importlib.reload(Font)
else:
from .common import Font
if "utils" in locals():
importlib.reload(utils)
else:
from .common import utils
execution_queue = queue.Queue()
lock_depsgraph_update_n_times = -1
# This function can safely be called in another thread.
# The function will be executed when the timer runs the next time.
def run_in_main_thread(function):
execution_queue.put(function)
def execute_queued_functions():
while not execution_queue.empty():
function = execution_queue.get()
function()
return 1.0
def apply_all_transforms(obj):
mb = obj.matrix_basis
if hasattr(obj.data, "transform"):
obj.data.transform(mb)
for c in obj.children:
c.matrix_local = mb @ c.matrix_local
obj.matrix_basis.identity()
# broken
# 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
def get_key(key):
return f"{utils.prefix()}_{key}"
# Ensure it's a curve object
# TODO: no raising, please
def get_curve_length(curve_obj, resolution=-1):
total_length = 0
curve = curve_obj.data
# Loop through all splines in the curve
for spline in curve.splines:
total_length = total_length + spline.calc_length(resolution=resolution)
return total_length
def get_curve_line_lengths(curve_obj, resolution=-1):
lengths = []
for spline in curve_obj.data.splines:
lengths.append(spline.calc_length(resolution=resolution))
return lengths
def get_next_line_advance(
curve_obj, current_advance, previous_glyph_advance, resolution=-1
):
curve_line_lengths = get_curve_line_lengths(curve_obj, resolution)
total_length = 0
for cll in curve_line_lengths:
total_length += cll
if current_advance - previous_glyph_advance < total_length:
return total_length
return current_advance
def calc_point_on_bezier(bezier_point_1, bezier_point_2, t):
p1 = bezier_point_1.co
h1 = bezier_point_1.handle_right
p2 = bezier_point_2.co
h2 = bezier_point_2.handle_left
if p1 == h1 and p2 == h2:
return p1 + t * (p2 - p1)
return (
((1 - t) ** 3) * p1
+ (3 * t * (1 - t) ** 2) * h1
+ (3 * (t**2) * (1 - t)) * h2
+ (t**3) * p2
)
# same in slightly more lines
# result is equal, performance minimally better perhaps?
# def calc_point_on_bezier(bezier_point_1, bezier_point_2, ratio):
# startPoint = bezier_point_1.co
# controlPoint1 = bezier_point_1.handle_right
# controlPoint2 = bezier_point_2.handle_left
# endPoint = bezier_point_2.co
# remainder = 1 - ratio
# ratioSquared = ratio * ratio
# remainderSquared = remainder * remainder
# startPointMultiplier = remainderSquared * remainder
# controlPoint1Multiplier = remainderSquared * ratio * 3
# controlPoint2Multiplier = ratioSquared * remainder * 3
# endPointMultiplier = ratioSquared * ratio
# return startPoint * startPointMultiplier + controlPoint1 * controlPoint1Multiplier + controlPoint2 * controlPoint2Multiplier + endPoint * endPointMultiplier
def calc_tangent_on_bezier(bezier_point_1, bezier_point_2, t):
p1 = bezier_point_1.co
h1 = bezier_point_1.handle_right
p2 = bezier_point_2.co
h2 = bezier_point_2.handle_left
if p1 == h1 and p2 == h2:
return (p2 - p1).normalized()
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()
# class TestCalcPoint():
# co: mathutils.Vector
# handle_left: mathutils.Vector
# handle_right: mathutils.Vector
# def __init__(self, co, handle_left=None, handle_right=None):
# self.co = co
# if handle_left is not None:
# self.handle_left = handle_left
# if handle_right is not None:
# self.handle_right = handle_right
# a = TestCalcPoint(mathutils.Vector((0,0,0)), handle_right=mathutils.Vector((0,1,0)))
# b = TestCalcPoint(mathutils.Vector((1,0,0)), handle_left=mathutils.Vector((1,1,0)))
# c = TestCalcPoint(mathutils.Vector((0,0,0)), handle_right=mathutils.Vector((0,0,0)))
# d = TestCalcPoint(mathutils.Vector((1,0,0)), handle_left=mathutils.Vector((1,0,0)))
# calc_point_on_bezier(a,b,0.5)
def align_rotations_auto_pivot(
mask, input_rotations, vectors, factors, local_main_axis
):
output_rotations = [
mathutils.Matrix().to_3x3() for _ in range(len(input_rotations))
]
for i in mask:
vector = mathutils.Vector(vectors[i]).normalized()
input_rotation = mathutils.Euler(input_rotations[i])
if vector.length < 1e-6:
output_rotations[i] = input_rotation.to_matrix()
continue
old_rotation = input_rotation.to_matrix()
old_axis = (old_rotation @ local_main_axis).normalized()
new_axis = vector
# rotation_axis = (-(old_axis) + new_axis).normalized()
rotation_axis = old_axis.cross(new_axis).normalized()
if rotation_axis.length < 1e-6:
# Vectors are linearly dependent, fallback to another axis
rotation_axis = (old_axis + mathutils.Matrix().to_3x3().col[2]).normalized()
if rotation_axis.length < 1e-6:
# This is now guaranteed to not be zero
rotation_axis = (
-(old_axis) + mathutils.Matrix().to_3x3().col[1]
).normalized()
# full_angle = radians(sqrt((4 * pow(input_rotation.to_quaternion().dot(mathutils.Quaternion(vectors[i].normalized())), 2) - 3)))
# dot = old_axis.dot(new_axis)
# normalized_diff = (old_axis - new_axis).normalized()
# full_angle = acos(min((old_axis * new_axis + normalized_diff.dot(2)).length, 1))
full_angle = old_axis.angle(new_axis)
angle = factors[i] * full_angle
rotation = mathutils.Quaternion(rotation_axis, angle).to_matrix()
new_rotation_matrix = old_rotation @ rotation
output_rotations[i] = new_rotation_matrix
return [mat.to_4x4() for mat in output_rotations]
def calc_bezier_length(bezier_point_1, bezier_point_2, resolution=20):
step = 1 / resolution
previous_p = bezier_point_1.co
length = 0
for i in range(-1, resolution):
t = (i + 1) * step
p = calc_point_on_bezier(bezier_point_1, bezier_point_2, t)
length += (p - previous_p).length
previous_p = p
return length
def 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 = []
total_length = 0
n_bezier_points = len(bezier_spline_obj.bezier_points)
real_n_bezier_points = len(bezier_spline_obj.bezier_points)
if bezier_spline_obj.use_cyclic_u:
n_bezier_points += 1
for i in range(0, n_bezier_points - 1):
i_a = i % (n_bezier_points - 1)
i_b = (i_a + 1) % real_n_bezier_points
bezier = [
bezier_spline_obj.bezier_points[i_a],
bezier_spline_obj.bezier_points[i_b],
]
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
return beziers, lengths, total_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(
f"{utils.prefix()}::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
# in case the handles sit on the points
# we interpolate the travel from points of the bezier
# 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)
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
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 = (
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
for i in range(0, len(beziers)):
if iterated_distance + lengths[i] > distance:
distance_on_bezier = distance - iterated_distance
d = distance_on_bezier / lengths[i]
# print(f"i: {i}, d: {d}, distance_on_bezier: {distance_on_bezier}, distance: {distance}")
location = calc_point_on_bezier(beziers[i][0], beziers[i][1], d)
if output_tangent:
tangent = calc_tangent_on_bezier(beziers[i][0], beziers[i][1], d)
return location, tangent
else:
return location
iterated_distance += lengths[i]
# if we are here, the point is outside the spline
last_i = len(beziers) - 1
p = beziers[last_i][1]
travel = (p.handle_right - p.co).normalized() * (distance - total_length)
# in case the handles sit on the points
# we interpolate the travel from points of the bezier
# 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_right == p.co and len(beziers) > 0:
travel_point = calc_point_on_bezier(beziers[-1][1], beziers[-1][0], 0.001)
travel = travel_point.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,
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
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
# so we need to append tuple + tuple
return calc_point_on_bezier_spline(
spline, (distance - total_length), output_tangent, resolution_factor
) + (i,)
if output_spline_index and not output_tangent:
# return value from c_p_o_b_s is a location vector
# so we need to append with a comma
return (
calc_point_on_bezier_spline(
spline,
(distance - total_length),
output_tangent,
resolution_factor,
),
i,
)
else:
return calc_point_on_bezier_spline(
spline, (distance - total_length), output_tangent, resolution_factor
)
total_length += length
# NOTE: this is a fallback
# and should not happen usually
return bezier_curve_obj.matrix_world @ mathutils.Vector((distance, 0, 0))
# 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
]
# not verified
# def turn_collection_hierarchy_into_path(obj):
# parent_collection = obj.users_collection[0]
# parent_names = []
# parent_names.append(parent_collection.name)
# get_parent_collection_names(parent_collection, parent_names)
# parent_names.reverse()
# return "\\".join(parent_names)
def find_font_object(fontcollection, font_name):
fonts = find_objects_by_custom_property(fontcollection.objects, "is_font", True)
for font in fonts:
if font["font_name"] == font_name and font.parent is None:
return font
return None
def find_font_face_object(font_obj, face_name):
faces = find_objects_by_custom_property(font_obj.children, "is_face", True)
for face in faces:
if face["face_name"] == face_name:
return face
return None
def move_in_fontcollection(obj, fontcollection, allow_duplicates=False):
# parent nesting structure
# the font object
font_obj = find_font_object(fontcollection, obj["font_name"])
if font_obj is 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 is None:
face_obj = bpy.data.objects.new(obj["face_name"], None)
face_obj.empty_display_type = "PLAIN_AXES"
face_obj["is_face"] = True
fontcollection.objects.link(face_obj)
# ensure custom properties are set
face_obj["face_name"] = obj["face_name"]
face_obj["font_name"] = obj["font_name"]
if face_obj.parent != font_obj:
face_obj.parent = font_obj
# create glyphs if it does not exist
glyphs_objs = find_objects_by_name(face_obj.children, startswith="glyphs")
if len(glyphs_objs) <= 0:
glyphs_obj = bpy.data.objects.new("glyphs", None)
glyphs_obj.empty_display_type = "PLAIN_AXES"
fontcollection.objects.link(glyphs_obj)
glyphs_obj.parent = face_obj
elif len(glyphs_objs) > 1:
print(
f"{utils.prefix()}::move_in_fontcollection: found more glyphs objects than expected"
)
# now it must exist
glyphs_obj = find_objects_by_name(face_obj.children, startswith="glyphs")[0]
glyphs_obj["face_name"] = obj["face_name"]
glyphs_obj["font_name"] = obj["font_name"]
def get_hash(o):
return hash(tuple(tuple(v.co) for v in o.data.vertices))
for other_obj in find_objects_by_custom_property(
glyphs_obj.children, "glyph", obj["glyph"]
):
if get_hash(other_obj) == get_hash(obj) and not allow_duplicates:
return other_obj
# and now parent it!
if obj.parent != glyphs_obj:
obj.parent = glyphs_obj
for c in obj.users_collection:
c.objects.unlink(obj)
if fontcollection.objects.find(obj.name) < 0:
fontcollection.objects.link(obj)
return obj
def bpy_to_abspath(blender_path):
return os.path.realpath(bpy.path.abspath(blender_path))
def register_font_from_filepath(filepath):
from .bimport import get_font_faces_in_file
availables = get_font_faces_in_file(filepath)
fonts = {}
for a in availables:
font_name = a["font_name"]
face_name = a["face_name"]
glyph = a["glyph"]
if font_name not in fonts:
fonts[font_name] = {}
if face_name not in fonts[font_name]:
fonts[font_name][face_name] = []
fonts[font_name][face_name].append(glyph)
for font_name in fonts:
for face_name in fonts[font_name]:
Font.register_font(
font_name, face_name, fonts[font_name][face_name], filepath
)
def load_font_from_filepath(filepath, glyphs="", font_name="", face_name=""):
if not filepath.endswith(".glb") and not filepath.endswith(".gltf"):
ShowMessageBox(
"Font loading error",
"ERROR",
f"Filepath({filepath}) is not a *.glb or *.gltf file",
)
return False
marker_property = "font_import"
bpy.ops.abc3d.import_font_gltf(
filepath=filepath,
glyphs=glyphs,
marker_property=marker_property,
font_name=font_name,
face_name=face_name,
)
fontcollection = bpy.data.collections.get("ABC3D")
if fontcollection is None:
fontcollection = bpy.data.collections.new("ABC3D")
modified_font_faces = []
all_glyph_os = []
for o in bpy.context.scene.objects:
if marker_property in o:
if "type" in o and o["type"] == "glyph":
all_glyph_os.append(o)
for o in all_glyph_os:
glyph_id = o["glyph"]
font_name = o["font_name"]
face_name = o["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]
Font.add_glyph(font_name, face_name, glyph_id, glyph_obj_pointer)
for c in o.children:
if is_metrics_object(c):
add_metrics_obj_from_bound_box(
glyph_obj, bound_box_as_array(c.bound_box)
)
modified_font_faces.append({"font_name": font_name, "face_name": face_name})
for mff in modified_font_faces:
mff_glyphs = []
face: Font.FontFace = Font.get_font_face(mff["font_name"], mff["face_name"])
if face is None:
print(
f"{utils.prefix()}::load_font_from_path({filepath=}, {glyphs=}, {font_name=}, {face_name=}) failed"
)
print(
f"{utils.prefix()}:: modified font face {mff=} could not be accessed."
)
continue
# iterate glyphs
for g in face.glyphs:
# iterate alternates
for glyph in face.glyphs[g]:
mff_glyphs.append(get_original(glyph))
if len(mff_glyphs) > 0:
add_default_metrics_to_objects(mff_glyphs)
# calculate unit factor
h = get_glyph_height(mff_glyphs[0])
if h != 0:
face.unit_factor = 1 / h
update_available_fonts()
remove_list = []
for o in bpy.context.scene.collection.all_objects:
if o.name not in fontcollection.all_objects:
if marker_property in o and o[marker_property] == True:
remove_list.append(o)
simply_delete_objects(remove_list)
# completely_delete_objects(remove_list)
def is_glyph_used(glyph_alternates):
fontcollection: bpy_types.Collection = bpy.data.collections.get("ABC3D")
glyph = bpy.types.PointerProperty
for glyph in glyph_alternates:
for o in bpy.context.scene.objects:
# only check other glyphs
if is_glyph_object(o):
# then attempt to compare properties
if (
get_key("font_name") in o
and get_key("face_name") in o
and get_key("glyph_id") in o
and o[get_key("font_name")] == glyph["font_name"]
and o[get_key("face_name")] == glyph["face_name"]
and o[get_key("glyph_id")] == glyph["glyph"]
):
# following check is not necessary,
# but we leave it in for backwards compatibility
# properties in the fontcollection start with prefix
# and so they should be caught by previous check
if fontcollection.users == 0 or not (
fontcollection in o.users_collection
and len(o.users_collection) <= 1
):
# it's in the scene and has the correct properties
# it is used
return True
# following check is possibly overkill
# but we also check for objects that use the data
# and are not glyph objects, in that case we don't pull the data
# from under their feet
if is_mesh(o) and o.data == glyph.data:
# in this case, yes we need to check if it is a glyph in the fontcollection
if fontcollection.users == 0 or not (
fontcollection in o.users_collection
and len(o.users_collection) <= 1
):
# bam!
return True
# whoosh!
return False
def clean_text_properties():
abc3d_data = bpy.context.scene.abc3d_data
remove_these = []
for i, text_properties in enumerate(abc3d_data.available_texts):
if len(text_properties.text_object.users_collection) <= 0:
remove_these.append(i)
remove_these.reverse()
for i in remove_these:
abc3d_data.available_texts.remove(i)
def clean_fontcollection(fontcollection=None):
if fontcollection is None:
fontcollection = bpy.data.collections.get("ABC3D")
if fontcollection is None:
print(
f"{utils.prefix()}::clean_fontcollection: failed because fontcollection is none"
)
return False
collection_fonts = find_objects_by_custom_property(
fontcollection.all_objects, "is_font", True
)
delete_these_fonts = []
delete_these_font_faces = []
delete_these_glyph_moms = []
for font_and_face in Font.get_loaded_fonts_and_faces():
font_name = font_and_face[0]
face_name = font_and_face[1]
collection_font_list = find_objects_by_custom_property(
collection_fonts, "font_name", font_name
)
for collection_font in collection_font_list:
collection_font_face_list = find_objects_by_custom_property(
collection_font.children, "face_name", face_name
)
count_font_faces = 0
for collection_font_face in collection_font_face_list:
glyphs_mom_list = find_objects_by_name(
collection_font_face.children, startswith="glyphs"
)
count_glyphs_moms = 0
for glyphs_mom in glyphs_mom_list:
if len(glyphs_mom.children) == 0:
delete_these_glyph_moms.append(glyphs_mom)
count_glyphs_moms += 1
if len(collection_font_face.children) == count_glyphs_moms:
delete_these_font_faces.append(collection_font_face)
count_font_faces += 1
if len(collection_font.children) == count_font_faces:
delete_these_fonts.append(collection_font)
completely_delete_objects(delete_these_glyph_moms)
completely_delete_objects(delete_these_font_faces)
completely_delete_objects(delete_these_fonts)
def unload_unused_glyph(font_name, face_name, glyph_id, do_clean_fontcollection=True):
fontcollection: bpy_types.Collection = bpy.data.collections.get("ABC3D")
glyph_variations = Font.get_glyphs(font_name, face_name, glyph_id)
if is_glyph_used(glyph_variations):
return False
delete_these = []
for glyph_pointer in glyph_variations:
for o in fontcollection.all_objects:
if (
is_glyph_object(o)
and o["font_name"] == font_name
and o["face_name"] == face_name
and o["glyph"] == glyph_id
):
if len(o.users_collection) <= 1:
delete_these.append(o)
completely_delete_objects(delete_these)
Font.unloaded_glyph(font_name, face_name, glyph_id)
if do_clean_fontcollection:
clean_fontcollection(fontcollection)
return True
def unload_unused_glyphs(do_clean_fontcollection=True):
fontcollection: bpy_types.Collection = bpy.data.collections.get("ABC3D")
if fontcollection is not None:
for font_and_face in Font.get_loaded_fonts_and_faces():
font_name = font_and_face[0]
face_name = font_and_face[1]
face: Font.FontFace | None = Font.get_font_face(font_name, face_name)
if face is None:
print(
f"{utils.prefix()}::unload_unused_glyphs: face is None {font_name=} {face_name=}"
)
continue
unloaded_these = []
for glyph_id in face.loaded_glyphs.copy():
unload_unused_glyph(
font_name, face_name, glyph_id, do_clean_fontcollection=False
)
if do_clean_fontcollection:
clean_fontcollection(fontcollection)
def update_available_fonts():
abc3d_data = bpy.context.scene.abc3d_data
for font_and_face in Font.get_loaded_fonts_and_faces():
found = False
font_name = font_and_face[0]
face_name = font_and_face[1]
for f in abc3d_data.available_fonts.values():
if font_name == f.font_name and face_name == f.face_name:
found = True
if not found:
f = abc3d_data.available_fonts.add()
f.font_name = font_name
f.face_name = face_name
print(
f"{utils.prefix()}::update_available_fonts: {__name__} added {font_name} {face_name}"
)
# def update_available_texts():
# abc3d_data = bpy.context.scene.abc3d_data
# for o in bpy.context.scene.objects:
# if "text_id" in o.keys():
# i = o["text_id"]
# found = False
# if len(abc3d_data.available_texts) > i:
# if abc3d_data.available_texts[i].glyphs
def getPreferences(context):
preferences = context.preferences
return preferences.addons["abc3d"].preferences
# clear available fonts
def clear_available_fonts():
bpy.context.scene.abc3d_data.available_fonts.clear()
def load_installed_fonts():
preferences = getPreferences(bpy.context)
font_dir = os.path.join(preferences.assets_dir, "fonts")
if os.path.exists(font_dir):
for file in os.listdir(font_dir):
if file.endswith(".glb") or file.endswith(".gltf"):
font_path = os.path.join(font_dir, file)
# ShowMessageBox("Loading Font", "INFO", f"loading font from {font_path}")
# print(f"loading font from {font_path}")
# for f in bpy.context.scene.abc3d_data.available_fonts.values():
# print(f"available font: {f.font_name} {f.face_name}")
register_font_from_filepath(font_path)
load_font_from_filepath(font_path)
def register_installed_fonts():
preferences = getPreferences(bpy.context)
font_dir = os.path.join(preferences.assets_dir, "fonts")
if os.path.exists(font_dir):
for file in os.listdir(font_dir):
if file.endswith(".glb") or file.endswith(".gltf"):
font_path = os.path.join(font_dir, file)
# ShowMessageBox("Loading Font", "INFO", f"loading font from {font_path}")
# print(f"loading font from {font_path}")
# for f in bpy.context.scene.abc3d_data.available_fonts.values():
# print(f"available font: {f.font_name} {f.face_name}")
register_font_from_filepath(font_path)
message_memory = []
def ShowMessageBox(title="Message Box", icon="INFO", message="", prevent_repeat=False):
"""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=)
"""
global message_memory
if prevent_repeat:
for m in message_memory:
if m[0] == title and m[1] == icon and m[2] == message:
return
message_memory.append([title, icon, message])
myLines = message
def draw(self, context):
if isinstance(myLines, str):
self.layout.label(text=myLines)
elif hasattr(myLines, "__iter__"):
for n in myLines:
self.layout.label(text=n)
bpy.context.window_manager.popup_menu(draw, title=title, icon=icon)
def simply_delete_objects(objs):
completely_delete_objects(objs)
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:
# not important
pass
try:
bpy.data.objects.remove(g, do_unlink=True)
except ReferenceError:
# not important
pass
except RuntimeError:
pass
def is_mesh(o):
return type(o.data) == bpy.types.Mesh
def is_metrics_object(o):
if f"{utils.prefix()}_type" in o:
return o[f"{utils.prefix()}_type"] == "metrics"
return (
re.match(".*_metrics$", o.name) is not None
or re.match(".*_metrics.[\d]{3}$", o.name) is not None
) and is_mesh(o)
def is_text_object(o):
if f"{utils.prefix()}_type" in o:
return o[f"{utils.prefix()}_type"] == "textobject"
for t in bpy.context.scene.abc3d_data.available_texts:
if o == t.text_object:
return True
return False
def is_glyph_object(o):
if f"{utils.prefix()}_type" in o:
return o[f"{utils.prefix()}_type"] == "glyph"
try:
return (
o.parent is not None
and "glyphs" in o.parent.name
and is_mesh(o)
and not is_metrics_object(o)
)
except ReferenceError:
return False
def is_glyph(o):
return is_glyph_object(o)
def update_types():
scene = bpy.context.scene
abc3d_data = scene.abc3d_data
for t in abc3d_data.available_texts:
t.text_object[f"{utils.prefix()}_type"] = "textobject"
for g in t.glyphs:
g.glyph_object[f"{utils.prefix()}_type"] = "glyph"
# blender bound_box vertices
#
# 3------7.
# |`. | `. +y
# | `2------6 |
# | | | | |
# 0---|--4. | +--- +x
# `. | `.| `.
# `1------5 `+z
def get_glyph_advance(glyph_obj):
for c in glyph_obj.children:
if is_metrics_object(c):
return abs(c.bound_box[4][0] - c.bound_box[0][0])
return abs(glyph_obj.bound_box[4][0] - glyph_obj.bound_box[0][0])
def get_glyph_prepost_advances(glyph_obj):
for c in glyph_obj.children:
if is_metrics_object(c):
return -1 * c.bound_box[0][0], c.bound_box[4][0]
return -1 * glyph_obj.bound_box[0][0], glyph_obj.bound_box[4][0]
def get_glyph_height(glyph_obj):
for c in glyph_obj.children:
if is_metrics_object(c):
return abs(c.bound_box[0][1] - c.bound_box[3][1])
return abs(glyph_obj.bound_box[0][1] - glyph_obj.bound_box[3][1])
def prepare_text(font_name, face_name, text, allow_replacement=True):
availability = Font.test_glyphs_availability(font_name, face_name, text)
if isinstance(availability, int):
if availability == Font.MISSING_FONT:
print(
f"{utils.prefix()}::prepare_text({font_name=}, {face_name=}, {text=}) failed with MISSING_FONT"
)
if availability is Font.MISSING_FACE:
print(
f"{utils.prefix()}::prepare_text({font_name=}, {face_name=}, {text=}) failed with MISSING_FACE"
)
return False
loadable = availability.unloaded
# possibly replace upper and lower case letters with each other
if len(availability.missing) > 0 and allow_replacement:
replacement_search = ""
for m in availability.missing:
if m.isalpha():
replacement_search += m.swapcase()
r = Font.test_availability(font_name, face_name, replacement_search)
loadable += r.unloaded
# not update (loaded, missing, files), we only use loadable/maybe later
if len(loadable) > 0:
for filepath in availability.filepaths:
load_font_from_filepath(filepath, loadable, font_name, face_name)
return True
def predict_actual_text(text_properties):
availability = Font.test_availability(
text_properties.font_name, text_properties.face_name, text_properties.text
)
AVAILABILITY = Font.test_availability(
text_properties.font_name,
text_properties.face_name,
text_properties.text.swapcase(),
)
t_text = text_properties.text
for c in availability.missing:
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
def is_bezier(curve):
if curve.type != "CURVE":
return False
if len(curve.data.splines) < 1:
return False
for spline in curve.data.splines:
if spline.type != "BEZIER":
return False
return True
text_object_keys = [
"font_name",
"face_name",
"type",
"text_id",
"font_size",
"letter_spacing",
"distribution_type",
"orientation",
"translation",
"offset",
"text",
]
glyph_object_keys = [
"type",
"glyph_index",
"glyph_id",
"text_id",
"font_name",
"face_name",
"font_size",
"letter_spacing",
"alternate",
]
ignore_keys_in_text_object_comparison = [
"type",
]
ignore_keys_in_glyph_object_comparison = [
"type",
"glyph_index",
"font_name",
"face_name",
"text_id",
]
ignore_keys_in_glyph_object_transfer = [
"type",
"text_id",
"glyph_index",
]
keys_trigger_regeneration = [
"font_name",
"face_name",
]
COMPARE_TEXT_OBJECT_SAME = 0
COMPARE_TEXT_OBJECT_DIFFER = 1
COMPARE_TEXT_OBJECT_REGENERATE = 2
def find_free_text_id():
scene = bpy.context.scene
abc3d_data = scene.abc3d_data
text_id = 0
found_free = False
while not found_free:
occupied = False
for t in abc3d_data.available_texts:
if text_id == t.text_id:
occupied = True
if occupied:
text_id += 1
else:
found_free = True
return text_id
def compare_text_properties_to_text_object(text_properties, o):
for key in text_object_keys:
if key in ignore_keys_in_text_object_comparison:
continue
object_key = get_key(key)
text_property = (
text_properties[key]
if key in text_properties
else getattr(text_properties, key)
)
text_object_property = o[object_key] if object_key in o else False
if text_property != text_object_property:
if key in keys_trigger_regeneration:
return COMPARE_TEXT_OBJECT_REGENERATE
elif key in ["translation", "orientation"]:
if (
text_property[0] != text_object_property[0]
or text_property[1] != text_object_property[1]
or text_property[2] != text_object_property[2]
):
return COMPARE_TEXT_OBJECT_DIFFER
# else same
else:
return COMPARE_TEXT_OBJECT_DIFFER
# else same
return COMPARE_TEXT_OBJECT_SAME
def transfer_text_properties_to_text_object(text_properties, o):
for key in text_object_keys:
if key in ignore_keys_in_text_object_comparison:
continue
object_key = get_key(key)
text_property = (
text_properties[key]
if key in text_properties
else getattr(text_properties, key)
)
o[object_key] = text_property
o[get_key("type")] = "textobject"
def get_glyph(glyph_id, font_name, face_name, notify_on_replacement=False):
glyph_tmp = Font.get_glyph(font_name, face_name, glyph_id, -1)
if glyph_tmp is None:
space_width = Font.is_space(glyph_id)
if space_width:
return space_width
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 notify_on_replacement:
ShowMessageBox(
title="Glyph replaced" if replaced else "Glyph missing",
icon="INFO" if replaced else "ERROR",
message=message,
prevent_repeat=True,
)
if not replaced:
return None
return glyph_tmp.original
def get_text_properties(text_id, scene=None):
if scene is None:
scene = bpy.context.scene
abc3d_data = scene.abc3d_data
for t in abc3d_data.available_texts:
if text_id == t.text_id:
return t
return None
def get_text_properties_by_index(text_index, scene=None):
if scene is None:
scene = bpy.context.scene
abc3d_data = scene.abc3d_data
if text_index >= len(abc3d_data.available_texts):
return None
return abc3d_data.available_texts[text_index]
def duplicate(
obj,
data=True,
actions=True,
add_to_collection=True,
collection=None,
recursive=True,
):
obj_copy = obj.copy()
if add_to_collection:
if collection:
collection.objects.link(obj_copy)
elif len(obj.users_collection) > 0:
obj.users_collection[0].objects.link(obj_copy)
if data and obj.data:
obj_copy.data = obj.data.copy()
if actions and obj.animation_data:
obj_copy.animation_data.action = obj.animation_data.action.copy()
if recursive and hasattr(obj, "children"):
for child in obj.children:
child_copy = duplicate(child)
child_copy.parent_type = child.parent_type
child_copy.parent = obj_copy
# child_copy.matrix_parent_inverse = obj_copy.matrix_world.inverted()
return obj_copy
def transfer_text_object_to_text_properties(
text_object, text_properties, id_from_text_properties=True
):
possible_brother_text_id = (
text_object[get_key("text_id")] if get_key("text_id") in text_object else ""
)
for key in text_object_keys:
if key in ignore_keys_in_text_object_comparison:
continue
object_key = get_key(key)
if id_from_text_properties and key == "text_id":
text_object[object_key] = text_properties["text_id"]
else:
text_object_property = (
text_object[object_key] if object_key in text_object else False
)
if text_object_property is not False:
text_properties[key] = text_object_property
if len(text_object.children) == 0:
if (
possible_brother_text_id != text_properties["text_id"]
and possible_brother_text_id != ""
):
possible_brother_properties = get_text_properties(possible_brother_text_id)
possible_brother_object = possible_brother_properties.text_object
if possible_brother_object is not None:
for child in possible_brother_object.children:
if is_glyph_object(child):
child_copy = duplicate(child)
child_copy.parent_type = child.parent_type
child_copy.parent = text_object
parent_to_curve(child_copy, text_object)
# child_copy.matrix_parent_inverse = text_object.matrix_world.inverted()
found_reconstructable_glyphs = False
glyph_objects_with_indices = []
required_keys = ["glyph_index", "glyph_id", "type"]
for glyph_object in text_object.children:
if is_glyph_object(glyph_object):
has_required_keys = True
for key in required_keys:
if get_key(key) not in glyph_object:
has_required_keys = False
if has_required_keys:
inner_node = None
glyph_id = glyph_object[get_key("glyph_id")]
for c in glyph_object.children:
if c.name.startswith(f"{glyph_id}_mesh"):
inner_node = c
if inner_node is not None:
glyph_objects_with_indices.append(glyph_object)
glyph_objects_with_indices.sort(key=lambda g: g[get_key("glyph_index")])
text = ""
for g in glyph_objects_with_indices:
text += g[get_key("glyph_id")]
is_good_text = False
if len(text) > 0:
if text == text_properties.text:
is_good_text = True
else:
t_text = predict_actual_text(text_properties)
if t_text == text:
is_good_text = True
if is_good_text:
text_properties.actual_text = text
text_properties.glyphs.clear()
prepare_text(text_properties.font_name, text_properties.face_name, text)
fail_after_all = False
for glyph_index, glyph_object in enumerate(glyph_objects_with_indices):
glyph_id = glyph_object[get_key("glyph_id")]
# glyph_tmp = Font.get_glyph(text_properties.font_name,
# text_properties.face_name,
# glyph_id)
# glyph = glyph_tmp.original
glyph_properties = text_properties.glyphs.add()
transfer_glyph_object_to_glyph_properties(glyph_object, glyph_properties)
# glyph_properties["glyph_object"] = glyph_object
inner_node = None
for c in glyph_object.children:
if c.name.startswith(f"{glyph_id}_mesh"):
inner_node = c
if inner_node is None:
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
if not found_reconstructable_glyphs:
text_properties.actual_text = ""
text_properties.glyphs.clear()
unfortunate_children = text_object.children
completely_delete_objects(unfortunate_children)
def kill_children():
completely_delete_objects(unfortunate_children)
run_in_main_thread(kill_children)
if "font_name" in text_properties and "face_name" in text_properties:
font_name = text_properties["font_name"]
face_name = text_properties["face_name"]
text_properties.font = f"{font_name} {face_name}"
def link_text_object_with_new_text_properties(text_object, scene=None):
if scene is None:
scene = bpy.context.scene
text_id = find_free_text_id()
text_properties = scene.abc3d_data.available_texts.add()
text_properties["text_id"] = text_id
# text_object[get_key("text_id")] = text_id
prepare_text(
text_object[get_key("font_name")],
text_object[get_key("face_name")],
text_object[get_key("text")],
)
text_properties.text_object = text_object
transfer_text_object_to_text_properties(text_object, text_properties)
def test_finding():
scene = bpy.context.scene
abc3d_data = scene.abc3d_data
text_id = find_free_text_id()
t = abc3d_data.available_texts.add()
t["text_id"] = text_id
o = bpy.context.active_object
transfer_text_object_to_text_properties(o, t)
def is_text_object_legit(text_object):
must_have_keys = [
get_key("font_name"),
get_key("face_name"),
get_key("text"),
get_key("type"),
]
for key in must_have_keys:
if key not in text_object:
return False
if text_object[get_key("type")] != "textobject":
return False
return True
# def detect_texts():
# scene = bpy.context.scene
# abc3d_data = scene.abc3d_data
# for o in bpy.data.objects:
# if get_key("type") in o \
# and o[get_key("type") == "textobject" \
# and o[get_key("t
def link_text_object_and_text_properties(o, text_properties):
text_id = text_properties.text_id
o["text_id"] = text_id
text_properties.textobject = o
def get_glyph_object_property(text_properties, glyph_properties, key):
if key in glyph_properties:
return glyph_properties[key]
if hasattr(glyph_properties, key):
return getattr(glyph_properties, key)
return (
text_properties[key]
if key in text_properties
else getattr(text_properties, key)
)
def transfer_properties_to_glyph_object(
text_properties, glyph_properties, glyph_object
):
for key in glyph_object_keys:
if key in ignore_keys_in_glyph_object_transfer:
continue
object_key = get_key(key)
glyph_object[object_key] = get_glyph_object_property(
text_properties, glyph_properties, key
)
glyph_object[get_key("type")] = "glyph"
glyph_object[get_key("text_id")] = text_properties["text_id"]
def transfer_glyph_object_to_glyph_properties(glyph_object, glyph_properties):
for key in glyph_object_keys:
if key in ignore_keys_in_glyph_object_transfer:
continue
glyph_properties[key] = glyph_object[get_key(key)]
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 get_text_difference_index(text_properties.actual_text, predicted_text)
if len(text_properties.glyphs) == 0:
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 g.glyph_object is None:
return True
elif g.glyph_object.parent is 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 update_matrices(obj):
if obj.parent is None:
obj.matrix_world = obj.matrix_basis
# else:
obj.matrix_world = (
obj.parent.matrix_world * obj.matrix_parent_inverse * obj.matrix_basis
)
def is_or_has_parent(o, parent, if_is_parent=True, max_depth=10):
if o == parent and if_is_parent:
return True
oo = o
for i in range(0, max_depth):
oo = oo.parent
if oo == parent:
return True
if oo is None:
return False
return False
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()
if c.data.use_path and len(c.data.splines) > 0:
if c.data.splines[0].type == "BEZIER":
i = -1 if c.data.splines[0].use_cyclic_u else 0
p = c.data.splines[0].bezier_points[i].co
o.matrix_parent_inverse.translation = p * -1.0
elif c.data.splines[0].type == "NURBS":
cm = c.to_mesh()
p = cm.vertices[0].co
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")
transfer_text_properties_to_text_object(
text_properties, text_properties.text_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
):
"""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
"""
# NOTE: depsgraph update not locked
# as we fixed data_path with parent_to_curve trick
# 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:
return False
distribution_type = "CALCULATE" if is_bezier(mom) else "FOLLOW_PATH"
predicted_text = predict_actual_text(text_properties)
ensure_glyphs(text_properties, predicted_text)
curve_length = get_curve_length(mom)
advance = text_properties.offset
glyph_advance = 0
glyph_index = 0
is_command = False
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
if c == "\\":
is_command = True
continue
is_newline = False
if is_command:
if c == "n":
is_newline = True
next_line_advance = get_next_line_advance(mom, advance, glyph_advance)
if advance == next_line_advance:
print(
f"would like to add new line for {text_properties.text} please"
)
# TODO: add a new line
advance = next_line_advance + text_properties.offset
continue
is_command = False
glyph_id = c
spline_index = 0
############### HANDLE SPACES
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
############### GLYPH PROPERTIES
glyph_properties = text_properties.glyphs[glyph_index]
# ensure_glyph_object(text_properties, glyph_properties)
############### ACTUAL TEXT
actual_text += glyph_id
############### NODE SCENE MANAGEMENT
# 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)
advance += glyph_pre_advance * scalor
# check if we want to loop
applied_advance = advance
if text_properties.loop_in:
if applied_advance < 0:
applied_advance %= curve_length
if text_properties.loop_out:
if applied_advance > curve_length:
applied_advance %= curve_length
if distribution_type == "FOLLOW_PATH":
outer_node.constraints.new(type="FOLLOW_PATH")
outer_node.constraints["Follow Path"].target = mom
outer_node.constraints["Follow Path"].use_fixed_location = True
outer_node.constraints["Follow Path"].offset_factor = (
applied_advance / curve_length
)
outer_node.constraints["Follow Path"].use_curve_follow = True
outer_node.constraints["Follow Path"].forward_axis = "FORWARD_X"
outer_node.constraints["Follow Path"].up_axis = "UP_Y"
spline_index = 0
elif distribution_type == "CALCULATE":
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(
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:
is_newline = True
# position
glyph_properties.glyph_object.location = (
location + text_properties.translation
)
# orientation / rotation
mask = [0]
input_rotations = [mathutils.Vector((0.0, 0.0, 0.0))]
vectors = [tangent]
factors = [1.0]
local_main_axis = mathutils.Vector((1.0, 0.0, 0.0))
motor = (
align_rotations_auto_pivot(
mask, input_rotations, vectors, factors, local_main_axis
)
if not text_properties.ignore_orientation
else [mathutils.Matrix()]
)
q = mathutils.Quaternion()
q.rotate(text_properties.orientation)
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:
# glyph_properties.glyph_object.rotation_quaternion = (mom.matrix_world.inverted().to_3x3() @ motor[0].to_3x3() @ q.to_matrix()).to_quaternion()
# # scale
glyph_properties.glyph_object.scale = (scalor, scalor, scalor)
if previous_glyph_object_rotation_mode:
glyph_properties.glyph_object.rotation_mode = (
previous_glyph_object_rotation_mode
)
# outer_node.hide_viewport = True
############### PREPARE FOR THE NEXT
glyph_advance = (
glyph_post_advance * scalor
+ text_properties.letter_spacing
+ glyph_properties.letter_spacing
)
# now we need to compensate for curvature
# otherwise letters will be closer together the curvier the bezier is
# NOTE: this could be done more efficiently
curve_compensation = 0
if distribution_type == "CALCULATE" and (not is_newline or spline_index == 0):
if text_properties.compensate_curvature and glyph_advance > 0:
previous_location, psi = calc_point_on_bezier_curve(
mom, advance, False, True
)
new_location, si = calc_point_on_bezier_curve(
mom, advance + glyph_advance, False, True
)
if psi == si:
n_max = 100
n = 0
while (
(previous_location - new_location).length > glyph_advance
and psi == si
and n < n_max
):
curve_compensation = curve_compensation - glyph_advance * 0.01
tmp_new_location, si = calc_point_on_bezier_curve(
mom,
advance + glyph_advance + curve_compensation,
output_tangent=False,
output_spline_index=True,
)
if tmp_new_location == new_location:
print(
f"{utils.prefix()}::set_text_on_curve::compensate_curvature while loop overstaying welcome"
)
break
new_location = tmp_new_location
n += 1
n = 0
while (
(previous_location - new_location).length < glyph_advance
and psi == si
and n < n_max
):
curve_compensation = curve_compensation + glyph_advance * 0.01
tmp_new_location, si = calc_point_on_bezier_curve(
mom,
advance + glyph_advance + curve_compensation,
output_tangent=False,
output_spline_index=True,
)
if tmp_new_location == new_location:
print(
f"{utils.prefix()}::set_text_on_curve::compensate_curvature while loop overstaying welcome"
)
break
new_location = tmp_new_location
n += 1
advance = advance + glyph_advance + curve_compensation
glyph_index += 1
previous_spline_index = spline_index
text_properties["actual_text"] = actual_text
return True
verification_object = {
f"{utils.prefix()}_type": "textobject",
f"{utils.prefix()}_text_id": 0,
f"{utils.prefix()}_font_name": "font_name",
f"{utils.prefix()}_face_name": "face_name",
f"{utils.prefix()}_font_size": 42,
f"{utils.prefix()}_letter_spacing": 42,
f"{utils.prefix()}_orientation": [0, 0, 0],
f"{utils.prefix()}_translation": [0, 0, 0],
}
def verify_text_object(o):
pass
# blender bound_box vertices
#
# 3------7.
# |`. | `. +y
# | `2------6 -z |
# | | | | `. |
# 0---|--4. | `+--- +x
# `. | `.|
# `1------5
def add_metrics_obj_from_bound_box(glyph, bound_box=None):
mesh = bpy.data.meshes.new(f"{glyph.name}_metrics") # add the new mesh
obj = bpy.data.objects.new(mesh.name, mesh)
obj["font_name"] = glyph["font_name"]
obj["face_name"] = glyph["face_name"]
obj["glyph"] = glyph["glyph"]
obj[f"{utils.prefix()}_type"] = "metrics"
# remove already existing metrics
remove_metrics = []
for c in glyph.children:
if is_metrics_object(c):
remove_metrics.append(c)
if len(remove_metrics) > 0:
completely_delete_objects(remove_metrics)
col = glyph.users_collection[0]
col.objects.link(obj)
# bpy.context.view_layer.objects.active = obj
obj.parent = glyph
if type(bound_box) == type(None):
bound_box = glyph.bound_box
verts = [
bound_box[0],
bound_box[1],
bound_box[2],
bound_box[3],
bound_box[4],
bound_box[5],
bound_box[6],
bound_box[7],
]
edges = [
[0, 1],
[1, 2],
[2, 3],
[3, 0],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
[4, 5],
[5, 6],
[6, 7],
[7, 4],
]
faces = []
mesh.from_pydata(verts, edges, faces)
def add_faces_to_metrics(obj):
mesh = bpy.data.meshes.new(f"{obj.name}") # add the new mesh
print(f"add_faces_to_metrics for {obj.name}")
bound_box = bound_box_as_array(obj.bound_box)
verts = [
bound_box[0],
bound_box[1],
bound_box[2],
bound_box[3],
bound_box[4],
bound_box[5],
bound_box[6],
bound_box[7],
]
edges = [
[0, 1],
[1, 2],
[2, 3],
[3, 0],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
[4, 5],
[5, 6],
[6, 7],
[7, 4],
]
faces = [
[0, 1, 2],
[2, 3, 0],
[2, 6, 7],
[7, 3, 2],
[6, 5, 4],
[4, 7, 6],
[4, 5, 1],
[0, 4, 1],
[1, 5, 6],
[1, 6, 2],
[4, 0, 7],
[7, 0, 3],
]
mesh.from_pydata(verts, edges, faces)
old_mesh = obj.data
obj.data = mesh
bpy.data.meshes.remove(old_mesh)
def remove_faces_from_metrics(obj):
mesh = bpy.data.meshes.new(f"{obj.name}") # add the new mesh
bound_box = bound_box_as_array(obj.bound_box)
verts = [
bound_box[0],
bound_box[1],
bound_box[2],
bound_box[3],
bound_box[4],
bound_box[5],
bound_box[6],
bound_box[7],
]
edges = [
[0, 1],
[1, 2],
[2, 3],
[3, 0],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
[4, 5],
[5, 6],
[6, 7],
[7, 4],
]
faces = []
mesh.from_pydata(verts, edges, faces)
old_mesh = obj.data
obj.data = mesh
bpy.data.meshes.remove(old_mesh)
# duplicate
# def remove_metrics_from_selection():
# for o in bpy.context.selected_objects:
# is_possibly_glyph = is_mesh(o)
# if is_possibly_glyph:
# metrics = []
# for c in o.children:
# if is_metrics_object(c):
# metrics.append(c)
# completely_delete_objects(metrics)
def get_max_bound_box(bb_1, bb_2=None):
if type(bb_2) == type(None):
bb_2 = bb_1
x_max = max(bb_1[4][0], bb_2[4][0])
x_min = min(bb_1[0][0], bb_2[0][0])
y_max = max(bb_1[3][1], bb_2[3][1])
y_min = min(bb_1[0][1], bb_2[0][1])
z_max = max(bb_1[1][2], bb_2[1][2])
z_min = min(bb_1[0][2], bb_2[0][2])
return [
mathutils.Vector((x_min, y_min, z_min)),
mathutils.Vector((x_min, y_min, z_max)),
mathutils.Vector((x_min, y_max, z_max)),
mathutils.Vector((x_min, y_max, z_min)),
mathutils.Vector((x_max, y_min, z_min)),
mathutils.Vector((x_max, y_min, z_max)),
mathutils.Vector((x_max, y_max, z_max)),
mathutils.Vector((x_max, y_max, z_min)),
]
# blender bound_box vertices
#
# 3------7.
# |`. | `. +y
# | `2------6 |
# | | | | |
# 0---|--4. | +--- +x
# `. | `.| `.
# `1------5 `+z
# why not [ [0] * 3 ] * 8
# https://stackoverflow.com/questions/2397141/how-to-initialize-a-two-dimensional-array-list-of-lists-if-not-using-numpy-in
def bound_box_as_array(bound_box):
array = [[0] * 3 for i in range(8)]
for i in range(0, len(bound_box)):
for j in range(0, len(bound_box[i])):
array[i][j] = bound_box[i][j]
return array
##
# @brief get_metrics_bound_box
# generates a metrics bounding box
# where x-width comes from bb
# and y-height + z-depth from bb_uebermetrics
#
# @param bb
# @param bb_uebermetrics
#
# @return metrics
def get_metrics_bound_box(bb, bb_uebermetrics):
metrics = [[0] * 3] * 8
# hurrays
if type(bb_uebermetrics) == bpy.types.bpy_prop_array:
metrics = bound_box_as_array(bb_uebermetrics)
else:
metrics = bb_uebermetrics.copy()
metrics[0][0] = bb[0][0]
metrics[1][0] = bb[1][0]
metrics[2][0] = bb[2][0]
metrics[3][0] = bb[3][0]
metrics[4][0] = bb[4][0]
metrics[5][0] = bb[5][0]
metrics[6][0] = bb[6][0]
metrics[7][0] = bb[7][0]
return metrics
def get_metrics_object(o):
if is_glyph(o):
for c in o.children:
if is_metrics_object(c):
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
targets = []
reference_bound_box = None
for o in objects:
is_possibly_glyph = is_glyph(o)
if is_possibly_glyph:
metrics = []
for c in o.children:
if is_metrics_object(c):
metrics.append(c)
if len(metrics) == 0:
targets.append(o)
reference_bound_box = get_max_bound_box(
o.bound_box, reference_bound_box
)
elif len(metrics) >= 0 and overwrite_existing:
completely_delete_objects(metrics)
targets.append(o)
reference_bound_box = get_max_bound_box(
o.bound_box, reference_bound_box
)
else:
for m in metrics:
reference_bound_box = get_max_bound_box(
m.bound_box, reference_bound_box
)
for t in targets:
bound_box = get_metrics_bound_box(t.bound_box, reference_bound_box)
add_metrics_obj_from_bound_box(t, bound_box)
def remove_metrics_from_objects(objects=None):
if type(objects) == type(None):
objects = bpy.context.selected_objects
metrics = []
for o in objects:
for c in o.children:
if is_metrics_object(c):
metrics.append(c)
completely_delete_objects(metrics)
def align_metrics_of_objects_to_active_object(objects=None):
if type(objects) == type(None):
objects = bpy.context.selected_objects
if len(objects) == 0:
return "no objects selected"
# define the reference_bound_box
reference_bound_box = None
if type(bpy.context.active_object) == type(None):
return "no active_object, but align_to_active_object is True"
for c in bpy.context.active_object.children:
if is_metrics_object(c):
reference_bound_box = bound_box_as_array(c.bound_box)
break
if type(reference_bound_box) == type(None):
if not is_mesh(bpy.context.active_object):
return "active_object is not a mesh and does not have a metrics child"
reference_bound_box = bound_box_as_array(bpy.context.active_object.bound_box)
# do it
for o in objects:
is_possibly_glyph = is_glyph(o)
if is_possibly_glyph and o is not bpy.context.active_object:
metrics = []
for c in o.children:
if is_metrics_object(c):
metrics.append(c)
bb = None
if len(metrics) == 0:
bb = get_metrics_bound_box(o.bound_box, reference_bound_box)
else:
bb = get_metrics_bound_box(metrics[0].bound_box, reference_bound_box)
if len(metrics) > 0:
completely_delete_objects(metrics)
add_metrics_obj_from_bound_box(o, bb)
return ""
def align_metrics_of_objects(objects=None):
if type(objects) == type(None):
objects = bpy.context.selected_objects
if len(objects) == 0:
return "no objects selected"
targets = []
reference_bound_box = None
for o in objects:
is_possibly_glyph = is_glyph(o)
if is_possibly_glyph:
metrics = []
for c in o.children:
if is_metrics_object(c):
metrics.append(c)
if len(metrics) == 0:
reference_bound_box = get_max_bound_box(
o.bound_box, reference_bound_box
)
elif len(metrics) > 0:
reference_bound_box = get_max_bound_box(
metrics[0].bound_box, reference_bound_box
)
targets.append(o)
for t in targets:
metrics = []
for c in t.children:
if is_metrics_object(c):
metrics.append(c)
bound_box = None
if len(metrics) == 0:
bound_box = get_metrics_bound_box(t.bound_box, reference_bound_box)
else:
bound_box = get_metrics_bound_box(metrics[0].bound_box, reference_bound_box)
completely_delete_objects(metrics)
add_metrics_obj_from_bound_box(t, bound_box)
return ""
def align_origins_to_active_object(objects=None, axis=2):
if objects is None:
objects = bpy.context.selected_objects
if len(objects) == 0:
return "no objects selected"
if bpy.context.active_object is None:
return "no active object selected"
reference_origin_position = bpy.context.active_object.matrix_world.translation[axis]
# do it
for o in objects:
is_possibly_glyph = is_glyph(o)
if is_possibly_glyph and o is not bpy.context.active_object:
if is_mesh(o):
diff = reference_origin_position - o.matrix_world.translation[axis]
for v in o.data.vertices:
v.co[axis] -= diff
o.matrix_world.translation[axis] = reference_origin_position
return ""
# NOTE:
# Following code is not necessary anymore,
# as we derive the advance through metrics
# boundaries
# def divide_vectors(v1=mathutils.Vector((1.0,1.0,1.0)), v2=mathutils.Vector((1.0,1.0,1.0))):
# return mathutils.Vector([v1[i] / v2[i] for i in range(3)])
# def get_origin_shift_metrics(o, axis=0):
# if not is_metrics_object(o):
# return False
# min_value = sys.float_info.max
# for v in o.data.vertices:
# if v.co[axis] < min_value:
# min_value = v.co[axis]
# if min_value == sys.float_info.max:
# return False
# return min_value
# def fix_origin_shift_metrics(o, axis=0):
# shift = get_origin_shift_metrics(o)
# if not shift:
# print("False")
# return False
# for v in o.data.vertices:
# v.co[axis] -= shift
# shift_vector = mathutils.Vector((0.0, 0.0, 0.0))
# shift_vector[axis] = shift
# # o.location = o.location - (divide_vectors(v2=o.matrix_world.to_scale()) * (o.matrix_world @ shift_vector))
# o.matrix_local.translation = o.matrix_local.translation + (shift_vector @ o.matrix_local.inverted())
# # update_matrices(o)
# return True
# def fix_objects_metrics_origins(objects=None, axis=0, handle_metrics_directly=True):
# if objects is None:
# objects = bpy.context.selected_objects
# if len(objects) == 0:
# return "no objects selected"
# for o in objects:
# is_possibly_glyph = is_glyph(o)
# if is_possibly_glyph:
# for c in o.children:
# if is_metrics_object(c):
# fix_origin_shift_metrics(c, axis)
# elif is_metrics_object(o) and handle_metrics_directly:
# fix_origin_shift_metrics(o, axis)
# return ""
# def align_origins_to_metrics(objects=None):
# if objects is None:
# objects = bpy.context.selected_objects
# if len(objects) == 0:
# return "no objects selected"
# for o in objects:
# is_possibly_glyph = is_glyph(o)
# if is_possibly_glyph:
# min_x = 9999999999
# for c in o.children:
# if is_metrics_object(c):
# for v in c.data.vertices:
# if v.co[0] < min_x:
# min_x = v.co[0]
# metrics_origin_x = c.matrix_world.translation[0] + min_x
# diff = metrics_origin_x - o.matrix_world.translation[0]
# for v in o.data.vertices:
# v.co[0] -= diff
# o.location += mathutils.Vector((diff, 0.0, 0.0)) @ o.matrix_world.inverted()
# for c in o.children:
# if is_metrics_object(c):
# c.location -= mathutils.Vector((diff, 0.0, 0.0)) @ o.matrix_world.inverted()
# return ""