Skip to content

Instantly share code, notes, and snippets.

@frankrolf
Last active May 15, 2024 17:43
Show Gist options
  • Save frankrolf/a90c95a7c1c798befa9b8b64cd9f1e25 to your computer and use it in GitHub Desktop.
Save frankrolf/a90c95a7c1c798befa9b8b64cd9f1e25 to your computer and use it in GitHub Desktop.
Robofont: close paths based on distance
'''
Robofont: Smart Close Paths
The idea is closing open contours based on point distance rather than
membership of a given contour.
'''
import itertools
import math
from fontTools.pens.basePen import BasePen
from fontTools.ufoLib.pointPen import AbstractPointPen
class CleanPen(AbstractPointPen):
def __init__(self, pointPen):
self.pointPen = pointPen
self.currentContour = None
self.contour_index = -1
def processContour(self):
pointPen = self.pointPen
contour = self.currentContour
filtered_contour = []
pt_index = 0
for pt_index, pt_data in enumerate(contour):
pt_data_prev = contour[(pt_index - 1) % len(contour)]
pt_data_curr = contour[(pt_index)]
coords_prev = pt_data_prev['point']
coords_curr = pt_data_curr['point']
if (
coords_prev == coords_curr and
pt_data_prev['segmentType'] is not None
):
pass
else:
filtered_contour.append(pt_data)
pt_index += 1
pointPen.beginPath()
for data in filtered_contour:
pointPen.addPoint(data['point'], **data)
pointPen.endPath()
def beginPath(self, identifier=None):
assert self.currentContour is None
self.currentContour = []
self.onCurve = []
self.contour_index += 1
def endPath(self):
assert self.currentContour is not None
self.processContour()
self.currentContour = None
def addPoint(
self, pt, segmentType=None, smooth=False, name=None, **kwargs
):
data = dict(
point=pt,
segmentType=segmentType,
smooth=smooth,
name=name
)
data.update(kwargs)
self.currentContour.append(data)
def addComponent(self, glyphName, transform):
pass
# assert self.currentContour is None
# self.pointPen.addComponent(glyphName, transform)
class ClosePen(BasePen):
'''
A pen that behaves like a normal pen, but does not know endPath.
'''
def __init__(self, pen):
self.other_pen = pen
def _moveTo(self, pt):
self.other_pen.moveTo(pt)
def _lineTo(self, pt):
self.other_pen.lineTo(pt)
def _curveToOne(self, pt1, pt2, pt3):
self.other_pen.curveTo(pt1, pt2, pt3)
def endPath(self):
self.other_pen.closePath()
def closePath(self):
self.other_pen.closePath()
class PointInfo(object):
'''
A class that stores info about a given point
'''
def __init__(self, point):
self.x, self.y = point.anchor
self.contour = point.contour
glyph = self.contour.glyph
self.c_index = glyph.contours.index(self.contour)
self.p_index = self.contour.bPoints.index(point)
if self.p_index == 0:
self.lastPoint = False
self.firstPoint = True
else:
self.lastPoint = True
self.firstPoint = False
def point_delta(pt1, pt2):
'''distance between two bpoints'''
return math.hypot(
(pt1.anchor[0] - pt2.anchor[0]),
(pt1.anchor[1] - pt2.anchor[1]))
def add_contour_to_end_bPoints(contour_a, contour_b):
'''adds one contour to another'''
# works in 1.8 not 3 before 3.2
for bp in contour_b.bPoints:
# this worked in 1.8:
# contour_a.appendBPoint(bp.type, bp.anchor, bp.bcpIn, bp.bcpOut)
contour_a.appendBPoint(bp.type, bp.anchor, bp.bcpIn, None)
g.removeContour(contour_b)
def add_contour_to_end(contour_a, contour_b):
'''adds one contour to another'''
for p in contour_b.points:
if p.type == 'move':
contour_a.appendPoint(
(p.x, p.y), 'line', p.smooth, p.name, p.identifier)
else:
contour_a.appendPoint(
(p.x, p.y), p.type, p.smooth, p.name, p.identifier)
g.removeContour(contour_b)
def closest_pair(g):
point_list = []
# collect all start- and endpoints of open contours
for contour in g.contours:
if contour.open:
point_list.append(contour.bPoints[0])
point_list.append(contour.bPoints[-1])
# collect the deltas, and find the minmum delta
delta_dict = {}
for pair in (itertools.combinations(point_list, 2)):
# using a dict here since some point combinations may have the same
# distance.
delta_dict.setdefault(point_delta(*pair), []).append(pair)
if delta_dict:
min_delta = min(delta_dict.keys())
point_pair = delta_dict.get(min_delta)[0]
return point_pair
else:
return
if __name__ == '__main__':
g = CurrentGlyph()
g.prepareUndo('close closest contours')
point_pair = closest_pair(g)
# not sure if that really makes sense
# start_points = {contour.index: contour.bPoints[0].anchor for contour in g.contours}
# if point_pair: # for step-by-step
while point_pair:
point_a, point_b = point_pair
pointinfo_a = PointInfo(point_a)
pointinfo_b = PointInfo(point_b)
contour_a = pointinfo_a.contour
contour_b = pointinfo_b.contour
if pointinfo_a.c_index == pointinfo_b.c_index:
# points are part of the same contour,
# the contour needs to be closed
contour = pointinfo_a.contour
temp_glyph = RGlyph()
pen = ClosePen(temp_glyph.getPen())
contour.draw(pen)
g.removeContour(contour)
g.appendContour(temp_glyph.contours[0])
temp_glyph.clear()
else:
# points are part of different contours
if pointinfo_a.firstPoint and pointinfo_b.firstPoint:
# print('first first')
contour_a.reverse()
# contour_a.insertBPoint(0, 'corner', (point_b.x, point_b.y))
add_contour_to_end(contour_a, contour_b)
contour_a.reverse()
elif pointinfo_a.firstPoint and pointinfo_b.lastPoint:
# print('first last')
contour_a.reverse()
contour_b.reverse()
add_contour_to_end(contour_a, contour_b)
contour_a.reverse()
elif pointinfo_a.lastPoint and pointinfo_b.firstPoint:
# print('last first')
# contour_a.appendBPoint('corner', point_b.anchor)
add_contour_to_end(contour_a, contour_b)
elif pointinfo_a.lastPoint and pointinfo_b.lastPoint:
# print('last last')
contour_b.reverse()
# contour_a.appendBPoint('corner', point_b.anchor)
add_contour_to_end(contour_a, contour_b)
point_pair = closest_pair(g)
g.changed()
g.performUndo()
g_copy = RGlyph()
g.prepareUndo('clean double points')
g.drawPoints(CleanPen(g_copy.getPointPen()))
g.clearContours()
g.appendGlyph(g_copy)
g.changed()
g.performUndo()
g.deselect()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment