Repository: fonttools/fonttools

Issue:
variationModel.interpolateFromMasters doesn't extrapolate as expected

With `extrapolate=True` there is an issue with`variationModel.interpolateFromMasters` as it clips results in 2 value models. Results are as expected in 3 value models. 

```python
from fontTools.varLib.models import VariationModel

# extrapolate under and over a 3 value system
locations = [
    dict(wght=-1),
    dict(wght=0),
    dict(wght=1),    
]

values = [30, 40, 50]

m = VariationModel(locations, extrapolate=True)
assert m.interpolateFromMasters(dict(wght=-1.5), values) == 25
assert m.interpolateFromMasters(dict(wght=-1), values) == 30
assert m.interpolateFromMasters(dict(wght=0), values) == 40
assert m.interpolateFromMasters(dict(wght=0.5), values) == 45
assert m.interpolateFromMasters(dict(wght=1), values) == 50
assert m.interpolateFromMasters(dict(wght=1.5), values) == 55


# extrapolate under and over a 2 value system
locations = [
    dict(wght=0),
    dict(wght=1),    
]

values = [40, 50]

m = VariationModel(locations, extrapolate=True)

# values under 0 are clipped.
print(m.interpolateFromMasters(dict(wght=-1), values))
print(m.interpolateFromMasters(dict(wght=-2), values))
print(m.interpolateFromMasters(dict(wght=-3), values))

#> 40.0
#> 40.0
#> 40.0

# this one fails
#assert m.interpolateFromMasters(dict(wght=-1), values) == 30

# the rest works as expected
assert m.interpolateFromMasters(dict(wght=0), values) == 40
assert m.interpolateFromMasters(dict(wght=0.5), values) == 45
assert m.interpolateFromMasters(dict(wght=1), values) == 50
assert m.interpolateFromMasters(dict(wght=1.5), values) == 55
```


Discussion:
at least it appears to be intentional e.g. see the docstring of VariationModel (emphasis mine)

https://github.com/fonttools/fonttools/commit/5284ac3c588fd329729ed29d0270e1c394b53d27

> If the extrapolate argument is set to True, then location values are
    interpretted [sic] in the normalized space, ie. in the [-1,+1] range, and
    values are extrapolated _outside this range_.

I'm sure @behdad can clarify that.
What @anthrotype said. Current extrapolation implementation extrapolations the values at -1 and +1.
Would it be possible to express the hope it would eventually also work for values between [0,1]?
I have to check the code. I think it's possible, yes. In that case it wouldn't need any specific range and would adapt to the min/max specified.
This is trivial for one axis, less so for more axes.
True. In MutatorMath that was a bit of work ([lines starting here](https://github.com/LettError/MutatorMath/blob/f7a7d7b0ea4a380ff164e2c303f96734ede245fd/Lib/mutatorMath/objects/mutator.py#L304)). This is too specific and can't be re-used for varlib. But I imagine it will be similar enough: a couple of different cases that need to be calculated separately.

Resolving patch:
diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py
--- a/Lib/fontTools/varLib/models.py
+++ b/Lib/fontTools/varLib/models.py
@@ -115,12 +115,15 @@ def normalizeLocation(location, axes):
     return out
 
 
-def supportScalar(location, support, ot=True, extrapolate=False):
+def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None):
     """Returns the scalar multiplier at location, for a master
     with support.  If ot is True, then a peak value of zero
     for support of an axis means "axis does not participate".  That
     is how OpenType Variation Font technology works.
 
+    If extrapolate is True, axisRanges must be a dict that maps axis
+    names to (axisMin, axisMax) tuples.
+
       >>> supportScalar({}, {})
       1.0
       >>> supportScalar({'wght':.2}, {})
@@ -137,11 +140,17 @@ def supportScalar(location, support, ot=True, extrapolate=False):
       0.75
       >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
       0.75
-      >>> supportScalar({'wght':4}, {'wght':(0,2,3)}, extrapolate=True)
-      2.0
-      >>> supportScalar({'wght':4}, {'wght':(0,2,2)}, extrapolate=True)
-      2.0
+      >>> supportScalar({'wght':3}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
+      -1.0
+      >>> supportScalar({'wght':-1}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
+      -1.0
+      >>> supportScalar({'wght':3}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
+      1.5
+      >>> supportScalar({'wght':-1}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
+      -0.5
     """
+    if extrapolate and axisRanges is None:
+        raise TypeError("axisRanges must be passed when extrapolate is True")
     scalar = 1.0
     for axis, (lower, peak, upper) in support.items():
         if ot:
@@ -160,18 +169,19 @@ def supportScalar(location, support, ot=True, extrapolate=False):
             continue
 
         if extrapolate:
-            if v < -1 and lower <= -1:
-                if peak <= -1 and peak < upper:
+            axisMin, axisMax = axisRanges[axis]
+            if v < axisMin and lower <= axisMin:
+                if peak <= axisMin and peak < upper:
                     scalar *= (v - upper) / (peak - upper)
                     continue
-                elif -1 < peak:
+                elif axisMin < peak:
                     scalar *= (v - lower) / (peak - lower)
                     continue
-            elif +1 < v and +1 <= upper:
-                if +1 <= peak and lower < peak:
+            elif axisMax < v and axisMax <= upper:
+                if axisMax <= peak and lower < peak:
                     scalar *= (v - lower) / (peak - lower)
                     continue
-                elif peak < +1:
+                elif peak < axisMax:
                     scalar *= (v - upper) / (peak - upper)
                     continue
 
@@ -189,9 +199,8 @@ def supportScalar(location, support, ot=True, extrapolate=False):
 class VariationModel(object):
     """Locations must have the base master at the origin (ie. 0).
 
-    If the extrapolate argument is set to True, then location values are
-    interpretted in the normalized space, ie. in the [-1,+1] range, and
-    values are extrapolated outside this range.
+    If the extrapolate argument is set to True, then values are extrapolated
+    outside the axis range.
 
       >>> from pprint import pprint
       >>> locations = [ \
@@ -241,6 +250,7 @@ def __init__(self, locations, axisOrder=None, extrapolate=False):
         self.origLocations = locations
         self.axisOrder = axisOrder if axisOrder is not None else []
         self.extrapolate = extrapolate
+        self.axisRanges = self.computeAxisRanges(locations) if extrapolate else None
 
         locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations]
         keyFunc = self.getMasterLocationsSortKeyFunc(
@@ -265,6 +275,17 @@ def getSubModel(self, items):
             self._subModels[key] = subModel
         return subModel, subList(key, items)
 
+    @staticmethod
+    def computeAxisRanges(locations):
+        axisRanges = {}
+        allAxes = {axis for loc in locations for axis in loc.keys()}
+        for loc in locations:
+            for axis in allAxes:
+                value = loc.get(axis, 0)
+                axisMin, axisMax = axisRanges.get(axis, (value, value))
+                axisRanges[axis] = min(value, axisMin), max(value, axisMax)
+        return axisRanges
+
     @staticmethod
     def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
         if {} not in locations:
@@ -439,8 +460,12 @@ def getDeltasAndSupports(self, items, *, round=noRound):
         return model.getDeltas(items, round=round), model.supports
 
     def getScalars(self, loc):
-        return [supportScalar(loc, support, extrapolate=self.extrapolate)
-                for support in self.supports]
+        return [
+            supportScalar(
+                loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
+            )
+            for support in self.supports
+        ]
 
     @staticmethod
     def interpolateFromDeltasAndScalars(deltas, scalars):


Test patch:
diff --git a/Tests/varLib/models_test.py b/Tests/varLib/models_test.py
--- a/Tests/varLib/models_test.py
+++ b/Tests/varLib/models_test.py
@@ -36,10 +36,40 @@ def test_supportScalar():
     assert supportScalar({"wght": 0.2}, {}) == 1.0
     assert supportScalar({"wght": 0.2}, {"wght": (0, 2, 3)}) == 0.1
     assert supportScalar({"wght": 2.5}, {"wght": (0, 2, 4)}) == 0.75
-    assert supportScalar({"wght": 4}, {"wght": (0, 2, 2)}) == 0.0
-    assert supportScalar({"wght": 4}, {"wght": (0, 2, 2)}, extrapolate=True) == 2.0
-    assert supportScalar({"wght": 4}, {"wght": (0, 2, 3)}, extrapolate=True) == 2.0
-    assert supportScalar({"wght": 2}, {"wght": (0, 0.75, 1)}, extrapolate=True) == -4.0
+    assert supportScalar({"wght": 3}, {"wght": (0, 2, 2)}) == 0.0
+    assert supportScalar({"wght": 3}, {"wght": (0, 2, 2)}, extrapolate=True, axisRanges={"wght": (0, 2)}) == 1.5
+    assert supportScalar({"wght": -1}, {"wght": (0, 2, 2)}, extrapolate=True, axisRanges={"wght": (0, 2)}) == -0.5
+    assert supportScalar({"wght": 3}, {"wght": (0, 1, 2)}, extrapolate=True, axisRanges={"wght": (0, 2)}) == -1.0
+    assert supportScalar({"wght": -1}, {"wght": (0, 1, 2)}, extrapolate=True, axisRanges={"wght": (0, 2)}) == -1.0
+    assert supportScalar({"wght": 2}, {"wght": (0, 0.75, 1)}, extrapolate=True, axisRanges={"wght": (0, 1)}) == -4.0
+    with pytest.raises(TypeError):
+        supportScalar({"wght": 2}, {"wght": (0, 0.75, 1)}, extrapolate=True, axisRanges=None)
+
+
+def test_model_extrapolate():
+    locations = [{}, {"a": 1}, {"b": 1}, {"a": 1, "b": 1}]
+    model = VariationModel(locations, extrapolate=True)
+    masterValues = [100, 200, 300, 400]
+    testLocsAndValues = [
+        ({"a": -1, "b": -1}, -200),
+        ({"a": -1, "b": 0}, 0),
+        ({"a": -1, "b": 1}, 200),
+        ({"a": -1, "b": 2}, 400),
+        ({"a": 0, "b": -1}, -100),
+        ({"a": 0, "b": 0}, 100),
+        ({"a": 0, "b": 1}, 300),
+        ({"a": 0, "b": 2}, 500),
+        ({"a": 1, "b": -1}, 0),
+        ({"a": 1, "b": 0}, 200),
+        ({"a": 1, "b": 1}, 400),
+        ({"a": 1, "b": 2}, 600),
+        ({"a": 2, "b": -1}, 100),
+        ({"a": 2, "b": 0}, 300),
+        ({"a": 2, "b": 1}, 500),
+        ({"a": 2, "b": 2}, 700),
+    ]
+    for loc, expectedValue in testLocsAndValues:
+        assert expectedValue == model.interpolateFromMasters(loc, masterValues)
 
 
 @pytest.mark.parametrize(