Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 42 additions & 23 deletions Lib/booleanOperations/flatten.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,26 +255,34 @@ def tValueForPoint(self, point):
else:
raise NotImplementedError

def tValueToPoint(self, t):
if self.segmentType == "curve":
on1 = self.previousOnCurve
off1 = self.points[0].coordinates
off2 = self.points[1].coordinates
on2 = self.points[2].coordinates
return _getCubicPoint(t, on1, off1, off2, on2)
elif self.segmentType == "line":
return _getLinePoint(t, self.previousOnCurve, self.points[0].coordinates)
elif self.segmentType == "qcurve":
raise NotImplementedError
else:
raise NotImplementedError

def hasPoint(self, p):
"""
Whether p lies on this segment.

p is tested against the segment's flattened chords. Callers pass
points taken from clipper's output, and clipper builds its polygons
out of those same chords, so a point belonging to this segment lies
on one of them to within clipper's integer rounding.

Testing against the curve would be looser, as the tolerance would
also have to cover the distance between the chords and the curve,
which varies with the curvature of the segment.
"""
if p is None:
return False
for t in self.tValueForPoint(p):
pp = self.tValueToPoint(t)
if _distance(p, pp) < _approximateSegmentLength/100:
x, y = _scaleSinglePoint(p, scale=clipperScale, convertToInteger=False)
tolerance = _approximateSegmentLength/100 * clipperScale
toleranceSquared = tolerance * tolerance
previousPoint = self.scaledPreviousOnCurve
for point in self.flat:
(x0, y0), (x1, y1) = previousPoint, point
previousPoint = point
# reject the chords p is nowhere near before doing any real work
if x < min(x0, x1) - tolerance or x > max(x0, x1) + tolerance:
continue
if y < min(y0, y1) - tolerance or y > max(y0, y1) + tolerance:
continue
if _squaredDistanceToLineSegment((x, y), (x0, y0), (x1, y1)) < toleranceSquared:
return True
return False

Expand Down Expand Up @@ -1137,6 +1145,23 @@ def _distance(pt1, pt2):
return math.sqrt((pt1[0] - pt2[0]) ** 2 + (pt1[1] - pt2[1]) ** 2)


def _squaredDistanceToLineSegment(pt, pt0, pt1):
"""
The square of the distance from pt to the closest point of the line
segment pt0..pt1.
"""
(x, y), (x0, y0), (x1, y1) = pt, pt0, pt1
dx = x1 - x0
dy = y1 - y0
lengthSquared = dx * dx + dy * dy
if lengthSquared:
t = ((x - x0) * dx + (y - y0) * dy) / lengthSquared
t = max(0.0, min(1.0, t))
x0 += dx * t
y0 += dy * t
return (x - x0) ** 2 + (y - y0) ** 2


def _pointOnLine(pt1, pt2, a):
return abs(_distance(pt1, a) + _distance(a, pt2) - _distance(pt1, pt2)) < epsilon

Expand Down Expand Up @@ -1167,12 +1192,6 @@ def _mid(pt1, pt2):
(x0, y0), (x1, y1) = pt1, pt2
return 0.5 * (x0 + x1), 0.5 * (y0 + y1)

def _getLinePoint(t, pt0, pt1):
if t == 0:
return pt0
if t == 1:
return pt1
return pt0[0] + (pt1[0]-pt0[0]) * t, pt0[1] + (pt1[1]-pt0[1]) * t

def _getCubicPoint(t, pt0, pt1, pt2, pt3):
if t == 0:
Expand Down
57 changes: 57 additions & 0 deletions tests/test_BooleanGlyph.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,62 @@ def test_unsupported_qcurve():
with pytest.raises(booleanOperations.exceptions.UnsupportedContourError):
booleanOperations.union(g, None)


# Two self-overlapping contours that meet at a sharp inside corner. Each
# should come back with a single on-curve point at that corner. See issue #72.
_INSIDE_CORNER_CONTOURS = [
[
((550, 380), "line"), ((738, 540), None), ((833, 738), None),
((833, 973), "curve"), ((833, 1148), None), ((781, 1274), None),
((707, 1274), "curve"), ((676, 1274), None), ((653, 1251), None),
((643, 1210), "curve"), ((584, 973), None), ((437, 826), None),
((215, 782), "curve"), ((253, 629), "line"), ((484, 688), None),
((656, 850), None), ((731, 1099), "curve"), ((689, 1087), "line"),
((703, 830), None), ((629, 635), None), ((441, 480), "curve"),
],
[
((289, 923), "line"), ((324, 936), None), ((360, 943), None),
((396, 943), "curve"), ((521, 943), None), ((609, 858), None),
((609, 737), "curve"), ((609, 686), None), ((594, 636), None),
((561, 577), "curve"), ((632, 547), "line"), ((695, 664), None),
((725, 768), None), ((725, 868), "curve"), ((725, 1047), None),
((630, 1159), None), ((478, 1159), "curve"), ((398, 1159), None),
((317, 1129), None), ((237, 1070), "curve"), ((261, 1003), "line"),
((332, 1059), None), ((399, 1086), None), ((468, 1086), "curve"),
((590, 1086), None), ((659, 1002), None), ((659, 870), "curve"),
((659, 829), None), ((651, 781), None), ((643, 740), "curve"),
((653, 712), "line"), ((683, 884), None), ((568, 1012), None),
((418, 1012), "curve"), ((368, 1012), None), ((318, 1001), None),
((269, 978), "curve"),
],
]


@pytest.mark.parametrize("points", _INSIDE_CORNER_CONTOURS)
def test_no_extraneous_oncurve_at_inside_corner(points):
font = defcon.Font()
g = font.newGlyph("test")
p = g.getPointPen()
p.beginPath()
for coordinates, segmentType in points:
p.addPoint(coordinates, segmentType=segmentType)
p.endPath()

result = defcon.Font().newGlyph("result")
booleanOperations.union(g, result.getPointPen())

# An inside corner used to come back as two on-curve points a fraction of
# a flattened step apart, because a valid intersection point was discarded
# and re-derived from the flattened outline.
for contour in result:
oncurves = [pt for pt in contour if pt.segmentType is not None]
for previous, point in zip(oncurves, oncurves[1:] + oncurves[:1]):
distance = math.hypot(previous.x - point.x, previous.y - point.y)
assert distance > 10, (
"on-curve points (%s, %s) and (%s, %s) are only %s units apart"
% (previous.x, previous.y, point.x, point.y, distance)
)


if __name__ == '__main__':
sys.exit(unittest.main())
Loading