/* * Copyright (c) 2018-2023, Andreas Kling * Copyright (c) 2021, the SerenityOS developers. * Copyright (c) 2021-2024, Sam Atkins * Copyright (c) 2024, Matthew Olsson * * SPDX-License-Identifier: BSD-2-Clause */ #include "Interpolation.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace Web::CSS { template static T interpolate_raw(T from, T to, float delta) { if constexpr (AK::Detail::IsSame) { return from + (to - from) * static_cast(delta); } else { return static_cast>(from + (to - from) * delta); } } static NonnullRefPtr with_keyword_values_resolved(DOM::Element& element, PropertyID property_id, CSSStyleValue const& value) { if (!value.is_keyword()) return value; switch (value.as_keyword().keyword()) { case CSS::Keyword::Initial: case CSS::Keyword::Unset: return property_initial_value(element.realm(), property_id); case CSS::Keyword::Inherit: return CSS::StyleComputer::get_inherit_value(element.realm(), property_id, &element); default: break; } return value; } ValueComparingRefPtr interpolate_property(DOM::Element& element, PropertyID property_id, CSSStyleValue const& a_from, CSSStyleValue const& a_to, float delta) { auto from = with_keyword_values_resolved(element, property_id, a_from); auto to = with_keyword_values_resolved(element, property_id, a_to); auto animation_type = animation_type_from_longhand_property(property_id); switch (animation_type) { case AnimationType::ByComputedValue: return interpolate_value(element, from, to, delta); case AnimationType::None: return to; case AnimationType::Custom: { if (property_id == PropertyID::Transform) { if (auto interpolated_transform = interpolate_transform(element, from, to, delta)) return *interpolated_transform; // https://drafts.csswg.org/css-transforms-1/#interpolation-of-transforms // In some cases, an animation might cause a transformation matrix to be singular or non-invertible. // For example, an animation in which scale moves from 1 to -1. At the time when the matrix is in // such a state, the transformed element is not rendered. return {}; } if (property_id == PropertyID::BoxShadow) return interpolate_box_shadow(element, from, to, delta); // FIXME: Handle all custom animatable properties [[fallthrough]]; } // FIXME: Handle repeatable-list animatable properties case AnimationType::RepeatableList: case AnimationType::Discrete: default: return delta >= 0.5f ? to : from; } } // https://drafts.csswg.org/css-transitions/#transitionable bool property_values_are_transitionable(PropertyID property_id, CSSStyleValue const& old_value, CSSStyleValue const& new_value) { // When comparing the before-change style and after-change style for a given property, // the property values are transitionable if they have an animation type that is neither not animatable nor discrete. auto animation_type = animation_type_from_longhand_property(property_id); if (animation_type == AnimationType::None || animation_type == AnimationType::Discrete) return false; // FIXME: Even when a property is transitionable, the two values may not be. The spec uses the example of inset/non-inset shadows. (void)old_value; (void)new_value; return true; } // A null return value means the interpolated matrix was not invertible or otherwise invalid RefPtr interpolate_transform(DOM::Element& element, CSSStyleValue const& from, CSSStyleValue 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) -> AK::Optional { AK::Vector values; for (auto const& value : transformation.values()) { switch (value->type()) { case CSSStyleValue::Type::Angle: values.append(AngleOrCalculated { value->as_angle().angle() }); break; case CSSStyleValue::Type::Math: values.append(LengthPercentage { value->as_math() }); break; case CSSStyleValue::Type::Length: values.append(LengthPercentage { value->as_length().length() }); break; case CSSStyleValue::Type::Percentage: values.append(LengthPercentage { value->as_percentage().percentage() }); break; case CSSStyleValue::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 { auto transformation = make_transformation(value); if (!transformation.has_value()) return {}; Optional paintable_box; if (auto layout_node = element.layout_node()) { if (auto paintable = layout_node->first_paintable(); paintable && is(paintable)) paintable_box = *static_cast(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, CSSStyleValue 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; FloatVector3 skew; FloatVector4 rotation; FloatVector4 perspective; }; // https://drafts.csswg.org/css-transforms-2/#decomposing-a-3d-matrix static constexpr auto decompose = [](FloatMatrix4x4 matrix) -> Optional { // https://drafts.csswg.org/css-transforms-1/#supporting-functions static constexpr auto combine = [](auto a, auto b, float ascl, float bscl) { return FloatVector3 { ascl * a[0] + bscl * b[0], ascl * a[1] + bscl * b[1], ascl * a[2] + bscl * b[2], }; }; // Normalize the matrix. if (matrix(3, 3) == 0.f) return {}; for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) matrix(i, j) /= matrix(3, 3); // perspectiveMatrix is used to solve for perspective, but it also provides // an easy way to test for singularity of the upper 3x3 component. auto perspective_matrix = matrix; for (int i = 0; i < 3; i++) perspective_matrix(3, i) = 0.f; perspective_matrix(3, 3) = 1.f; if (!perspective_matrix.is_invertible()) return {}; DecomposedValues values; // First, isolate perspective. if (matrix(3, 0) != 0.f || matrix(3, 1) != 0.f || matrix(3, 2) != 0.f) { // rightHandSide is the right hand side of the equation. // Note: It is the bottom side in a row-major matrix FloatVector4 bottom_side = { matrix(3, 0), matrix(3, 1), matrix(3, 2), matrix(3, 3), }; // Solve the equation by inverting perspectiveMatrix and multiplying // rightHandSide by the inverse. auto inverse_perspective_matrix = perspective_matrix.inverse(); auto transposed_inverse_perspective_matrix = inverse_perspective_matrix.transpose(); values.perspective = transposed_inverse_perspective_matrix * bottom_side; } else { // No perspective. values.perspective = { 0.0, 0.0, 0.0, 1.0 }; } // Next take care of translation for (int i = 0; i < 3; i++) values.translation[i] = matrix(i, 3); // Now get scale and shear. 'row' is a 3 element array of 3 component vectors FloatVector3 row[3]; for (int i = 0; i < 3; i++) row[i] = { matrix(0, i), matrix(1, i), matrix(2, i) }; // Compute X scale factor and normalize first row. values.scale[0] = row[0].length(); row[0].normalize(); // Compute XY shear factor and make 2nd row orthogonal to 1st. values.skew[0] = row[0].dot(row[1]); row[1] = combine(row[1], row[0], 1.f, -values.skew[0]); // Now, compute Y scale and normalize 2nd row. values.scale[1] = row[1].length(); row[1].normalize(); values.skew[0] /= values.scale[1]; // Compute XZ and YZ shears, orthogonalize 3rd row values.skew[1] = row[0].dot(row[2]); row[2] = combine(row[2], row[0], 1.f, -values.skew[1]); values.skew[2] = row[1].dot(row[2]); row[2] = combine(row[2], row[1], 1.f, -values.skew[2]); // Next, get Z scale and normalize 3rd row. values.scale[2] = row[2].length(); row[2].normalize(); values.skew[1] /= values.scale[2]; values.skew[2] /= values.scale[2]; // At this point, the matrix (in rows) is orthonormal. // Check for a coordinate system flip. If the determinant // is -1, then negate the matrix and the scaling factors. auto pdum3 = row[1].cross(row[2]); if (row[0].dot(pdum3) < 0.f) { for (int i = 0; i < 3; i++) { values.scale[i] *= -1.f; row[i][0] *= -1.f; row[i][1] *= -1.f; row[i][2] *= -1.f; } } // Now, get the rotations out values.rotation[0] = 0.5f * sqrt(max(1.f + row[0][0] - row[1][1] - row[2][2], 0.f)); values.rotation[1] = 0.5f * sqrt(max(1.f - row[0][0] + row[1][1] - row[2][2], 0.f)); values.rotation[2] = 0.5f * sqrt(max(1.f - row[0][0] - row[1][1] + row[2][2], 0.f)); values.rotation[3] = 0.5f * sqrt(max(1.f + row[0][0] + row[1][1] + row[2][2], 0.f)); if (row[2][1] > row[1][2]) values.rotation[0] = -values.rotation[0]; if (row[0][2] > row[2][0]) values.rotation[1] = -values.rotation[1]; if (row[1][0] > row[0][1]) values.rotation[2] = -values.rotation[2]; // FIXME: This accounts for the fact that the browser coordinate system is left-handed instead of right-handed. // The reason for this is that the positive Y-axis direction points down instead of up. To fix this, we // invert the Y axis. However, it feels like the spec pseudo-code above should have taken something like // this into account, so we're probably doing something else wrong. values.rotation[2] *= -1; return values; }; // https://drafts.csswg.org/css-transforms-2/#recomposing-to-a-3d-matrix static constexpr auto recompose = [](DecomposedValues const& values) -> FloatMatrix4x4 { auto matrix = FloatMatrix4x4::identity(); // apply perspective for (int i = 0; i < 4; i++) matrix(3, i) = values.perspective[i]; // apply translation for (int i = 0; i < 4; i++) { for (int j = 0; j < 3; j++) matrix(i, 3) += values.translation[j] * matrix(i, j); } // apply rotation auto x = values.rotation[0]; auto y = values.rotation[1]; auto z = values.rotation[2]; auto w = values.rotation[3]; // Construct a composite rotation matrix from the quaternion values // rotationMatrix is a identity 4x4 matrix initially auto rotation_matrix = FloatMatrix4x4::identity(); rotation_matrix(0, 0) = 1.f - 2.f * (y * y + z * z); rotation_matrix(1, 0) = 2.f * (x * y - z * w); rotation_matrix(2, 0) = 2.f * (x * z + y * w); rotation_matrix(0, 1) = 2.f * (x * y + z * w); rotation_matrix(1, 1) = 1.f - 2.f * (x * x + z * z); rotation_matrix(2, 1) = 2.f * (y * z - x * w); rotation_matrix(0, 2) = 2.f * (x * z - y * w); rotation_matrix(1, 2) = 2.f * (y * z + x * w); rotation_matrix(2, 2) = 1.f - 2.f * (x * x + y * y); matrix = matrix * rotation_matrix; // apply skew // temp is a identity 4x4 matrix initially auto temp = FloatMatrix4x4::identity(); if (values.skew[2] != 0.f) { temp(1, 2) = values.skew[2]; matrix = matrix * temp; } if (values.skew[1] != 0.f) { temp(1, 2) = 0.f; temp(0, 2) = values.skew[1]; matrix = matrix * temp; } if (values.skew[0] != 0.f) { temp(0, 2) = 0.f; temp(0, 1) = values.skew[0]; matrix = matrix * temp; } // apply scale for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) matrix(j, i) *= values.scale[i]; } return matrix; }; // https://drafts.csswg.org/css-transforms-2/#interpolation-of-decomposed-3d-matrix-values static constexpr auto interpolate = [](DecomposedValues& from, DecomposedValues& to, float delta) -> DecomposedValues { auto product = clamp(from.rotation.dot(to.rotation), -1.0f, 1.0f); FloatVector4 interpolated_rotation; if (fabsf(product) == 1.0f) { interpolated_rotation = from.rotation; } else { auto theta = acos(product); auto w = sin(delta * theta) / sqrtf(1.0f - product * product); for (int i = 0; i < 4; i++) { from.rotation[i] *= cos(delta * theta) - product * w; to.rotation[i] *= w; interpolated_rotation[i] = from.rotation[i] + to.rotation[i]; } } return { interpolate_raw(from.translation, to.translation, delta), interpolate_raw(from.scale, to.scale, delta), interpolate_raw(from.skew, to.skew, delta), interpolated_rotation, interpolate_raw(from.perspective, to.perspective, delta), }; }; 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); 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); StyleValueVector values; values.ensure_capacity(16); for (int i = 0; i < 16; i++) values.append(NumberStyleValue::create(static_cast(interpolated(i % 4, i / 4)))); return StyleValueList::create({ TransformationStyleValue::create(TransformFunction::Matrix3d, move(values)) }, StyleValueList::Separator::Comma); } Color interpolate_color(Color from, Color to, float delta) { // https://drafts.csswg.org/css-color/#interpolation-space // If the host syntax does not define what color space interpolation should take place in, it defaults to Oklab. auto from_oklab = from.to_oklab(); auto to_oklab = to.to_oklab(); auto color = Color::from_oklab( interpolate_raw(from_oklab.L, to_oklab.L, delta), interpolate_raw(from_oklab.a, to_oklab.a, delta), interpolate_raw(from_oklab.b, to_oklab.b, delta)); color.set_alpha(interpolate_raw(from.alpha(), to.alpha(), delta)); return color; } NonnullRefPtr interpolate_box_shadow(DOM::Element& element, CSSStyleValue const& from, CSSStyleValue const& to, float delta) { // https://drafts.csswg.org/css-backgrounds/#box-shadow // Animation type: by computed value, treating none as a zero-item list and appending blank shadows // (transparent 0 0 0 0) with a corresponding inset keyword as needed to match the longer list if // the shorter list is otherwise compatible with the longer one static constexpr auto process_list = [](CSSStyleValue const& value) { StyleValueVector shadows; if (value.is_value_list()) { for (auto const& element : value.as_value_list().values()) { if (element->is_shadow()) shadows.append(element); } } else if (value.is_shadow()) { shadows.append(value); } else if (!value.is_keyword() || value.as_keyword().keyword() != Keyword::None) { VERIFY_NOT_REACHED(); } return shadows; }; static constexpr auto extend_list_if_necessary = [](StyleValueVector& values, StyleValueVector const& other) { values.ensure_capacity(other.size()); for (size_t i = values.size(); i < other.size(); i++) { values.unchecked_append(ShadowStyleValue::create( CSSColorValue::create_from_color(Color::Transparent), LengthStyleValue::create(Length::make_px(0)), LengthStyleValue::create(Length::make_px(0)), LengthStyleValue::create(Length::make_px(0)), LengthStyleValue::create(Length::make_px(0)), other[i]->as_shadow().placement())); } }; StyleValueVector from_shadows = process_list(from); StyleValueVector to_shadows = process_list(to); extend_list_if_necessary(from_shadows, to_shadows); extend_list_if_necessary(to_shadows, from_shadows); VERIFY(from_shadows.size() == to_shadows.size()); StyleValueVector result_shadows; result_shadows.ensure_capacity(from_shadows.size()); for (size_t i = 0; i < from_shadows.size(); i++) { auto const& from_shadow = from_shadows[i]->as_shadow(); auto const& to_shadow = to_shadows[i]->as_shadow(); auto result_shadow = ShadowStyleValue::create( CSSColorValue::create_from_color(interpolate_color(from_shadow.color()->to_color({}), to_shadow.color()->to_color({}), delta)), interpolate_value(element, from_shadow.offset_x(), to_shadow.offset_x(), delta), interpolate_value(element, from_shadow.offset_y(), to_shadow.offset_y(), delta), interpolate_value(element, from_shadow.blur_radius(), to_shadow.blur_radius(), delta), interpolate_value(element, from_shadow.spread_distance(), to_shadow.spread_distance(), delta), delta >= 0.5f ? to_shadow.placement() : from_shadow.placement()); result_shadows.unchecked_append(result_shadow); } return StyleValueList::create(move(result_shadows), StyleValueList::Separator::Comma); } NonnullRefPtr interpolate_value(DOM::Element& element, CSSStyleValue const& from, CSSStyleValue const& to, float delta) { if (from.type() != to.type()) { // Handle mixed percentage and dimension types // https://www.w3.org/TR/css-values-4/#mixed-percentages struct NumericBaseTypeAndDefault { CSSNumericType::BaseType base_type; ValueComparingNonnullRefPtr default_value; }; static constexpr auto numeric_base_type_and_default = [](CSSStyleValue const& value) -> Optional { switch (value.type()) { case CSSStyleValue::Type::Angle: { static auto default_angle_value = AngleStyleValue::create(Angle::make_degrees(0)); return NumericBaseTypeAndDefault { CSSNumericType::BaseType::Angle, default_angle_value }; } case CSSStyleValue::Type::Frequency: { static auto default_frequency_value = FrequencyStyleValue::create(Frequency::make_hertz(0)); return NumericBaseTypeAndDefault { CSSNumericType::BaseType::Frequency, default_frequency_value }; } case CSSStyleValue::Type::Length: { static auto default_length_value = LengthStyleValue::create(Length::make_px(0)); return NumericBaseTypeAndDefault { CSSNumericType::BaseType::Length, default_length_value }; } case CSSStyleValue::Type::Percentage: { static auto default_percentage_value = PercentageStyleValue::create(Percentage { 0.0 }); return NumericBaseTypeAndDefault { CSSNumericType::BaseType::Percent, default_percentage_value }; } case CSSStyleValue::Type::Time: { static auto default_time_value = TimeStyleValue::create(Time::make_seconds(0)); return NumericBaseTypeAndDefault { CSSNumericType::BaseType::Time, default_time_value }; } default: return {}; } }; static constexpr auto to_calculation_node = [](CSSStyleValue const& value) -> NonnullOwnPtr { switch (value.type()) { case CSSStyleValue::Type::Angle: return NumericCalculationNode::create(value.as_angle().angle()); case CSSStyleValue::Type::Frequency: return NumericCalculationNode::create(value.as_frequency().frequency()); case CSSStyleValue::Type::Length: return NumericCalculationNode::create(value.as_length().length()); case CSSStyleValue::Type::Percentage: return NumericCalculationNode::create(value.as_percentage().percentage()); case CSSStyleValue::Type::Time: return NumericCalculationNode::create(value.as_time().time()); default: VERIFY_NOT_REACHED(); } }; auto from_base_type_and_default = numeric_base_type_and_default(from); auto to_base_type_and_default = numeric_base_type_and_default(to); if (from_base_type_and_default.has_value() && to_base_type_and_default.has_value() && (from_base_type_and_default->base_type == CSSNumericType::BaseType::Percent || to_base_type_and_default->base_type == CSSNumericType::BaseType::Percent)) { // This is an interpolation from a numeric unit to a percentage, or vice versa. The trick here is to // interpolate two separate values. For example, consider an interpolation from 30px to 80%. It's quite // hard to understand how this interpolation works, but if instead we rewrite the values as "30px + 0%" and // "0px + 80%", then it is very simple to understand; we just interpolate each component separately. auto interpolated_from = interpolate_value(element, from, from_base_type_and_default->default_value, delta); auto interpolated_to = interpolate_value(element, to_base_type_and_default->default_value, to, delta); Vector> values; values.ensure_capacity(2); values.unchecked_append(to_calculation_node(interpolated_from)); values.unchecked_append(to_calculation_node(interpolated_to)); auto calc_node = SumCalculationNode::create(move(values)); return CSSMathValue::create(move(calc_node), CSSNumericType { to_base_type_and_default->base_type, 1 }); } return delta >= 0.5f ? to : from; } switch (from.type()) { case CSSStyleValue::Type::Angle: return AngleStyleValue::create(Angle::make_degrees(interpolate_raw(from.as_angle().angle().to_degrees(), to.as_angle().angle().to_degrees(), delta))); case CSSStyleValue::Type::Color: { Optional layout_node; if (auto node = element.layout_node()) layout_node = *node; return CSSColorValue::create_from_color(interpolate_color(from.to_color(layout_node), to.to_color(layout_node), delta)); } case CSSStyleValue::Type::Integer: return IntegerStyleValue::create(interpolate_raw(from.as_integer().integer(), to.as_integer().integer(), delta)); case CSSStyleValue::Type::Length: { auto& from_length = from.as_length().length(); auto& to_length = to.as_length().length(); return LengthStyleValue::create(Length(interpolate_raw(from_length.raw_value(), to_length.raw_value(), delta), from_length.type())); } case CSSStyleValue::Type::Number: return NumberStyleValue::create(interpolate_raw(from.as_number().number(), to.as_number().number(), delta)); case CSSStyleValue::Type::Percentage: return PercentageStyleValue::create(Percentage(interpolate_raw(from.as_percentage().percentage().value(), to.as_percentage().percentage().value(), delta))); case CSSStyleValue::Type::Position: { // https://www.w3.org/TR/css-values-4/#combine-positions // FIXME: Interpolation of is defined as the independent interpolation of each component (x, y) normalized as an offset from the top left corner as a . auto& from_position = from.as_position(); auto& to_position = to.as_position(); return PositionStyleValue::create( interpolate_value(element, from_position.edge_x(), to_position.edge_x(), delta)->as_edge(), interpolate_value(element, from_position.edge_y(), to_position.edge_y(), delta)->as_edge()); } case CSSStyleValue::Type::Ratio: { auto from_ratio = from.as_ratio().ratio(); auto to_ratio = to.as_ratio().ratio(); // The interpolation of a is defined by converting each to a number by dividing the first value // by the second (so a ratio of 3 / 2 would become 1.5), taking the logarithm of that result (so the 1.5 would // become approximately 0.176), then interpolating those values. The result during the interpolation is // converted back to a by inverting the logarithm, then interpreting the result as a with the // result as the first value and 1 as the second value. auto from_number = log(from_ratio.value()); auto to_number = log(to_ratio.value()); auto interp_number = interpolate_raw(from_number, to_number, delta); return RatioStyleValue::create(Ratio(pow(M_E, interp_number))); } case CSSStyleValue::Type::Rect: { auto from_rect = from.as_rect().rect(); auto to_rect = to.as_rect().rect(); return RectStyleValue::create({ Length(interpolate_raw(from_rect.top_edge.raw_value(), to_rect.top_edge.raw_value(), delta), from_rect.top_edge.type()), Length(interpolate_raw(from_rect.right_edge.raw_value(), to_rect.right_edge.raw_value(), delta), from_rect.right_edge.type()), Length(interpolate_raw(from_rect.bottom_edge.raw_value(), to_rect.bottom_edge.raw_value(), delta), from_rect.bottom_edge.type()), Length(interpolate_raw(from_rect.left_edge.raw_value(), to_rect.left_edge.raw_value(), delta), from_rect.left_edge.type()), }); } case CSSStyleValue::Type::Transformation: VERIFY_NOT_REACHED(); case CSSStyleValue::Type::ValueList: { auto& from_list = from.as_value_list(); auto& to_list = to.as_value_list(); if (from_list.size() != to_list.size()) return from; StyleValueVector interpolated_values; interpolated_values.ensure_capacity(from_list.size()); for (size_t i = 0; i < from_list.size(); ++i) interpolated_values.append(interpolate_value(element, from_list.values()[i], to_list.values()[i], delta)); return StyleValueList::create(move(interpolated_values), from_list.separator()); } default: return from; } } }