diff --git a/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/cssref.html b/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/cssref.html
index e746bccb2e3..7113a0b6b76 100644
--- a/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/cssref.html
+++ b/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/cssref.html
@@ -1789,12 +1789,35 @@
linear | <cubic-bezier-easing-function> | <step-easing-function> | <fx-easing-function>
+ linear | <linear-easing-function> | <cubic-bezier-easing-function> | <step-easing-function> | <fx-easing-function>
Linear linear
- The linear easing function is a simple linear mapping from the input progress value to the output progress value.
+ The linear easing keyword is a simple linear mapping from the input progress value to the output progress value.
+ It it equivalent to linear(0, 1)
+ Linear Easing Function <linear-easing-function>
+ linear([ <number> && <percentage>{0,2} ]#)
+ The linear easing function interpolates linearly between its control points. The control points are specified
+ as a comma-separated list of two or more items, where each item consists of a <number> ,
+ optionally followed by one or two <percentage> values.
+ The <number> specifies the progress of the animation in time.
+ The optional <percentage> specifies when that progress
+ is reached; if two percentages are specified, they indicate a segment of time when the animation is paused.
+ If no percentage is specified, the control point is spaced evenly between neighboring control points.
+ Examples of linear easing functions:
+
+
+ linear(0, 0.25, 1)
+
+
+
+ linear(0, 0.25 75%, 1)
+
+
+
+ linear(0, 0.25 25% 75%, 1)
+
Cubic Bézier Easing Functions <cubic-bezier-easing-function>
ease | ease-in | ease-out | ease-in-out | cubic-bezier(<number [0,1]> , <number> , <number [0,1]> , <number> )
The values have the following meaning:
diff --git a/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/easing-linear-func1.svg b/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/easing-linear-func1.svg
new file mode 100644
index 00000000000..2339b1cca81
--- /dev/null
+++ b/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/easing-linear-func1.svg
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+ input progress
+ 0
+ 1
+ 0
+ 1
+ .5
+ .5
+ .75
+ .75
+ .25
+ .25
+ output progress
+
diff --git a/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/easing-linear-func2.svg b/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/easing-linear-func2.svg
new file mode 100644
index 00000000000..e6c2726f2e7
--- /dev/null
+++ b/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/easing-linear-func2.svg
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+ input progress
+ 0
+ 1
+ 0
+ 1
+ .5
+ .5
+ .75
+ .75
+ .25
+ .25
+ output progress
+
diff --git a/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/easing-linear-func3.svg b/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/easing-linear-func3.svg
new file mode 100644
index 00000000000..c002fa09f79
--- /dev/null
+++ b/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/easing-linear-func3.svg
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+ input progress
+ 0
+ 1
+ 0
+ 1
+ .5
+ .5
+ .75
+ .75
+ .25
+ .25
+ output progress
+
diff --git a/modules/javafx.graphics/src/main/java/com/sun/javafx/css/InterpolatorConverter.java b/modules/javafx.graphics/src/main/java/com/sun/javafx/css/InterpolatorConverter.java
index fafc893db54..19c160c8a5b 100644
--- a/modules/javafx.graphics/src/main/java/com/sun/javafx/css/InterpolatorConverter.java
+++ b/modules/javafx.graphics/src/main/java/com/sun/javafx/css/InterpolatorConverter.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.
+ * Copyright (c) 2024, 2025, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
@@ -29,6 +29,7 @@
import javafx.animation.Interpolator.StepPosition;
import javafx.css.ParsedValue;
import javafx.css.StyleConverter;
+import javafx.geometry.Point2D;
import javafx.scene.text.Font;
import java.util.LinkedHashMap;
import java.util.List;
@@ -45,7 +46,8 @@
*
*
* If the value is a {@code ParsedValue} array, the first element represents the name of the function
- * ({@code cubic-bezier} or {@code steps}), and the second element contains a list of arguments.
+ * ({@code linear}, {@code cubic-bezier}, or {@code steps}), and the second element contains a list of
+ * arguments.
*/
public class InterpolatorConverter extends StyleConverter {
@@ -113,6 +115,11 @@ public Interpolator convert(ParsedValue value, Font font)
});
});
+ case "linear(" -> CACHE.computeIfAbsent(value, key -> {
+ List args = arguments(key);
+ return Interpolator.ofLinear(args.toArray(Point2D[]::new));
+ });
+
default -> throw new AssertionError();
};
}
diff --git a/modules/javafx.graphics/src/main/java/com/sun/scenario/animation/LinearInterpolator.java b/modules/javafx.graphics/src/main/java/com/sun/scenario/animation/LinearInterpolator.java
new file mode 100644
index 00000000000..3d3c08f6c7d
--- /dev/null
+++ b/modules/javafx.graphics/src/main/java/com/sun/scenario/animation/LinearInterpolator.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package com.sun.scenario.animation;
+
+import javafx.animation.Interpolator;
+import javafx.geometry.Point2D;
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Implementation of a piecewise linear interpolator as described by
+ * CSS Easing Functions Level 2
+ */
+public final class LinearInterpolator extends Interpolator {
+
+ // Control points stored as [x0, y0, x1, y1, ...]
+ private final double[] controlPoints;
+
+ public LinearInterpolator(Point2D[] controlPoints) {
+ Objects.requireNonNull(controlPoints, "controlPoints cannot be null");
+
+ if (controlPoints.length < 2) {
+ throw new IllegalArgumentException("controlPoints must have at least two items");
+ }
+
+ int n = controlPoints.length;
+ this.controlPoints = new double[n * 2];
+
+ for (int i = 0; i < n; i++) {
+ Point2D p = controlPoints[i];
+ this.controlPoints[2 * i] = p.getX();
+ this.controlPoints[2 * i + 1] = p.getY();
+ }
+
+ canonicalize(this.controlPoints, n);
+ }
+
+ private static void canonicalize(double[] controlPoints, int n) {
+ // If the first control point has no input progress value, set it to 0.
+ if (Double.isNaN(controlPoints[0])) {
+ controlPoints[0] = 0.0;
+ }
+
+ // If the last control point has no input progress value, set it to 1.
+ int lastIdx = 2 * (n - 1);
+ if (Double.isNaN(controlPoints[lastIdx])) {
+ controlPoints[lastIdx] = 1.0;
+ }
+
+ // Ensure that the input progress value of each control point is greater than or equal to the
+ // input progress values of all preceding control points (monotonically non-decreasing).
+ double largestX = controlPoints[0];
+ for (int i = 1; i < n; ++i) {
+ double curX = controlPoints[2 * i];
+ if (curX < largestX) {
+ controlPoints[2 * i] = largestX;
+ } else if (!Double.isNaN(curX)) {
+ largestX = curX;
+ }
+ }
+
+ // For all control points without input progress value: determine an appropriate input progress value
+ // by equally spacing the control points between neighboring control points.
+ for (int i = 1; i < n; ++i) {
+ if (!Double.isNaN(controlPoints[2 * i])) {
+ continue;
+ }
+
+ int j = i;
+ while (j < n && Double.isNaN(controlPoints[2 * j])) {
+ ++j;
+ }
+
+ double x0 = controlPoints[2 * (i - 1)];
+ double x1 = controlPoints[2 * j];
+ double xStep = (x1 - x0) / (j - i + 1);
+ double x = x0;
+ for (int k = i; k < j; ++k) {
+ x += xStep;
+ controlPoints[2 * k] = x;
+ }
+
+ i = j;
+ }
+ }
+
+ /*
+ * Algorithm implemented based on the following specification:
+ * https://www.w3.org/TR/css-easing-2/#linear-easing-function-output
+ */
+ @Override
+ public double curve(double t) {
+ int n = controlPoints.length / 2;
+ int pointAIndex = 0;
+
+ for (int i = 0; i < n; ++i) {
+ if (controlPoints[2 * i] <= t) {
+ pointAIndex = i;
+ } else {
+ break;
+ }
+ }
+
+ if (pointAIndex == n - 1) {
+ --pointAIndex;
+ }
+
+ int idx = pointAIndex * 2;
+ double pointAInput = controlPoints[idx];
+ double pointAOutput = controlPoints[idx + 1];
+ double pointBInput = controlPoints[idx + 2];
+ double pointBOutput = controlPoints[idx + 3];
+
+ if (pointAInput == pointBInput) {
+ return pointBOutput;
+ }
+
+ double progressFromPointA = t - pointAInput;
+ double pointInputRange = pointBInput - pointAInput;
+ double progressBetweenPoints = progressFromPointA / pointInputRange;
+ double pointOutputRange = pointBOutput - pointAOutput;
+ double outputFromLastPoint = progressBetweenPoints * pointOutputRange;
+ return pointAOutput + outputFromLastPoint;
+ }
+
+ @Override
+ public String toString() {
+ return "LinearInterpolator " + Arrays.toString(controlPoints);
+ }
+}
diff --git a/modules/javafx.graphics/src/main/java/javafx/animation/Interpolator.java b/modules/javafx.graphics/src/main/java/javafx/animation/Interpolator.java
index a2e15275b6b..e95b891d0b7 100644
--- a/modules/javafx.graphics/src/main/java/javafx/animation/Interpolator.java
+++ b/modules/javafx.graphics/src/main/java/javafx/animation/Interpolator.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2010, 2024, Oracle and/or its affiliates. All rights reserved.
+ * Copyright (c) 2010, 2025, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
@@ -25,8 +25,10 @@
package javafx.animation;
+import javafx.geometry.Point2D;
import javafx.util.Duration;
import com.sun.javafx.animation.InterpolatorHelper;
+import com.sun.scenario.animation.LinearInterpolator;
import com.sun.scenario.animation.NumberTangentInterpolator;
import com.sun.scenario.animation.SplineInterpolator;
import com.sun.scenario.animation.StepInterpolator;
@@ -78,9 +80,7 @@ public String toString() {
};
/**
- * Built-in interpolator that provides linear time interpolation. The return
- * value of {@code interpolate()} is {@code startValue} + ({@code endValue}
- * - {@code startValue}) * {@code fraction}.
+ * Built-in interpolator instance that is equivalent to {@code ofLinear([0, 0], [1, 1])}.
*/
public static final Interpolator LINEAR = new Interpolator() {
@Override
@@ -94,6 +94,33 @@ public String toString() {
}
};
+ /**
+ * Returns an interpolator that interpolates linearly between its specified control points.
+ *
+ * Each control point associates an input progress value (X) with an output progress value (Y).
+ * The input progress value is the normalized progress of the animation, while the output progress
+ * value is the normalized progress of the value change. This means that when the time has reached
+ * X of the animation, the animation should be at Y of its value change. Both values are specified
+ * in normalized coordinates, but are not required to fall within the {@code [0..1]} interval.
+ *
+ * If the input progress value (X) of a control point is unspecified ({@link Double#NaN}), it is
+ * distributed evenly between its neighboring control points. If the input progress value of
+ * the first or last control point is unspecified, it is set to 0 or 1, respectively.
+ *
+ * The control point list is canonicalized so that input progress values (X) are non-decreasing in
+ * the order provided. In particular, if a control point specifies an X value that is lower than
+ * any preceding control point's X value, it is adjusted to match the greatest preceding X value.
+ *
+ * @param controlPoints the control points
+ * @throws NullPointerException if {@code controlPoints} is {@code null}
+ * @throws IllegalArgumentException if {@code controlPoints} contains less than two items
+ * @return a linear interpolator
+ * @since 26
+ */
+ public static Interpolator ofLinear(Point2D... controlPoints) {
+ return new LinearInterpolator(controlPoints);
+ }
+
/*
* Easing is calculated with the following algorithm (taken from SMIL 3.0
* specs). The result is clamped because of possible rounding errors.
diff --git a/modules/javafx.graphics/src/main/java/javafx/css/CssParser.java b/modules/javafx.graphics/src/main/java/javafx/css/CssParser.java
index 39c0ce464a2..aac0a14afa0 100644
--- a/modules/javafx.graphics/src/main/java/javafx/css/CssParser.java
+++ b/modules/javafx.graphics/src/main/java/javafx/css/CssParser.java
@@ -74,6 +74,7 @@
import com.sun.javafx.scene.layout.region.StrokeBorderPaintConverter;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
+import javafx.geometry.Point2D;
import javafx.scene.effect.BlurType;
import javafx.scene.layout.BackgroundPosition;
import javafx.scene.layout.BackgroundRepeat;
@@ -4104,6 +4105,43 @@ private ParsedValueImpl, Interpolator> parseEasingFunction(Term term) throws P
}, InterpolatorConverter.getInstance());
}
+ case "linear(" -> {
+ List args = new ArrayList<>();
+
+ for (Term arg = term.firstArg; arg != null; arg = arg.nextArg) {
+ double inputValue = Double.NaN;
+ double outputValue = Double.NaN;
+
+ if (arg == null || arg.token == null || arg.token.getType() != CssLexer.NUMBER) {
+ error(arg, "Expected \'\'");
+ } else {
+ outputValue = Double.parseDouble(arg.token.getText());
+ }
+
+ // 0, 1, or 2 s
+ for (int i = 0; i < 2; ++i) {
+ Term next = arg.nextInSeries;
+ if (next != null) {
+ if (next.token == null || next.token.getType() != CssLexer.PERCENTAGE) {
+ error(next, "Expected \'\'");
+ } else {
+ inputValue = size(next.token).getValue() / 100.0;
+ }
+
+ arg = next;
+ args.add(new Point2D(inputValue, outputValue));
+ } else if (i == 0) {
+ args.add(new Point2D(inputValue, outputValue));
+ }
+ }
+ }
+
+ yield new ParsedValueImpl<>(new ParsedValueImpl[] {
+ new ParsedValueImpl(term.token.getText(), null),
+ new ParsedValueImpl(args, null)
+ }, InterpolatorConverter.getInstance());
+ }
+
default -> {
yield new ParsedValueImpl<>(
new ParsedValueImpl(term.token.getText(), null),
@@ -4112,11 +4150,11 @@ private ParsedValueImpl, Interpolator> parseEasingFunction(Term term) throws P
};
}
- // https://www.w3.org/TR/css-easing-1/#easing-functions
- // = linear | |
+ // https://www.w3.org/TR/css-easing-2/#easing-functions
+ // = linear | | |
private boolean isEasingFunction(Token token) throws ParseException {
return token != null && switch (token.getText()) {
- case "linear" -> true;
+ case "linear", "linear(" -> true;
case "ease", "ease-in", "ease-out", "ease-in-out", "cubic-bezier(" -> true;
case "step-start", "step-end", "steps(" -> true;
case "-fx-ease-in", "-fx-ease-out", "-fx-ease-both" -> true;
diff --git a/modules/javafx.graphics/src/test/addExports b/modules/javafx.graphics/src/test/addExports
index 77f94dfa1f1..c6b7aa659d2 100644
--- a/modules/javafx.graphics/src/test/addExports
+++ b/modules/javafx.graphics/src/test/addExports
@@ -54,6 +54,7 @@
--add-exports javafx.graphics/com.sun.scenario.effect=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.scenario.effect.light=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.scenario=ALL-UNNAMED
+--add-opens javafx.graphics/com.sun.scenario.animation=ALL-UNNAMED
--add-opens javafx.graphics/javafx.css=ALL-UNNAMED
--add-opens javafx.graphics/javafx.scene=ALL-UNNAMED
--add-opens javafx.graphics/javafx.scene.robot=ALL-UNNAMED
diff --git a/modules/javafx.graphics/src/test/java/test/com/sun/javafx/css/converters/InterpolatorConverterTest.java b/modules/javafx.graphics/src/test/java/test/com/sun/javafx/css/converters/InterpolatorConverterTest.java
index d14e7b10f4a..e6e16a2fb78 100644
--- a/modules/javafx.graphics/src/test/java/test/com/sun/javafx/css/converters/InterpolatorConverterTest.java
+++ b/modules/javafx.graphics/src/test/java/test/com/sun/javafx/css/converters/InterpolatorConverterTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.
+ * Copyright (c) 2024, 2025, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
@@ -30,6 +30,7 @@
import javafx.animation.Interpolator;
import javafx.animation.Interpolator.StepPosition;
import javafx.css.ParsedValue;
+import javafx.geometry.Point2D;
import java.util.List;
import java.util.Locale;
import org.junit.jupiter.api.Test;
@@ -48,6 +49,16 @@ public void testConvertLinearInterpolator() {
assertInterpolatorEquals(LINEAR, result);
}
+ @Test
+ public void testConvertLinearInterpolatorWithControlPoints() {
+ var value = new ParsedValueImpl(new ParsedValue[] {
+ new ParsedValueImpl<>("linear(", null),
+ new ParsedValueImpl<>(List.of(new Point2D(0, 0), new Point2D(0.5, 0.25), new Point2D(1, 1)), null) },
+ null);
+ var result = InterpolatorConverter.getInstance().convert(value, null);
+ assertInterpolatorEquals(ofLinear(new Point2D(0, 0), new Point2D(0.5, 0.25), new Point2D(1, 1)), result);
+ }
+
@Test
public void testConvertEaseInterpolator() {
var value = new ParsedValueImpl(new ParsedValueImpl<>("ease", null), null);
diff --git a/modules/javafx.graphics/src/test/java/test/com/sun/scenario/animation/LinearInterpolatorTest.java b/modules/javafx.graphics/src/test/java/test/com/sun/scenario/animation/LinearInterpolatorTest.java
new file mode 100644
index 00000000000..e7a0a5f56e1
--- /dev/null
+++ b/modules/javafx.graphics/src/test/java/test/com/sun/scenario/animation/LinearInterpolatorTest.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package test.com.sun.scenario.animation;
+
+import com.sun.scenario.animation.LinearInterpolator;
+import javafx.geometry.Point2D;
+import test.javafx.util.ReflectionUtils;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class LinearInterpolatorTest {
+
+ @Test
+ void constructor_nullArray_throws() {
+ assertThrows(NullPointerException.class, () -> new LinearInterpolator(null));
+ }
+
+ @Test
+ void constructor_emptyArray_throws() {
+ assertThrows(IllegalArgumentException.class, () -> new LinearInterpolator(new Point2D[0]));
+ }
+
+ @Test
+ void constructor_singleElement_throws() {
+ assertThrows(IllegalArgumentException.class, () -> new LinearInterpolator(new Point2D[] { new Point2D(0, 1) }));
+ }
+
+ @Nested
+ class CanonicalizationTest {
+ @Test
+ void firstAndLastNaN_areCanonicalizedTo0And1() {
+ var interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(Double.NaN, 0.2),
+ new Point2D(Double.NaN, 0.8),
+ });
+
+ assertArrayEquals(
+ new double[] {
+ 0.0, 0.2, // first x: 0
+ 1.0, 0.8 // last x: 1
+ },
+ ReflectionUtils.getFieldValue(interpolator, "controlPoints"),
+ 1e-9);
+ }
+
+ @Test
+ void outOfOrderInputProgress_isCanonicalizedToMonotonicallyNonDecreasing() {
+ // Intentionally out of order: 0.5, 0.25, 0.75
+ LinearInterpolator interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(0.5, 0.0),
+ new Point2D(0.25, 1.0),
+ new Point2D(0.75, 0.5)
+ });
+
+ // After canonicalization, x's should be non-decreasing:
+ // x0 = 0.5
+ // x1 < x0 => clamped to 0.5
+ // x2 = 0.75
+ assertArrayEquals(
+ new double[] { 0.5, 0.0, 0.5, 1.0, 0.75, 0.5 },
+ ReflectionUtils.getFieldValue(interpolator, "controlPoints"),
+ 1e-9);
+ }
+
+ @Test
+ void missingInputProgressValue_isCanonicalizedHalfWayBetweenPreviousAndNext() {
+ var interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(0.0, 0.0),
+ new Point2D(Double.NaN, 0.5),
+ new Point2D(1.0, 1.0)
+ });
+
+ // Middle x should be halfway between 0.0 and 1.0 => 0.5
+ assertArrayEquals(
+ new double[] { 0.0, 0.0, 0.5, 0.5, 1.0, 1.0 },
+ ReflectionUtils.getFieldValue(interpolator, "controlPoints"),
+ 1e-9);
+ }
+
+ @Test
+ void contiguousNaNRange_isCanonicalizedToEvenlySpacedValues() {
+ // x: 0.0, NaN, NaN, NaN, 1.0
+ var interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(0.0, 0.0),
+ new Point2D(Double.NaN, 0.2),
+ new Point2D(Double.NaN, 0.4),
+ new Point2D(Double.NaN, 0.6),
+ new Point2D(1.0, 1.0)
+ });
+
+ // After canonicalization, the missing x's should be evenly spaced:
+ // 0.0, 0.25, 0.5, 0.75, 1.0
+ assertArrayEquals(
+ new double[] {
+ 0.0, 0.0,
+ 0.25, 0.2,
+ 0.5, 0.4,
+ 0.75, 0.6,
+ 1.0, 1.0
+ },
+ ReflectionUtils.getFieldValue(interpolator, "controlPoints"),
+ 1e-9);
+ }
+
+ @Test
+ void twoContiguousNaNRanges_areCanonicalizedToEvenlySpacedValues() {
+ // x: NaN, NaN, NaN, 0.5, NaN, NaN, NaN
+ var interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(Double.NaN, 0.0),
+ new Point2D(Double.NaN, 0.2),
+ new Point2D(Double.NaN, 0.4),
+ new Point2D(0.5, 0.6),
+ new Point2D(Double.NaN, 0.8),
+ new Point2D(Double.NaN, 1.0),
+ new Point2D(Double.NaN, 1.2)
+ });
+
+ // After canonicalization:
+ // - first NaN becomes 0.0 (first point)
+ // - last NaN becomes 1.0 (last point)
+ // We have two NaN runs:
+ // [0, NaN, NaN, 0.5] -> 0.0, 1/6, 2/6, 0.5
+ // [0.5, NaN, NaN, 1] -> 0.5, 4/6, 5/6, 1.0
+ assertArrayEquals(
+ new double[] {
+ 0.0, 0.0,
+ 1.0 / 6.0, 0.2,
+ 2.0 / 6.0, 0.4,
+ 0.5, 0.6,
+ 4.0 / 6.0, 0.8,
+ 5.0 / 6.0, 1.0,
+ 1.0, 1.2
+ },
+ ReflectionUtils.getFieldValue(interpolator, "controlPoints"),
+ 1e-9);
+ }
+ }
+
+ @Nested
+ class CurveTest {
+ @Test
+ void twoPointsNaN_givesSimpleLinearInterpolation() {
+ var interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(Double.NaN, 0.0), // -> x=0
+ new Point2D(Double.NaN, 1.0) // -> x=1
+ });
+
+ assertEquals(0.0, interpolator.curve(0.0), 1e-9);
+ assertEquals(0.5, interpolator.curve(0.5), 1e-9);
+ assertEquals(1.0, interpolator.curve(1.0), 1e-9);
+ }
+
+ @Test
+ void curveUsesLastControlPointForMatchingX() {
+ // x=0, x=0.5, x=0.5, x=1
+ var interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(0.0, 0.0),
+ new Point2D(0.5, 1.0),
+ new Point2D(0.5, 0.2), // same x as previous, different y
+ new Point2D(1.0, 1.0)
+ });
+
+ assertEquals(0.0, interpolator.curve(0.0), 1e-9);
+ assertEquals(0.2, interpolator.curve(0.5), 1e-9); // last y for x=0.5
+ assertEquals(1.0, interpolator.curve(1.0), 1e-9);
+ }
+
+ @Test
+ void curveInterpolatesBetweenInnerPoints() {
+ var interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(0.0, 0.0),
+ new Point2D(0.5, 0.5),
+ new Point2D(1.0, 1.0)
+ });
+
+ assertEquals(0.25, interpolator.curve(0.25), 1e-9);
+ assertEquals(0.75, interpolator.curve(0.75), 1e-9);
+ }
+
+ @Test
+ void curveExtrapolatesBeforeFirstAndAfterLast() {
+ var interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(Double.NaN, 0),
+ new Point2D(0.5, 0.25),
+ new Point2D(Double.NaN, 1)
+ });
+
+ // Extrapolate for t <= 0 with first segment
+ assertEquals(0, interpolator.curve(0), 1e-9);
+ assertEquals(-0.125, interpolator.curve(-0.25), 1e-9);
+
+ // Extrapolate for t >= 1 with last segment
+ assertEquals(1.0, interpolator.curve(1), 1e-9);
+ assertEquals(1.375, interpolator.curve(1.25), 1e-9);
+ }
+
+ @Test
+ void curveHandlesTBetweenDuplicateXsCorrectly() {
+ var interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(0.0, 0.0),
+ new Point2D(0.5, 0.0),
+ new Point2D(0.5, 1.0),
+ new Point2D(1.0, 1.0)
+ });
+
+ // For t between 0.0 and 0.5, we use segment (0,0)-(0.5,0)
+ assertEquals(0.0, interpolator.curve(0.25), 1e-9);
+
+ // For t between 0.5 and 1.0, we use segment (0.5,1)-(1,1)
+ assertEquals(1.0, interpolator.curve(0.75), 1e-9);
+ }
+
+ @Test
+ void curveHandlesNumericOverflowToInfinity() {
+ var interpolator = new LinearInterpolator(new Point2D[] {
+ new Point2D(0, 0.5),
+ new Point2D(9e-310, 1)
+ });
+
+ assertEquals(0.5, interpolator.curve(0));
+ assertEquals(Double.POSITIVE_INFINITY, interpolator.curve(0.5));
+ assertEquals(Double.NEGATIVE_INFINITY, interpolator.curve(-0.5));
+ }
+ }
+}
diff --git a/modules/javafx.graphics/src/test/java/test/javafx/css/CssParser_transition_Test.java b/modules/javafx.graphics/src/test/java/test/javafx/css/CssParser_transition_Test.java
index 26b48f1d180..6182ab897af 100644
--- a/modules/javafx.graphics/src/test/java/test/javafx/css/CssParser_transition_Test.java
+++ b/modules/javafx.graphics/src/test/java/test/javafx/css/CssParser_transition_Test.java
@@ -31,6 +31,7 @@
import javafx.css.Declaration;
import javafx.css.Rule;
import javafx.css.Stylesheet;
+import javafx.geometry.Point2D;
import javafx.util.Duration;
import com.sun.javafx.css.TransitionDefinition;
import org.junit.jupiter.api.Test;
@@ -168,10 +169,16 @@ public void testTransitionTimingFunction() {
steps(3, jump-none), steps(3, jump-both),
steps(3, start), steps(3, end); }
.rule4 { transition-timing-function: steps(3); }
+ .rule5 { transition-timing-function: linear(0, 0.25, 1),
+ linear(0, 0.25 75%, 1),
+ linear(0, 0.25 25% 75%, 1), /* equivalent to (0, 0.25 25%, 0.25 75%, 1) */
+ linear(0, 0.25 25%, 0.25 75%, 1),
+ linear(0, .1 25%, .75 50%, 1); }
.err1 { transition-timing-function: cubic-bezier(2, 0, 0, 0); }
.err2 { transition-timing-function: steps(2, 3); }
.err3 { transition-timing-function: steps(1, foo); }
.err4 { transition-timing-function: steps(foo, start); }
+ .err5 { transition-timing-function: linear(0, 0.25 0.5, 1); }
""");
Interpolator[] values = values("transition-timing-function", stylesheet.getRules().get(0));
@@ -198,10 +205,21 @@ public void testTransitionTimingFunction() {
values = values("transition-timing-function", stylesheet.getRules().get(3));
assertInterpolatorEquals(STEPS(3, StepPosition.END), values[0]);
+ values = values("transition-timing-function", stylesheet.getRules().get(4));
+ assertInterpolatorEquals(ofLinear(new Point2D(0, 0), new Point2D(0.5, 0.25), new Point2D(1, 1)), values[0]);
+ assertInterpolatorEquals(ofLinear(new Point2D(0, 0), new Point2D(0.75, 0.25), new Point2D(1, 1)), values[1]);
+ assertInterpolatorEquals(
+ ofLinear(new Point2D(0, 0), new Point2D(0.25, 0.25), new Point2D(0.75, 0.25), new Point2D(1, 1)), values[2]);
+ assertInterpolatorEquals(
+ ofLinear(new Point2D(0, 0), new Point2D(0.25, 0.25), new Point2D(0.75, 0.25), new Point2D(1, 1)), values[3]);
+ assertInterpolatorEquals(
+ ofLinear(new Point2D(0, 0), new Point2D(0.25, 0.1), new Point2D(0.5, 0.75), new Point2D(1, 1)), values[4]);
+
assertStartsWith("Expected ''", CssParser.errorsProperty().get(0).getMessage());
assertStartsWith("Expected ''", CssParser.errorsProperty().get(2).getMessage());
assertStartsWith("Expected ''", CssParser.errorsProperty().get(4).getMessage());
assertStartsWith("Expected ''", CssParser.errorsProperty().get(6).getMessage());
+ assertStartsWith("Expected ''", CssParser.errorsProperty().get(8).getMessage());
}
@Test
diff --git a/tests/manual/graphics/CssTransitionsTest.java b/tests/manual/graphics/CssTransitionsTest.java
index da68171b4b6..0180e58a3b1 100644
--- a/tests/manual/graphics/CssTransitionsTest.java
+++ b/tests/manual/graphics/CssTransitionsTest.java
@@ -107,18 +107,23 @@ private Region createTransitionTimingFunctionTab() {
}
#rect1 { transition-timing-function: linear; }
- #rect2 { transition-timing-function: ease; }
- #rect3 { transition-timing-function: ease-in; }
- #rect4 { transition-timing-function: ease-out; }
- #rect5 { transition-timing-function: ease-in-out; }
- #rect6 { transition-timing-function: cubic-bezier(0.34, 2.2, 0.64, 1); }
+ #rect2 { transition-timing-function: linear(0, 0.063, 0.25, 0.563, 1 36.4%,
+ 0.812, 0.75, 0.813, 1 72.7%,
+ 0.953, 0.938, 0.953, 1 90.9%,
+ 0.984, 1 100% 100%); }
+ #rect3 { transition-timing-function: ease; }
+ #rect4 { transition-timing-function: ease-in; }
+ #rect5 { transition-timing-function: ease-out; }
+ #rect6 { transition-timing-function: ease-in-out; }
+ #rect7 { transition-timing-function: cubic-bezier(0.34, 2.2, 0.64, 1); }
""",
new RectInfo("#rect1", "rect1", Color.WHITE),
new RectInfo("#rect2", "rect2", Color.WHITE),
new RectInfo("#rect3", "rect3", Color.WHITE),
new RectInfo("#rect4", "rect4", Color.WHITE),
new RectInfo("#rect5", "rect5", Color.WHITE),
- new RectInfo("#rect6", "rect6", Color.WHITE));
+ new RectInfo("#rect6", "rect6", Color.WHITE),
+ new RectInfo("#rect7", "rect7", Color.WHITE));
}
private Region createBackgroundTransitionsTab() {