# This file is part of pymadcad, distributed under license LGPL v3
'''
This file is to render floating texts and mesh texts
The current implementation of floating texts uses a bitmap font texture baked at program start.
It's following the first technique described in this tutorial: https://learnopengl.com/In-Practice/Text-Rendering
For mesh texts, freetype (https://freetype.org/) is used to read the font files and extract curve primitives from it.
The primitives are then discretized and triangulated using the madcad functions.
When specified by their name, fonts are searched in the system font directories plus the directories in module variable `fontpath`
'''
import os
import sys
from dataclasses import dataclass
from .common import resourcedir
from .mathutils import (Box, vec3, fvec3, fvec4, vec2, fvec2, fmat4,
ceil, sqrt, distance, anglebt, linrange,
)
from .triangulation import triangulation
from .primitives import Segment
from .mesh import Wire, Web, Mesh, web
from . import settings, rendering
from PIL import Image, ImageFont, ImageDraw
import numpy.core as np
import moderngl as mgl
# TODO: utiliser les methodes et attributs ImgeFont.size, .getmetrics(), etc pour avoir les hauteur et largeur de police
fontpath = [resourcedir]
def font_locations():
''' yield system and user font directories
it looks at module variable `fontpath` and then at system font paths
The function is a generator so if iteration is stopped in its beginning, no more than necessary system call is performed
'''
yield from fontpath
home = os.getenv('HOME')
if sys.platform == 'linux':
yield from exploredir(os.path.join(home, '.fonts'))
yield from exploredir('/usr/local/share/fonts')
yield from exploredir('/usr/share/fonts')
yield '/usr/share/fonts'
elif sys.platform == 'win32':
yield os.path.join(home, r'AppData\Local\Microsoft\Windows\Fonts')
yield os.path.join(os.environ['WINDIR'],'fonts')
elif sys.platform == 'darwin':
yield os.path.join(home, 'Library/Fonts')
yield '/Library/Fonts'
yield '/System/Library/Fonts'
def exploredir(dir):
''' list directory elements as string file paths '''
if os.path.exists(dir):
for sub in os.listdir(dir):
yield os.path.join(dir, sub)
def font_list():
''' yield all font files found '''
for location in font_locations():
try:
for name in os.listdir(location):
if name.endswith('.ttf'):
yield name
except (NotADirectoryError, FileNotFoundError):
pass
def font_path(name):
''' find the font file path for the given font name
if the given name is already a path to an existing font file, return it immediately
'''
if os.path.exists(name):
return name
filename = name+'.ttf'
for location in font_locations():
path = os.path.join(location, filename)
if os.path.exists(path): return path
raise FileNotFoundError('unable to find font {}'.format(repr(name)))
def create_font_texture(font: ImageFont, maxchar=1100) -> '(Image, (c,l))':
''' create a font texture by rasterizing a given font '''
# determine the size of the needed texture
fontsize = font.size + 4
width = 64
while 2*(width//fontsize)**2 < maxchar:
width *= 2
c = width//(fontsize//2)
l = maxchar//c +1
width = c * (fontsize//2)
height = l * fontsize
# create texture
tex = Image.new('L', (width,height), 0)
draw = ImageDraw.Draw(tex)
# draw the font onto
for i in range(maxchar):
draw.text(char_placement(font.size, c, l, i), chr(i), fill=255, font=font)
#tex.show()
return tex, (c, l)
def char_placement(fontsize, c, l, n):
fontsize += 4
return ((fontsize//2) * (n%c), fontsize * (n//c))
[docs]class TextDisplay(rendering.Display):
''' halo display of a monospaced text
This class is usually used through `scheme.note_floating()`
'''
pointsdef = [
[0, 0],
[0,-1],
[1,-1],
[0, 0],
[1,-1],
[1, 0],
]
def __init__(self, scene, position, text, size=None, color=None, font=None, align=(0,0), layer=0):
if not color: color = settings.display['annotation_color']
if not size: size = settings.display['view_font_size']
self.position = fvec3(position)
self.color = fvec3(color)
self.size = size
self.layer = layer
# load font
fontfile = font_path(font or 'NotoMono-Regular')
def load(scene):
img, align = create_font_texture(ImageFont.truetype(fontfile, 2*size))
return scene.ctx.texture(img.size, 1, img.tobytes()), align
self.fonttex, self.fontalign = scene.resource(('fontfile', size), load)
# load shader
def load(scene):
shader = scene.ctx.program(
vertex_shader=open(resourcedir+'/shaders/font.vert').read(),
fragment_shader=open(resourcedir+'/shaders/font.frag').read(),
)
shader['fonttex'].value = 0
return shader
self.shader = scene.resource('shader_font', load)
# place triangles
points = np.zeros((len(self.pointsdef)*len(text), 4), 'f4')
l = 0
c = 0
mc = 0
for i,char in enumerate(text):
if char == '\n':
c = 0
l += 1
elif char == '\t':
c += 4 - c%4 # TODO: use a tab size in settings
elif char == ' ':
c += 1
else:
n = ord(char)
#placement = char_placement(2*size, *self.fontalign, n)
for j,add in enumerate(self.pointsdef):
points[len(self.pointsdef)*i + j] = [
add[0]+c,
add[1]-l,
(n % self.fontalign[0] +add[0]) / self.fontalign[0],
(n //self.fontalign[0] -add[1]) / self.fontalign[1],
]
c += 1
if c > mc:
mc = c
l += 1
align = (processalign(align[0], mc), -processalign(align[1], l))
points -= [*align, 0, 0]
self.textsize = (mc,l)
self.visualsize = (-align[0], -align[1], mc//2-align[0], l-align[1])
# create the buffers on the GPU
self.vb_points = scene.ctx.buffer(points)
self.vb_faces = scene.ctx.buffer(np.arange(points.shape[0], dtype='u4'))
self.va = scene.ctx.vertex_array(
self.shader,
[(self.vb_points, '2f 2f', 'v_position', 'v_uv')],
)
def render(self, view):
self.shader['layer'] = self.layer
self.shader['color'].write(self.color)
self.shader['position'].write(fvec3(self.world * fvec4(self.position,1)))
self.shader['view'].write(view.uniforms['view'])
self.shader['proj'].write(view.uniforms['proj'])
self.shader['ratio'].value = (
(self.size-2.5) / view.width()*2,
(self.size-2.5) / view.height()*4,
)
self.fonttex.use(0)
self.va.render(mgl.TRIANGLES)
def stack(self, view):
return ((), 'screen', 2, self.render),
def processalign(align, size):
''' return the concrete alignment, expanding alignments such as 'left', or 'center' to numeric values '''
if isinstance(align, str):
if align in ('left', 'top'): return 0
elif align in ('right', 'bottom'): return size
elif align == 'center': return size/2
else:
raise ValueError("align must be int, float or any of 'left', 'right', 'center'")
else:
return align
def textsize(text, tab=4):
''' visual size of a text as vec2(column,line) '''
l = 0
c = 0
mc = 0
for i,char in enumerate(text):
if char == '\n':
c = 0
l += 1
elif char == '\t':
c += tab - c%tab
else:
c += 1
if c > mc:
mc = c
l += 1
return fvec2(mc, l)
BezierLinear = Segment
@dataclass
class BezierQuadratic:
''' primitive representing a quadratic Bezier spline '''
a: vec3
b: vec3
c: vec3
def __call__(self, t):
u = 1-t
return u**2*self.a + 2*u*t*self.b + t**2*self.c
def mesh(self, resolution=None):
div = settings.curve_resolution(
distance(self.a, self.b) + distance(self.b, self.c),
anglebt(self.b-self.a, self.c-self.b),
resolution)
return Wire(self(t) for t in linrange(0, 1, div=div))
def display(self, scene):
return self.mesh().display(scene)
@dataclass
class BezierCubic:
''' primitive representing a cubic Bezier spline '''
a: vec3
b: vec3
c: vec3
d: vec3
def __call__(self, t):
u = 1-t
return u**3*self.a + 3*u**2*t*self.b + 3*u*t**2*self.c + t**3*self.d
def mesh(self, resolution=None):
div = settings.curve_resolution(
distance(self.a, self.b) + distance(self.b, self.c) + distance(self.c, self.d),
anglebt(self.b-self.a, self.c-self.b) + angletbt(self.c-self.b, self.d-self.c),
resolution)
return Wire(self(t) for t in linrange(0, 1, div=div))
def display(self, scene):
return self.mesh().display(scene)
''' font caching dictionnary
for each font in the cache, an item `'file.ttf': dict` is created
each key in this dictionnary is a string character, giving a dictionnary of already computed stuff about the matchng glyph in the font file
This is an example of its content:
cache_fonts = {
'monospace.ttf': {
'cbox': Box(...),
'web': Web(...),
'mesh': Mesh(...),
}
}
'''
cache_fonts = {}
try:
import freetype
except ImportError:
pass
else:
def character_primitives(face: freetype.Face) -> list:
''' return a list of primitives describing the outline of the character currently loaded in the given face '''
primitives = []
last = [None]
def move_to(a, ctx):
last[0] = ft2vec(a)
def line_to(b, ctx):
primitives.append(BezierLinear(last[0], ft2vec(b)))
last[0] = ft2vec(b)
def conic_to(b, c, ctx):
primitives.append(BezierQuadratic(last[0], ft2vec(b), ft2vec(c)))
last[0] = ft2vec(c)
def cubic_to(b, c, d, ctx):
primitives.append(BezierCubic(last[0], ft2vec(b), ft2vec(c), ft2vec(d)))
last[0] = ft2vec(d)
# in fact there will be no cubic_to because there is no such things in freetype fonts, but just in case for freetype-py
face.glyph.outline.decompose(move_to=move_to, line_to=line_to, conic_to=conic_to, cubic_to=cubic_to)
return primitives
def ft2vec(ft): return vec3(ft.x, ft.y, 0)
def character_cbox(face: freetype.Face) -> Box:
''' return the cbox of the current glyph loaded in the face '''
box = face.glyph.outline.get_cbox()
return Box(
min=vec3(box.xMin, box.yMin, 0),
max=vec3(box.xMax, box.yMax, 0))
def character_outline(char: str, font: str, resolution=None) -> dict:
''' return a discretized character outline, cached '''
try:
return cache_fonts[font][char]
except (KeyError, AttributeError):
if font not in cache_fonts: cache_fonts[font] = {}
if char not in cache_fonts[font]: cache_fonts[font][char] = {}
cache = cache_fonts[font][char]
if isinstance(font, str):
face = freetype.Face(font_path(font))
else:
face = font
scale = 1024
face.set_char_size(scale)
face.load_char(char)
cache['fixed'] = face.is_fixed_width
cache['cbox'] = character_cbox(face) .transform(1/scale)
cache['web'] = web(character_primitives(face), resolution=resolution) .transform(1/scale) .mergegroups()
return cache
def character_surface(char, font, resolution=None) -> dict:
''' return a triangulated character, cached '''
try:
return cache_fonts[font][char]
except (KeyError, AttributeError):
cache = character_outline(char, font, resolution)
cache['mesh'] = triangulation(cache['web'])
return cache
[docs] def text(text: str, font:str=None, size:float=1, spacing=vec2(0.05, 0.2), fill=True, align=(0,0), resolution=None):
''' return a Mesh/Web containing the given text written using the given font
The meshed font is cached so long texts are still fast to mesh
Parameters:
text: a multiline string to represent
font: the string name of a font such as `'NotoMono-Bold'` or the path to a `.ttf` font file
size: the character size (metric unit)
spacing: spacing ratio between characters `vec2(horizontal, vertical)`
fill: if True, the characters are triangulated and the function returns a `Mesh`, else it returns its outline as a `Web`
align:
text alignment, the tuple items can be:
- 'left' or 'top'
- 'center'
- 'right' or 'bottom'
- any float, as an offset on the position
resolution: discretisation setting for the character primitives
Example:
>>> part = text('Hello everyone.\\nthis is a great font !!',
... font='NotoSans-Regular',
... align=('left', 0),
... fill=True)
'''
if fill:
pool = Mesh()
character = character_surface
else:
pool = Web()
character = character_outline
face = freetype.Face(font_path(font or 'NotoMono-Regular'))
position = vec3(0)
for char in text:
if char == ' ':
position.x += 0.3
elif char == '\t':
width = 0.3
position.x += int(position.x/width) * width
elif char == '\n':
position.y -= (1+spacing.y)
position.x = 0
else:
cache = character(char, face, resolution)
if fill: part = cache['mesh']
else: part = cache['web']
pool += part.transform(position-vec3(cache['cbox'].min.x,0,0))
if cache['fixed']:
position.x += 0.5 + spacing.x
else:
position.x += cache['cbox'].width.x + spacing.x
if size != 1:
pool = pool.transform(size)
if align != (0,0):
width = pool.box().width
pool = pool.transform(vec3(
processalign(align[0], -width[0]),
processalign(align[1], -width[1]),
0))
return pool
def test_character_primitives():
from .rendering import show
face = freetype.Face('cad/pymadcad/madcad/NotoMono-Regular.ttf')
face.set_char_size(1024)
face.load_char('&')
show([ triangulation(character_primitives(face)) ])
def test_character_cached():
from .rendering import show
show([
character_outline('&', 'NotoMono-Regular').web,
character_surface('g', 'NotoMono-Regular').mesh.transform(vec3(1,0,0)),
])
def test_text():
from .rendering import show
from .generation import extrusion
from .scheme import note_distance
settings.display['view_font_size'] = 10
part = text('Hello everyone.\nthis is a great font !!', font='NotoSans-Regular', align=('center', 0))
part = extrusion(vec3(0,0,-1), part.flip())
show([
vec3(0),
note_distance(vec3(0), vec3(0,1,0), offset=vec3(-0.5,0,0), text='size'),
part,
],
display_wire=True,
)