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 @@

Color Functions <color- ) ]+

<easing-function>

-

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-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 easing function +
linear(0, 0.25, 1)
+
+
+ linear easing function +
linear(0, 0.25 75%, 1)
+
+
+ linear easing function +
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 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 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() {