add a Circle class, handle concentrical case, explain why 2 iterations are enough

This commit is contained in:
Cosimo Lupo 2021-01-15 16:59:17 +00:00
parent 4f886cc226
commit 4f1102ac6e
No known key found for this signature in database
GPG Key ID: 179A8F0895A02F4F
3 changed files with 94 additions and 55 deletions

View File

@ -534,10 +534,11 @@ class LayerV1ListBuilder:
r1 = _to_variable_value(r1) r1 = _to_variable_value(r1)
# avoid abrupt change after rounding when c0 is near c1's perimeter # avoid abrupt change after rounding when c0 is near c1's perimeter
c0x, c0y = nudge_start_circle_almost_inside( c = nudge_start_circle_almost_inside(
(x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value (x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value
) )
x0, y0 = x0._replace(value=c0x), y0._replace(value=c0y) x0, y0 = x0._replace(value=c.centre[0]), y0._replace(value=c.centre[1])
r0 = r0._replace(value=c.radius)
for i, (x, y, r) in enumerate(((x0, y0, r0), (x1, y1, r1))): for i, (x, y, r) in enumerate(((x0, y0, r0), (x1, y1, r1))):
# rounding happens here as floats are converted to integers # rounding happens here as floats are converted to integers

View File

@ -12,10 +12,6 @@ def _round_point(pt):
return (otRound(pt[0]), otRound(pt[1])) return (otRound(pt[0]), otRound(pt[1]))
def _round_circle(centre, radius):
return _round_point(centre), otRound(radius)
def _unit_vector(vec): def _unit_vector(vec):
length = hypot(*vec) length = hypot(*vec)
if length == 0: if length == 0:
@ -28,11 +24,6 @@ def _unit_vector(vec):
_NEARLY_ZERO = 1 / (1 << 12) # 0.000244140625 _NEARLY_ZERO = 1 / (1 << 12) # 0.000244140625
def _is_circle_inside_circle(inner_centre, inner_radius, outer_centre, outer_radius):
dist = inner_radius + hypot(*_vector_between(inner_centre, outer_centre))
return abs(outer_radius - dist) <= _NEARLY_ZERO or outer_radius > dist
# The unit vector's X and Y components are respectively # The unit vector's X and Y components are respectively
# U = (cos(α), sin(α)) # U = (cos(α), sin(α))
# where α is the angle between the unit vector and the positive x axis. # where α is the angle between the unit vector and the positive x axis.
@ -62,8 +53,33 @@ def _nudge_point(pt, direction):
return tuple(result) return tuple(result)
class Circle:
def __init__(self, centre, radius):
self.centre = centre
self.radius = radius
def __repr__(self):
return f"Circle(centre={self.centre}, radius={self.radius})"
def round(self):
return Circle(_round_point(self.centre), otRound(self.radius))
def inside(self, outer_circle):
dist = self.radius + hypot(*_vector_between(self.centre, outer_circle.centre))
return (
abs(outer_circle.radius - dist) <= _NEARLY_ZERO
or outer_circle.radius > dist
)
def concentric(self, other):
return self.centre == other.centre
def nudge_towards(self, direction):
self.centre = _nudge_point(self.centre, direction)
def nudge_start_circle_almost_inside(c0, r0, c1, r1): def nudge_start_circle_almost_inside(c0, r0, c1, r1):
""" Nudge c0 so it continues to be inside/outside c1 after rounding. """Nudge c0 so it continues to be inside/outside c1 after rounding.
The rounding of circle coordinates to integers may cause an abrupt change The rounding of circle coordinates to integers may cause an abrupt change
if the start circle c0 is so close to the end circle c1's perimiter that if the start circle c0 is so close to the end circle c1's perimiter that
@ -74,29 +90,50 @@ def nudge_start_circle_almost_inside(c0, r0, c1, r1):
https://github.com/googlefonts/colr-gradients-spec/issues/204 https://github.com/googlefonts/colr-gradients-spec/issues/204
https://github.com/googlefonts/picosvg/issues/158 https://github.com/googlefonts/picosvg/issues/158
""" """
inside_before_round = _is_circle_inside_circle(c0, r0, c1, r1) start_circle, end_circle = Circle(c0, r0), Circle(c1, r1)
rc0, rr0 = _round_circle(c0, r0)
rc1, rr1 = _round_circle(c1, r1)
inside_after_round = _is_circle_inside_circle(rc0, rr0, rc1, rr1)
if inside_before_round != inside_after_round: inside_before_round = start_circle.inside(end_circle)
# at most 2 iterations ought to be enough to converge
for _ in range(2): round_start = start_circle.round()
if rc0 == rc1: # nowhere to nudge along a zero vector, bail out round_end = end_circle.round()
break
# At most 3 iterations ought to be enough to converge. In the first, we
# check if the start circle keeps containment after normal rounding; then
# we continue adjusting by -/+ 1.0 until containment is restored.
# Normal rounding can at most move each coordinates -/+0.5; in the worst case
# both the start and end circle's centres and radii will be rounded in opposite
# directions, e.g. when they move along a 45 degree diagonal:
# c0 = (1.5, 1.5) ===> (2.0, 2.0)
# r0 = 0.5 ===> 1.0
# c1 = (0.499, 0.499) ===> (0.0, 0.0)
# r1 = 2.499 ===> 2.0
# In this example, the relative distance between the circles, calculated
# as r1 - (r0 + distance(c0, c1)) is initially 0.57437 (c0 is inside c1), and
# -1.82842 after rounding (c0 is now outside c1). Nudging c0 by -1.0 on both
# x and y axes moves it towards c1 by hypot(-1.0, -1.0) = 1.41421. Two of these
# moves cover twice that distance, which is enough to restore containment.
max_attempts = 3
for _ in range(max_attempts):
inside_after_round = round_start.inside(round_end)
if inside_before_round == inside_after_round:
break
if round_start.concentric(round_end):
# can't move c0 towards c1 (they are the same), so we change the radius
if inside_after_round: if inside_after_round:
direction = _vector_between(rc1, rc0) round_start.radius += 1.0
else: else:
direction = _vector_between(rc0, rc1) round_start.radius -= 1.0
rc0 = _nudge_point(rc0, direction) else:
inside_after_round = _is_circle_inside_circle(rc0, rr0, rc1, rr1) if inside_after_round:
if inside_before_round == inside_after_round: direction = _vector_between(round_end.centre, round_start.centre)
break else:
else: # ... or it's a bug direction = _vector_between(round_start.centre, round_end.centre)
raise AssertionError( round_start.nudge_towards(direction)
f"Nudging circle <c0={c0}, r0={r0}> " else: # likely a bug
f"{'inside' if inside_before_round else 'outside'} " raise AssertionError(
f"<c1={c1}, r1={r1}> failed after two attempts!" f"Rounding circle {start_circle} "
) f"{'inside' if inside_before_round else 'outside'} "
f"{end_circle} failed after {max_attempts} attempts!"
)
return rc0 return round_start

View File

@ -1,11 +1,7 @@
from fontTools.ttLib import newTable from fontTools.ttLib import newTable
from fontTools.ttLib.tables import otTables as ot from fontTools.ttLib.tables import otTables as ot
from fontTools.colorLib import builder from fontTools.colorLib import builder
from fontTools.colorLib.geometry import ( from fontTools.colorLib.geometry import nudge_start_circle_almost_inside, Circle
nudge_start_circle_almost_inside,
_is_circle_inside_circle,
_round_circle,
)
from fontTools.colorLib.builder import LayerV1ListBuilder from fontTools.colorLib.builder import LayerV1ListBuilder
from fontTools.colorLib.errors import ColorLibError from fontTools.colorLib.errors import ColorLibError
import pytest import pytest
@ -1066,18 +1062,19 @@ class TrickyRadialGradientTest:
@staticmethod @staticmethod
def circle_inside_circle(c0, r0, c1, r1, rounded=False): def circle_inside_circle(c0, r0, c1, r1, rounded=False):
if rounded: if rounded:
return _is_circle_inside_circle( return Circle(c0, r0).round().inside(Circle(c1, r1).round())
*_round_circle(c0, r0), *_round_circle(c1, r1)
)
else: else:
return _is_circle_inside_circle(c0, r0, c1, r1) return Circle(c0, r0).inside(Circle(c1, r1))
def nudge_start_circle_position(self, c0, r0, c1, r1, inside=True): def nudge_start_circle_position(self, c0, r0, c1, r1, inside=True):
assert self.circle_inside_circle(c0, r0, c1, r1) is inside assert self.circle_inside_circle(c0, r0, c1, r1) is inside
assert self.circle_inside_circle(c0, r0, c1, r1, rounded=True) is not inside assert self.circle_inside_circle(c0, r0, c1, r1, rounded=True) is not inside
c0 = nudge_start_circle_almost_inside(c0, r0, c1, r1) r = nudge_start_circle_almost_inside(c0, r0, c1, r1)
assert self.circle_inside_circle(c0, r0, c1, r1, rounded=True) is inside assert (
return c0 self.circle_inside_circle(r.centre, r.radius, c1, r1, rounded=True)
is inside
)
return r.centre, r.radius
def test_noto_emoji_mosquito_u1f99f(self): def test_noto_emoji_mosquito_u1f99f(self):
# https://github.com/googlefonts/picosvg/issues/158 # https://github.com/googlefonts/picosvg/issues/158
@ -1085,22 +1082,26 @@ class TrickyRadialGradientTest:
r0 = 0 r0 = 0
c1 = (642.99108, 104.70327999999995) c1 = (642.99108, 104.70327999999995)
r1 = 260.0072 r1 = 260.0072
rc0 = self.nudge_start_circle_position(c0, r0, c1, r1, inside=True) result = self.nudge_start_circle_position(c0, r0, c1, r1, inside=True)
assert rc0 == (386, 71) assert result == ((386, 71), 0)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"c0, r0, c1, r1, inside, expected", "c0, r0, c1, r1, inside, expected",
[ [
# inside before round, outside after round # inside before round, outside after round
((1.4, 0), 0, (2.6, 0), 1.3, True, (2, 0)), ((1.4, 0), 0, (2.6, 0), 1.3, True, ((2, 0), 0)),
((1, 0), 0.6, (2.8, 0), 2.45, True, (2, 0)), ((1, 0), 0.6, (2.8, 0), 2.45, True, ((2, 0), 1)),
((6.49, 6.49), 0, (0.49, 0.49), 8.49, True, (5, 5)), ((6.49, 6.49), 0, (0.49, 0.49), 8.49, True, ((5, 5), 0)),
# outside before round, inside after round # outside before round, inside after round
((0, 0), 0, (2, 0), 1.5, False, (-1, 0)), ((0, 0), 0, (2, 0), 1.5, False, ((-1, 0), 0)),
((0, -0.5), 0, (0, -2.5), 1.5, False, (0, 1)), ((0, -0.5), 0, (0, -2.5), 1.5, False, ((0, 1), 0)),
# the following ones require two nudges to round correctly # the following ones require two nudges to round correctly
((0.5, 0), 0, (9.4, 0), 8.8, False, (-1, 0)), ((0.5, 0), 0, (9.4, 0), 8.8, False, ((-1, 0), 0)),
((1.5, 1.5), 0, (0.49, 0.49), 1.49, True, (0, 0)), ((1.5, 1.5), 0, (0.49, 0.49), 1.49, True, ((0, 0), 0)),
# limit case when circle almost exactly overlap
((0.5000001, 0), 0.5000001, (0.499999, 0), 0.4999999, True, ((0, 0), 0)),
# concentrical circles, r0 > r1
((0, 0), 1.49, (0, 0), 1, False, ((0, 0), 2)),
], ],
) )
def test_nudge_start_circle_position(self, c0, r0, c1, r1, inside, expected): def test_nudge_start_circle_position(self, c0, r0, c1, r1, inside, expected):