geometry.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. """Helpers for manipulating 2D points and vectors in COLR table."""
  2. from math import copysign, cos, hypot, isclose, pi
  3. from fontTools.misc.roundTools import otRound
  4. def _vector_between(origin, target):
  5. return (target[0] - origin[0], target[1] - origin[1])
  6. def _round_point(pt):
  7. return (otRound(pt[0]), otRound(pt[1]))
  8. def _unit_vector(vec):
  9. length = hypot(*vec)
  10. if length == 0:
  11. return None
  12. return (vec[0] / length, vec[1] / length)
  13. _CIRCLE_INSIDE_TOLERANCE = 1e-4
  14. # The unit vector's X and Y components are respectively
  15. # U = (cos(α), sin(α))
  16. # where α is the angle between the unit vector and the positive x axis.
  17. _UNIT_VECTOR_THRESHOLD = cos(3 / 8 * pi) # == sin(1/8 * pi) == 0.38268343236508984
  18. def _rounding_offset(direction):
  19. # Return 2-tuple of -/+ 1.0 or 0.0 approximately based on the direction vector.
  20. # We divide the unit circle in 8 equal slices oriented towards the cardinal
  21. # (N, E, S, W) and intermediate (NE, SE, SW, NW) directions. To each slice we
  22. # map one of the possible cases: -1, 0, +1 for either X and Y coordinate.
  23. # E.g. Return (+1.0, -1.0) if unit vector is oriented towards SE, or
  24. # (-1.0, 0.0) if it's pointing West, etc.
  25. uv = _unit_vector(direction)
  26. if not uv:
  27. return (0, 0)
  28. result = []
  29. for uv_component in uv:
  30. if -_UNIT_VECTOR_THRESHOLD <= uv_component < _UNIT_VECTOR_THRESHOLD:
  31. # unit vector component near 0: direction almost orthogonal to the
  32. # direction of the current axis, thus keep coordinate unchanged
  33. result.append(0)
  34. else:
  35. # nudge coord by +/- 1.0 in direction of unit vector
  36. result.append(copysign(1.0, uv_component))
  37. return tuple(result)
  38. class Circle:
  39. def __init__(self, centre, radius):
  40. self.centre = centre
  41. self.radius = radius
  42. def __repr__(self):
  43. return f"Circle(centre={self.centre}, radius={self.radius})"
  44. def round(self):
  45. return Circle(_round_point(self.centre), otRound(self.radius))
  46. def inside(self, outer_circle, tolerance=_CIRCLE_INSIDE_TOLERANCE):
  47. dist = self.radius + hypot(*_vector_between(self.centre, outer_circle.centre))
  48. return (
  49. isclose(outer_circle.radius, dist, rel_tol=_CIRCLE_INSIDE_TOLERANCE)
  50. or outer_circle.radius > dist
  51. )
  52. def concentric(self, other):
  53. return self.centre == other.centre
  54. def move(self, dx, dy):
  55. self.centre = (self.centre[0] + dx, self.centre[1] + dy)
  56. def round_start_circle_stable_containment(c0, r0, c1, r1):
  57. """Round start circle so that it stays inside/outside end circle after rounding.
  58. The rounding of circle coordinates to integers may cause an abrupt change
  59. if the start circle c0 is so close to the end circle c1's perimiter that
  60. it ends up falling outside (or inside) as a result of the rounding.
  61. To keep the gradient unchanged, we nudge it in the right direction.
  62. See:
  63. https://github.com/googlefonts/colr-gradients-spec/issues/204
  64. https://github.com/googlefonts/picosvg/issues/158
  65. """
  66. start, end = Circle(c0, r0), Circle(c1, r1)
  67. inside_before_round = start.inside(end)
  68. round_start = start.round()
  69. round_end = end.round()
  70. inside_after_round = round_start.inside(round_end)
  71. if inside_before_round == inside_after_round:
  72. return round_start
  73. elif inside_after_round:
  74. # start was outside before rounding: we need to push start away from end
  75. direction = _vector_between(round_end.centre, round_start.centre)
  76. radius_delta = +1.0
  77. else:
  78. # start was inside before rounding: we need to push start towards end
  79. direction = _vector_between(round_start.centre, round_end.centre)
  80. radius_delta = -1.0
  81. dx, dy = _rounding_offset(direction)
  82. # At most 2 iterations ought to be enough to converge. Before the loop, we
  83. # know the start circle didn't keep containment after normal rounding; thus
  84. # we continue adjusting by -/+ 1.0 until containment is restored.
  85. # Normal rounding can at most move each coordinates -/+0.5; in the worst case
  86. # both the start and end circle's centres and radii will be rounded in opposite
  87. # directions, e.g. when they move along a 45 degree diagonal:
  88. # c0 = (1.5, 1.5) ===> (2.0, 2.0)
  89. # r0 = 0.5 ===> 1.0
  90. # c1 = (0.499, 0.499) ===> (0.0, 0.0)
  91. # r1 = 2.499 ===> 2.0
  92. # In this example, the relative distance between the circles, calculated
  93. # as r1 - (r0 + distance(c0, c1)) is initially 0.57437 (c0 is inside c1), and
  94. # -1.82842 after rounding (c0 is now outside c1). Nudging c0 by -1.0 on both
  95. # x and y axes moves it towards c1 by hypot(-1.0, -1.0) = 1.41421. Two of these
  96. # moves cover twice that distance, which is enough to restore containment.
  97. max_attempts = 2
  98. for _ in range(max_attempts):
  99. if round_start.concentric(round_end):
  100. # can't move c0 towards c1 (they are the same), so we change the radius
  101. round_start.radius += radius_delta
  102. assert round_start.radius >= 0
  103. else:
  104. round_start.move(dx, dy)
  105. if inside_before_round == round_start.inside(round_end):
  106. break
  107. else: # likely a bug
  108. raise AssertionError(
  109. f"Rounding circle {start} "
  110. f"{'inside' if inside_before_round else 'outside'} "
  111. f"{end} failed after {max_attempts} attempts!"
  112. )
  113. return round_start