LibWeb: Reimplement transform interpolation according to spec

We had a partial implementation of transformation function interpolation
that did not support numerical interpolation of simple functions (e.g.
`scale(0)` -> `scale(1)`). This refactors the interpolation to follow
the spec more closely.

Gains us 267 WPT subtest passes in `css/css-transforms`.

Fixes #6774.
This commit is contained in:
Jelle Raaijmakers 2025-11-14 13:41:32 +01:00 committed by Jelle Raaijmakers
parent 911ecf1450
commit e4dc2663ba
Notes: github-actions[bot] 2025-11-18 13:37:43 +00:00
6 changed files with 610 additions and 89 deletions

View file

@ -4,6 +4,7 @@
* Copyright (c) 2021-2025, Sam Atkins <sam@ladybird.org>
* Copyright (c) 2024, Matthew Olsson <mattco@serenityos.org>
* Copyright (c) 2025, Tim Ledbetter <tim.ledbetter@ladybird.org>
* Copyright (c) 2025, Jelle Raaijmakers <jelle@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -613,7 +614,7 @@ ValueComparingRefPtr<StyleValue const> interpolate_property(DOM::Element& elemen
return interpolate_repeatable_list(element, calculation_context, from, to, delta, allow_discrete);
case AnimationType::Custom: {
if (property_id == PropertyID::Transform) {
if (auto interpolated_transform = interpolate_transform(element, from, to, delta, allow_discrete))
if (auto interpolated_transform = interpolate_transform(element, calculation_context, from, to, delta, allow_discrete))
return *interpolated_transform;
// https://drafts.csswg.org/css-transforms-1/#interpolation-of-transforms
@ -756,83 +757,8 @@ bool property_values_are_transitionable(PropertyID property_id, StyleValue const
return true;
}
// A null return value means the interpolated matrix was not invertible or otherwise invalid
RefPtr<StyleValue const> interpolate_transform(DOM::Element& element, StyleValue const& from, StyleValue const& to, float delta, AllowDiscrete)
static Optional<FloatMatrix4x4> interpolate_matrices(FloatMatrix4x4 const& from, FloatMatrix4x4 const& to, float delta)
{
// Note that the spec uses column-major notation, so all the matrix indexing is reversed.
static constexpr auto make_transformation = [](TransformationStyleValue const& transformation) -> Optional<Transformation> {
Vector<TransformValue> values;
for (auto const& value : transformation.values()) {
switch (value->type()) {
case StyleValue::Type::Angle:
values.append(AngleOrCalculated { value->as_angle().angle() });
break;
case StyleValue::Type::Calculated: {
auto& calculated = value->as_calculated();
if (calculated.resolves_to_angle()) {
values.append(AngleOrCalculated { calculated });
} else if (calculated.resolves_to_length()) {
values.append(LengthPercentage { calculated });
} else if (calculated.resolves_to_number() || calculated.resolves_to_percentage()) {
values.append(NumberPercentage { calculated });
} else {
dbgln("Calculation `{}` inside {} transform-function is not a recognized type", calculated.to_string(SerializationMode::Normal), to_string(transformation.transform_function()));
return {};
}
break;
}
case StyleValue::Type::Length:
values.append(LengthPercentage { value->as_length().length() });
break;
case StyleValue::Type::Percentage:
values.append(LengthPercentage { value->as_percentage().percentage() });
break;
case StyleValue::Type::Number:
values.append(NumberPercentage { Number(Number::Type::Number, value->as_number().number()) });
break;
default:
return {};
}
}
return Transformation { transformation.transform_function(), move(values) };
};
static constexpr auto transformation_style_value_to_matrix = [](DOM::Element& element, TransformationStyleValue const& value) -> Optional<FloatMatrix4x4> {
auto transformation = make_transformation(value);
if (!transformation.has_value())
return {};
Optional<Painting::PaintableBox const&> paintable_box;
if (auto layout_node = element.layout_node()) {
if (auto* paintable = as_if<Painting::PaintableBox>(layout_node->first_paintable()))
paintable_box = *paintable;
}
if (auto matrix = transformation->to_matrix(paintable_box); !matrix.is_error())
return matrix.value();
return {};
};
static constexpr auto style_value_to_matrix = [](DOM::Element& element, StyleValue const& value) -> FloatMatrix4x4 {
if (value.is_transformation())
return transformation_style_value_to_matrix(element, value.as_transformation()).value_or(FloatMatrix4x4::identity());
// This encompasses both the allowed value "none" and any invalid values
if (!value.is_value_list())
return FloatMatrix4x4::identity();
auto matrix = FloatMatrix4x4::identity();
for (auto const& value_element : value.as_value_list().values()) {
if (value_element->is_transformation()) {
if (auto value_matrix = transformation_style_value_to_matrix(element, value_element->as_transformation()); value_matrix.has_value())
matrix = matrix * value_matrix.value();
}
}
return matrix;
};
struct DecomposedValues {
FloatVector3 translation;
FloatVector3 scale;
@ -1037,20 +963,370 @@ RefPtr<StyleValue const> interpolate_transform(DOM::Element& element, StyleValue
};
};
auto from_matrix = style_value_to_matrix(element, from);
auto to_matrix = style_value_to_matrix(element, to);
auto from_decomposed = decompose(from_matrix);
auto to_decomposed = decompose(to_matrix);
auto from_decomposed = decompose(from);
auto to_decomposed = decompose(to);
if (!from_decomposed.has_value() || !to_decomposed.has_value())
return {};
auto interpolated_decomposed = interpolate(from_decomposed.value(), to_decomposed.value(), delta);
auto interpolated = recompose(interpolated_decomposed);
return recompose(interpolated_decomposed);
}
StyleValueVector values;
values.ensure_capacity(16);
for (int i = 0; i < 16; i++)
values.append(NumberStyleValue::create(static_cast<double>(interpolated[i % 4, i / 4])));
return StyleValueList::create({ TransformationStyleValue::create(PropertyID::Transform, TransformFunction::Matrix3d, move(values)) }, StyleValueList::Separator::Comma);
// https://drafts.csswg.org/css-transforms-1/#interpolation-of-transforms
RefPtr<StyleValue const> interpolate_transform(DOM::Element& element, CalculationContext const& calculation_context,
StyleValue const& from, StyleValue const& to, float delta, AllowDiscrete)
{
// * If both Va and Vb are none:
// * Vresult is none.
if (from.is_keyword() && from.as_keyword().keyword() == Keyword::None
&& to.is_keyword() && to.as_keyword().keyword() == Keyword::None) {
return KeywordStyleValue::create(Keyword::None);
}
// * Treating none as a list of zero length, if Va or Vb differ in length:
auto style_value_to_transformations = [](StyleValue const& style_value)
-> Vector<NonnullRefPtr<TransformationStyleValue const>> {
if (style_value.is_transformation())
return { style_value.as_transformation() };
// NB: This encompasses both the allowed value "none" and any invalid values.
if (!style_value.is_value_list())
return {};
Vector<NonnullRefPtr<TransformationStyleValue const>> result;
result.ensure_capacity(style_value.as_value_list().size());
for (auto const& value : style_value.as_value_list().values()) {
VERIFY(value->is_transformation());
result.unchecked_append(value->as_transformation());
}
return result;
};
auto from_transformations = style_value_to_transformations(from);
auto to_transformations = style_value_to_transformations(to);
if (from_transformations.size() != to_transformations.size()) {
// * extend the shorter list to the length of the longer list, setting the function at each additional
// position to the identity transform function matching the function at the corresponding position in the
// longer list. Both transform function lists are then interpolated following the next rule.
auto& shorter_list = from_transformations.size() < to_transformations.size() ? from_transformations : to_transformations;
auto const& longer_list = from_transformations.size() < to_transformations.size() ? to_transformations : from_transformations;
for (size_t i = shorter_list.size(); i < longer_list.size(); ++i) {
auto const& transformation = longer_list[i];
shorter_list.append(TransformationStyleValue::identity_transformation(transformation->transform_function()));
}
}
// https://drafts.csswg.org/css-transforms-1/#transform-primitives
auto is_2d_primitive = [](TransformFunction function) {
return first_is_one_of(function,
TransformFunction::Rotate,
TransformFunction::Scale,
TransformFunction::Translate);
};
auto is_2d_transform = [&is_2d_primitive](TransformFunction function) {
return is_2d_primitive(function)
|| first_is_one_of(function,
TransformFunction::ScaleX,
TransformFunction::ScaleY,
TransformFunction::TranslateX,
TransformFunction::TranslateY);
};
// https://drafts.csswg.org/css-transforms-2/#transform-primitives
auto is_3d_primitive = [](TransformFunction function) {
return first_is_one_of(function,
TransformFunction::Rotate3d,
TransformFunction::Scale3d,
TransformFunction::Translate3d);
};
auto is_3d_transform = [&is_2d_transform, &is_3d_primitive](TransformFunction function) {
return is_2d_transform(function)
|| is_3d_primitive(function)
|| first_is_one_of(function,
TransformFunction::RotateX,
TransformFunction::RotateY,
TransformFunction::RotateZ,
TransformFunction::ScaleZ,
TransformFunction::TranslateZ);
};
auto convert_2d_transform_to_primitive = [](NonnullRefPtr<TransformationStyleValue const> transform)
-> NonnullRefPtr<TransformationStyleValue const> {
TransformFunction generic_function;
StyleValueVector parameters;
switch (transform->transform_function()) {
case TransformFunction::Scale:
generic_function = TransformFunction::Scale;
parameters.append(transform->values()[0]);
parameters.append(transform->values().size() > 1 ? transform->values()[1] : transform->values()[0]);
break;
case TransformFunction::ScaleX:
generic_function = TransformFunction::Scale;
parameters.append(transform->values()[0]);
parameters.append(NumberStyleValue::create(1.));
break;
case TransformFunction::ScaleY:
generic_function = TransformFunction::Scale;
parameters.append(NumberStyleValue::create(1.));
parameters.append(transform->values()[0]);
break;
case TransformFunction::Rotate:
generic_function = TransformFunction::Rotate;
parameters.append(transform->values()[0]);
break;
case TransformFunction::Translate:
generic_function = TransformFunction::Translate;
parameters.append(transform->values()[0]);
parameters.append(transform->values().size() > 1
? transform->values()[1]
: LengthStyleValue::create(Length::make_px(0.)));
break;
case TransformFunction::TranslateX:
generic_function = TransformFunction::Translate;
parameters.append(transform->values()[0]);
parameters.append(LengthStyleValue::create(Length::make_px(0.)));
break;
case TransformFunction::TranslateY:
generic_function = TransformFunction::Translate;
parameters.append(LengthStyleValue::create(Length::make_px(0.)));
parameters.append(transform->values()[0]);
break;
default:
VERIFY_NOT_REACHED();
}
return TransformationStyleValue::create(PropertyID::Transform, generic_function, move(parameters));
};
auto convert_3d_transform_to_primitive = [&](NonnullRefPtr<TransformationStyleValue const> transform)
-> NonnullRefPtr<TransformationStyleValue const> {
// NB: Convert to 2D primitive if possible so we don't have to deal with scale/translate X/Y separately.
if (is_2d_transform(transform->transform_function()))
transform = convert_2d_transform_to_primitive(transform);
TransformFunction generic_function;
StyleValueVector parameters;
switch (transform->transform_function()) {
case TransformFunction::Rotate:
case TransformFunction::RotateZ:
generic_function = TransformFunction::Rotate3d;
parameters.append(NumberStyleValue::create(0.));
parameters.append(NumberStyleValue::create(0.));
parameters.append(NumberStyleValue::create(1.));
parameters.append(transform->values()[0]);
break;
case TransformFunction::RotateX:
generic_function = TransformFunction::Rotate3d;
parameters.append(NumberStyleValue::create(1.));
parameters.append(NumberStyleValue::create(0.));
parameters.append(NumberStyleValue::create(0.));
parameters.append(transform->values()[0]);
break;
case TransformFunction::RotateY:
generic_function = TransformFunction::Rotate3d;
parameters.append(NumberStyleValue::create(0.));
parameters.append(NumberStyleValue::create(1.));
parameters.append(NumberStyleValue::create(0.));
parameters.append(transform->values()[0]);
break;
case TransformFunction::Scale:
generic_function = TransformFunction::Scale3d;
parameters.append(transform->values()[0]);
parameters.append(transform->values().size() > 1 ? transform->values()[1] : transform->values()[0]);
parameters.append(NumberStyleValue::create(1.));
break;
case TransformFunction::ScaleZ:
generic_function = TransformFunction::Scale3d;
parameters.append(NumberStyleValue::create(1.));
parameters.append(NumberStyleValue::create(1.));
parameters.append(transform->values()[0]);
break;
case TransformFunction::Translate:
generic_function = TransformFunction::Translate3d;
parameters.append(transform->values()[0]);
parameters.append(transform->values().size() > 1
? transform->values()[1]
: LengthStyleValue::create(Length::make_px(0.)));
parameters.append(LengthStyleValue::create(Length::make_px(0.)));
break;
case TransformFunction::TranslateZ:
generic_function = TransformFunction::Translate3d;
parameters.append(LengthStyleValue::create(Length::make_px(0.)));
parameters.append(LengthStyleValue::create(Length::make_px(0.)));
parameters.append(transform->values()[0]);
break;
default:
VERIFY_NOT_REACHED();
}
return TransformationStyleValue::create(PropertyID::Transform, generic_function, move(parameters));
};
// * Let Vresult be an empty list. Beginning at the start of Va and Vb, compare the corresponding functions at each
// position:
StyleValueVector result;
result.ensure_capacity(from_transformations.size());
size_t index = 0;
for (; index < from_transformations.size(); ++index) {
auto from_transformation = from_transformations[index];
auto to_transformation = to_transformations[index];
auto from_function = from_transformation->transform_function();
auto to_function = to_transformation->transform_function();
// * While the functions have either the same name, or are derivatives of the same primitive transform
// function, interpolate the corresponding pair of functions as described in § 10 Interpolation of
// primitives and derived transform functions and append the result to Vresult.
// https://drafts.csswg.org/css-transforms-2/#interpolation-of-transform-functions
// Two different types of transform functions that share the same primitive, or transform functions of the same
// type with different number of arguments can be interpolated. Both transform functions need a former
// conversion to the common primitive first and get interpolated numerically afterwards. The computed value will
// be the primitive with the resulting interpolated arguments.
// The transform functions <matrix()>, matrix3d() and perspective() get converted into 4x4 matrices first and
// interpolated as defined in section Interpolation of Matrices afterwards.
if (first_is_one_of(TransformFunction::Matrix, from_function, to_function)
|| first_is_one_of(TransformFunction::Matrix3d, from_function, to_function)
|| first_is_one_of(TransformFunction::Perspective, from_function, to_function)) {
break;
}
// If both transform functions share a primitive in the two-dimensional space, both transform functions get
// converted to the two-dimensional primitive. If one or both transform functions are three-dimensional
// transform functions, the common three-dimensional primitive is used.
if (is_2d_transform(from_function) && is_2d_transform(to_function)) {
from_transformation = convert_2d_transform_to_primitive(from_transformation);
to_transformation = convert_2d_transform_to_primitive(to_transformation);
} else if (is_3d_transform(from_function) || is_3d_transform(to_function)) {
// NB: 3D primitives do not support value expansion like their 2D counterparts do (e.g. scale(1.5) ->
// scale(1.5, 1.5), so we check if they are already a primitive first.
if (!is_3d_primitive(from_function))
from_transformation = convert_3d_transform_to_primitive(from_transformation);
if (!is_3d_primitive(to_function))
to_transformation = convert_3d_transform_to_primitive(to_transformation);
}
from_function = from_transformation->transform_function();
to_function = to_transformation->transform_function();
// NB: We converted both functions to their primitives. But if they're different primitives or if they have a
// different number of values, we can't interpolate numerically between them. Break here so the next loop
// can take care of the remaining functions.
auto const& from_values = from_transformation->values();
auto const& to_values = to_transformation->values();
if (from_function != to_function || from_values.size() != to_values.size())
break;
// https://drafts.csswg.org/css-transforms-2/#interpolation-of-transform-functions
if (from_function == TransformFunction::Rotate3d) {
// FIXME: For interpolations with the primitive rotate3d(), the direction vectors of the transform functions get
// normalized first. If the normalized vectors are not equal and both rotation angles are non-zero the
// transform functions get converted into 4x4 matrices first and interpolated as defined in section
// Interpolation of Matrices afterwards. Otherwise the rotation angle gets interpolated numerically and the
// rotation vector of the non-zero angle is used or (0, 0, 1) if both angles are zero.
auto interpolated_rotation = interpolate_rotate(element, calculation_context, from_transformation,
to_transformation, delta, AllowDiscrete::No);
if (!interpolated_rotation)
break;
result.unchecked_append(*interpolated_rotation);
} else {
StyleValueVector interpolated;
interpolated.ensure_capacity(from_values.size());
for (size_t i = 0; i < from_values.size(); ++i) {
auto interpolated_value = interpolate_value(element, calculation_context, from_values[i], to_values[i],
delta, AllowDiscrete::No);
if (!interpolated_value)
break;
interpolated.unchecked_append(*interpolated_value);
}
if (interpolated.size() != from_values.size())
break;
result.unchecked_append(TransformationStyleValue::create(PropertyID::Transform, from_function, move(interpolated)));
}
}
// NB: Return if we're done.
if (index == from_transformations.size())
return StyleValueList::create(move(result), StyleValueList::Separator::Space);
static constexpr auto make_transformation = [](TransformationStyleValue const& transformation) -> Optional<Transformation> {
Vector<TransformValue> values;
values.ensure_capacity(transformation.values().size());
for (auto const& value : transformation.values()) {
switch (value->type()) {
case StyleValue::Type::Angle:
values.unchecked_append(AngleOrCalculated { value->as_angle().angle() });
break;
case StyleValue::Type::Calculated: {
auto& calculated = value->as_calculated();
if (calculated.resolves_to_angle()) {
values.unchecked_append(AngleOrCalculated { calculated });
} else if (calculated.resolves_to_length()) {
values.unchecked_append(LengthPercentage { calculated });
} else if (calculated.resolves_to_number() || calculated.resolves_to_percentage()) {
values.unchecked_append(NumberPercentage { calculated });
} else {
dbgln("Calculation `{}` inside {} transform-function is not a recognized type", calculated.to_string(SerializationMode::Normal), to_string(transformation.transform_function()));
return {};
}
break;
}
case StyleValue::Type::Length:
values.unchecked_append(LengthPercentage { value->as_length().length() });
break;
case StyleValue::Type::Percentage:
values.unchecked_append(LengthPercentage { value->as_percentage().percentage() });
break;
case StyleValue::Type::Number:
values.unchecked_append(NumberPercentage { Number(Number::Type::Number, value->as_number().number()) });
break;
default:
return {};
}
}
return Transformation { transformation.transform_function(), move(values) };
};
// * If the pair do not have a common name or primitive transform function, post-multiply the remaining
// transform functions in each of Va and Vb respectively to produce two 4x4 matrices. Interpolate these two
// matrices as described in § 11 Interpolation of Matrices, append the result to Vresult, and cease
// iterating over Va and Vb.
Optional<Painting::PaintableBox const&> paintable_box;
if (auto* paintable = as_if<Painting::PaintableBox>(element.paintable()))
paintable_box = *paintable;
auto post_multiply_remaining_transformations = [&paintable_box](size_t start_index, Vector<NonnullRefPtr<TransformationStyleValue const>> const& transformations) {
FloatMatrix4x4 result = FloatMatrix4x4::identity();
for (auto index = start_index; index < transformations.size(); ++index) {
auto transformation = make_transformation(transformations[index]);
if (!transformation.has_value()) {
dbgln("Unable to interpret a transformation; bailing out of interpolation.");
break;
}
auto transformation_matrix = transformation->to_matrix(paintable_box);
if (transformation_matrix.is_error()) {
dbgln("Unable to interpret a transformation's matrix; bailing out of interpolation.");
break;
}
result = result * transformation_matrix.value();
}
return result;
};
auto from_matrix = post_multiply_remaining_transformations(index, from_transformations);
auto to_matrix = post_multiply_remaining_transformations(index, to_transformations);
auto maybe_interpolated_matrix = interpolate_matrices(from_matrix, to_matrix, delta);
if (maybe_interpolated_matrix.has_value()) {
auto interpolated_matrix = maybe_interpolated_matrix.release_value();
StyleValueVector values;
values.ensure_capacity(16);
for (int i = 0; i < 16; i++)
values.unchecked_append(NumberStyleValue::create(interpolated_matrix[i % 4, i / 4]));
result.append(TransformationStyleValue::create(PropertyID::Transform, TransformFunction::Matrix3d, move(values)));
} else {
dbgln("Unable to interpolate matrices.");
}
return StyleValueList::create(move(result), StyleValueList::Separator::Space);
}
Color interpolate_color(Color from, Color to, float delta, ColorSyntax syntax)