Coverage for /usr/lib/python3/dist-packages/fontTools/colorLib/geometry.py: 26%

62 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-14 15:55 +0200

1"""Helpers for manipulating 2D points and vectors in COLR table.""" 

2 

3from math import copysign, cos, hypot, isclose, pi 

4from fontTools.misc.roundTools import otRound 

5 

6 

7def _vector_between(origin, target): 

8 return (target[0] - origin[0], target[1] - origin[1]) 

9 

10 

11def _round_point(pt): 

12 return (otRound(pt[0]), otRound(pt[1])) 

13 

14 

15def _unit_vector(vec): 

16 length = hypot(*vec) 

17 if length == 0: 

18 return None 

19 return (vec[0] / length, vec[1] / length) 

20 

21 

22_CIRCLE_INSIDE_TOLERANCE = 1e-4 

23 

24 

25# The unit vector's X and Y components are respectively 

26# U = (cos(α), sin(α)) 

27# where α is the angle between the unit vector and the positive x axis. 

28_UNIT_VECTOR_THRESHOLD = cos(3 / 8 * pi) # == sin(1/8 * pi) == 0.38268343236508984 

29 

30 

31def _rounding_offset(direction): 

32 # Return 2-tuple of -/+ 1.0 or 0.0 approximately based on the direction vector. 

33 # We divide the unit circle in 8 equal slices oriented towards the cardinal 

34 # (N, E, S, W) and intermediate (NE, SE, SW, NW) directions. To each slice we 

35 # map one of the possible cases: -1, 0, +1 for either X and Y coordinate. 

36 # E.g. Return (+1.0, -1.0) if unit vector is oriented towards SE, or 

37 # (-1.0, 0.0) if it's pointing West, etc. 

38 uv = _unit_vector(direction) 

39 if not uv: 

40 return (0, 0) 

41 

42 result = [] 

43 for uv_component in uv: 

44 if -_UNIT_VECTOR_THRESHOLD <= uv_component < _UNIT_VECTOR_THRESHOLD: 

45 # unit vector component near 0: direction almost orthogonal to the 

46 # direction of the current axis, thus keep coordinate unchanged 

47 result.append(0) 

48 else: 

49 # nudge coord by +/- 1.0 in direction of unit vector 

50 result.append(copysign(1.0, uv_component)) 

51 return tuple(result) 

52 

53 

54class Circle: 

55 def __init__(self, centre, radius): 

56 self.centre = centre 

57 self.radius = radius 

58 

59 def __repr__(self): 

60 return f"Circle(centre={self.centre}, radius={self.radius})" 

61 

62 def round(self): 

63 return Circle(_round_point(self.centre), otRound(self.radius)) 

64 

65 def inside(self, outer_circle, tolerance=_CIRCLE_INSIDE_TOLERANCE): 

66 dist = self.radius + hypot(*_vector_between(self.centre, outer_circle.centre)) 

67 return ( 

68 isclose(outer_circle.radius, dist, rel_tol=_CIRCLE_INSIDE_TOLERANCE) 

69 or outer_circle.radius > dist 

70 ) 

71 

72 def concentric(self, other): 

73 return self.centre == other.centre 

74 

75 def move(self, dx, dy): 

76 self.centre = (self.centre[0] + dx, self.centre[1] + dy) 

77 

78 

79def round_start_circle_stable_containment(c0, r0, c1, r1): 

80 """Round start circle so that it stays inside/outside end circle after rounding. 

81 

82 The rounding of circle coordinates to integers may cause an abrupt change 

83 if the start circle c0 is so close to the end circle c1's perimiter that 

84 it ends up falling outside (or inside) as a result of the rounding. 

85 To keep the gradient unchanged, we nudge it in the right direction. 

86 

87 See: 

88 https://github.com/googlefonts/colr-gradients-spec/issues/204 

89 https://github.com/googlefonts/picosvg/issues/158 

90 """ 

91 start, end = Circle(c0, r0), Circle(c1, r1) 

92 

93 inside_before_round = start.inside(end) 

94 

95 round_start = start.round() 

96 round_end = end.round() 

97 inside_after_round = round_start.inside(round_end) 

98 

99 if inside_before_round == inside_after_round: 

100 return round_start 

101 elif inside_after_round: 

102 # start was outside before rounding: we need to push start away from end 

103 direction = _vector_between(round_end.centre, round_start.centre) 

104 radius_delta = +1.0 

105 else: 

106 # start was inside before rounding: we need to push start towards end 

107 direction = _vector_between(round_start.centre, round_end.centre) 

108 radius_delta = -1.0 

109 dx, dy = _rounding_offset(direction) 

110 

111 # At most 2 iterations ought to be enough to converge. Before the loop, we 

112 # know the start circle didn't keep containment after normal rounding; thus 

113 # we continue adjusting by -/+ 1.0 until containment is restored. 

114 # Normal rounding can at most move each coordinates -/+0.5; in the worst case 

115 # both the start and end circle's centres and radii will be rounded in opposite 

116 # directions, e.g. when they move along a 45 degree diagonal: 

117 # c0 = (1.5, 1.5) ===> (2.0, 2.0) 

118 # r0 = 0.5 ===> 1.0 

119 # c1 = (0.499, 0.499) ===> (0.0, 0.0) 

120 # r1 = 2.499 ===> 2.0 

121 # In this example, the relative distance between the circles, calculated 

122 # as r1 - (r0 + distance(c0, c1)) is initially 0.57437 (c0 is inside c1), and 

123 # -1.82842 after rounding (c0 is now outside c1). Nudging c0 by -1.0 on both 

124 # x and y axes moves it towards c1 by hypot(-1.0, -1.0) = 1.41421. Two of these 

125 # moves cover twice that distance, which is enough to restore containment. 

126 max_attempts = 2 

127 for _ in range(max_attempts): 

128 if round_start.concentric(round_end): 

129 # can't move c0 towards c1 (they are the same), so we change the radius 

130 round_start.radius += radius_delta 

131 assert round_start.radius >= 0 

132 else: 

133 round_start.move(dx, dy) 

134 if inside_before_round == round_start.inside(round_end): 

135 break 

136 else: # likely a bug 

137 raise AssertionError( 

138 f"Rounding circle {start} " 

139 f"{'inside' if inside_before_round else 'outside'} " 

140 f"{end} failed after {max_attempts} attempts!" 

141 ) 

142 

143 return round_start