From 8d6e56ef3aa6c11e38a5dd16996dbe3883b38af5 Mon Sep 17 00:00:00 2001 From: Simeon Date: Thu, 6 Aug 2020 15:50:09 -0700 Subject: [PATCH] Fix translation of rectangles with RoundCorners. (#326) We were not handling the distinction between RoundCorners.Radius and Rectangle.Roundness. This change does a much better job. Note that it gets the Trimming wrong for RoundCorners - that will come in a later fix. I'm getting this in now because it has so much refactoring that it will be hard to focus on the trimming algorithm alongside this. These are the After Effects rules for how RoundCorners.Radius and Rectangle.Roundness interact: RoundCorners.Radius is ignored if Rectangle.Roundness != 0. RoundCorners.Radius is able to produce an ellipse if the radius is large enough. Rectangle.Roundness creates round corners with equal X and Y radii, so it can never create an ellipse. --- source/Issues/LT0011.md | 14 +- source/Issues/LT0016.md | 6 +- source/Issues/LT0037.md | 17 + source/LottieData/Animatable.cs | 14 +- source/LottieData/LottieData.projitems | 2 +- source/LottieData/LottieObjectType.cs | 2 +- .../{RoundedCorner.cs => RoundCorners.cs} | 8 +- .../LottieCompositionYamlSerializer.cs | 8 +- source/LottieData/ShapeContentType.cs | 2 +- source/LottieData/Tools/Stats.cs | 2 +- .../Serialization/ShapeLayerContents.cs | 6 +- source/LottieToWinComp/CompositeOpacity.cs | 2 +- .../CompositionObjectFactory.cs | 13 +- source/LottieToWinComp/ExpressionFactory.cs | 28 +- .../LottieToWinComp/LottieToWinComp.projitems | 3 + .../LottieToWinCompTranslator.Rectangles.cs | 497 +++++++++++ .../LottieToWinCompTranslator.cs | 841 ++---------------- .../RectangleOrRoundedRectangleGeometry.cs | 72 ++ source/LottieToWinComp/ShapeContext.cs | 315 +++++++ source/LottieToWinComp/TranslationIssues.cs | 6 +- source/LottieToWinComp/TrimmedAnimatable.cs | 2 +- 21 files changed, 1062 insertions(+), 798 deletions(-) create mode 100644 source/Issues/LT0037.md rename source/LottieData/{RoundedCorner.cs => RoundCorners.cs} (90%) create mode 100644 source/LottieToWinComp/LottieToWinCompTranslator.Rectangles.cs create mode 100644 source/LottieToWinComp/RectangleOrRoundedRectangleGeometry.cs create mode 100644 source/LottieToWinComp/ShapeContext.cs diff --git a/source/Issues/LT0011.md b/source/Issues/LT0011.md index 877be38..0804c2b 100644 --- a/source/Issues/LT0011.md +++ b/source/Issues/LT0011.md @@ -1,18 +1,18 @@ -[comment]: # (name:MultipleAnimatedRoundedCornersIsNotSupported) -[comment]: # (text:Multiple animated rounded corners is not supported.) +[comment]: # (name:MultipleAnimatedRoundCornersIsNotSupported) +[comment]: # (text:Multiple animated round corners is not supported.) # Lottie-Windows Warning LT0011 -The Lottie file contains a shape with multiple rounded corner properties, and at least +The Lottie file contains a shape with multiple round corners properties, and at least one of them is animated. ## Remarks -After Effects allows arbitrary numbers of rounded corners to be applied to a shape. Currently -Lottie-Windows will ignore animations on a rounded corner if there are other rounded corners +After Effects allows arbitrary numbers of round corners to be applied to a shape. Currently +Lottie-Windows will ignore animations on a round corners if there are other round corners applied to the shape. -In most cases the After Effects project can be modified to use a single animated rounded -corner in order to avoid this issue. +In most cases the After Effects project can be modified to use a single animated round +corners in order to avoid this issue. If support for this feature is important for your scenario please provide feedback by raising it as an issue [here](https://github.com/windows-toolkit/Lottie-Windows/issues). diff --git a/source/Issues/LT0016.md b/source/Issues/LT0016.md index bb0eea4..676e5b5 100644 --- a/source/Issues/LT0016.md +++ b/source/Issues/LT0016.md @@ -1,9 +1,9 @@ -[comment]: # (name:PathWithRoundedCornersIsNotSupported) -[comment]: # (text:Path with rounded corners is not supported.) +[comment]: # (name:PathWithRoundCornersIsNotSupported) +[comment]: # (text:Path with round corners is not supported.) # Lottie-Windows Warning LT0016 -The Lottie file specifies rounded corners on a path. This is not currently supported by Lottie-Windows. +The Lottie file specifies round corners on a path. This is not currently supported by Lottie-Windows. ## Remarks If support for this feature is important for your scenario please provide feedback diff --git a/source/Issues/LT0037.md b/source/Issues/LT0037.md new file mode 100644 index 0000000..e3e4f74 --- /dev/null +++ b/source/Issues/LT0037.md @@ -0,0 +1,17 @@ +[comment]: # (name:ConflictingRoundnessAndRadiusIsNotSupported) +[comment]: # (text:Rectangle roundness with round corners is not supported.) + +# Lottie-Windows Warning LT0037 + +A Lottie file contains a rectangle that has rounded corners via a corner radius, and also has round corners applied to it. + +## Remarks +Currently Lottie-Windows ignores the round corners applied to a rectangle that ever has a non-0 corner radius. + +If support for this feature is important for your scenario please provide feedback +by raising it as an issue [here](https://github.com/windows-toolkit/Lottie-Windows/issues). + +## Resources + +* [Lottie-Windows repository](https://aka.ms/lottie) +* [Questions and feedback via Github](https://github.com/windows-toolkit/Lottie-Windows/issues) diff --git a/source/LottieData/Animatable.cs b/source/LottieData/Animatable.cs index 5e8a840..32e2ab6 100644 --- a/source/LottieData/Animatable.cs +++ b/source/LottieData/Animatable.cs @@ -101,7 +101,19 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData /// Returns true if this value is always equal to the given value. /// /// true if this value is always equal to the given value. - public bool AlwaysEquals(T value) => !IsAnimated && value.Equals(InitialValue); + public bool Always(T value) => !IsAnimated && value.Equals(InitialValue); + + /// + /// Returns true if this value is ever equal to the given value. + /// + /// true if this value is ever equal to the given value. + public bool Ever(T value) => value.Equals(InitialValue) || KeyFrames.Any(kf => value.Equals(kf.Value)); + + /// + /// Returns true if this value is ever not equal to the given value. + /// + /// true if this value is ever not equal to the given value. + public bool EverNot(T value) => !Always(value); /// // Not a great hash code because it ignore the KeyFrames, but quick. diff --git a/source/LottieData/LottieData.projitems b/source/LottieData/LottieData.projitems index f376321..8a52331 100644 --- a/source/LottieData/LottieData.projitems +++ b/source/LottieData/LottieData.projitems @@ -62,7 +62,7 @@ - + diff --git a/source/LottieData/LottieObjectType.cs b/source/LottieData/LottieObjectType.cs index e79d239..5247bcd 100644 --- a/source/LottieData/LottieObjectType.cs +++ b/source/LottieData/LottieObjectType.cs @@ -23,7 +23,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData RadialGradientStroke, Rectangle, Repeater, - RoundedCorner, + RoundCorners, Shape, ShapeGroup, ShapeLayer, diff --git a/source/LottieData/RoundedCorner.cs b/source/LottieData/RoundCorners.cs similarity index 90% rename from source/LottieData/RoundedCorner.cs rename to source/LottieData/RoundCorners.cs index 070fd72..86443e4 100644 --- a/source/LottieData/RoundedCorner.cs +++ b/source/LottieData/RoundCorners.cs @@ -7,9 +7,9 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData #if PUBLIC_LottieData public #endif - sealed class RoundedCorner : ShapeLayerContent + sealed class RoundCorners : ShapeLayerContent { - public RoundedCorner( + public RoundCorners( in ShapeLayerContentArgs args, Animatable radius) : base(in args) @@ -30,9 +30,9 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData public Animatable Radius { get; } /// - public override ShapeContentType ContentType => ShapeContentType.RoundedCorner; + public override ShapeContentType ContentType => ShapeContentType.RoundCorners; /// - public override LottieObjectType ObjectType => LottieObjectType.RoundedCorner; + public override LottieObjectType ObjectType => LottieObjectType.RoundCorners; } } diff --git a/source/LottieData/Serialization/LottieCompositionYamlSerializer.cs b/source/LottieData/Serialization/LottieCompositionYamlSerializer.cs index fa0a0bc..9c2fad0 100644 --- a/source/LottieData/Serialization/LottieCompositionYamlSerializer.cs +++ b/source/LottieData/Serialization/LottieCompositionYamlSerializer.cs @@ -93,7 +93,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData.Serialization case LottieObjectType.RadialGradientStroke: case LottieObjectType.Rectangle: case LottieObjectType.Repeater: - case LottieObjectType.RoundedCorner: + case LottieObjectType.RoundCorners: case LottieObjectType.Shape: case LottieObjectType.ShapeGroup: case LottieObjectType.SolidColorFill: @@ -314,8 +314,8 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData.Serialization return FromMergePaths((MergePaths)content, superclassContent); case ShapeContentType.Repeater: return FromRepeater((Repeater)content, superclassContent); - case ShapeContentType.RoundedCorner: - return FromRoundedCorner((RoundedCorner)content, superclassContent); + case ShapeContentType.RoundCorners: + return FromRoundCorners((RoundCorners)content, superclassContent); default: throw Unreachable; } @@ -674,7 +674,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData.Serialization return result; } - YamlObject FromRoundedCorner(RoundedCorner content, YamlMap superclassContent) + YamlObject FromRoundCorners(RoundCorners content, YamlMap superclassContent) { var result = superclassContent; result.Add(nameof(content.Radius), FromAnimatable(content.Radius)); diff --git a/source/LottieData/ShapeContentType.cs b/source/LottieData/ShapeContentType.cs index 192e552..27e1beb 100644 --- a/source/LottieData/ShapeContentType.cs +++ b/source/LottieData/ShapeContentType.cs @@ -23,7 +23,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData RadialGradientStroke, Rectangle, Repeater, - RoundedCorner, + RoundCorners, SolidColorFill, SolidColorStroke, Transform, diff --git a/source/LottieData/Tools/Stats.cs b/source/LottieData/Tools/Stats.cs index e99c183..6eba33b 100644 --- a/source/LottieData/Tools/Stats.cs +++ b/source/LottieData/Tools/Stats.cs @@ -182,7 +182,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData.Tools break; case ShapeContentType.Repeater: break; - case ShapeContentType.RoundedCorner: + case ShapeContentType.RoundCorners: break; case ShapeContentType.SolidColorFill: break; diff --git a/source/LottieReader/Serialization/ShapeLayerContents.cs b/source/LottieReader/Serialization/ShapeLayerContents.cs index c9dc37d..7f6abe9 100644 --- a/source/LottieReader/Serialization/ShapeLayerContents.cs +++ b/source/LottieReader/Serialization/ShapeLayerContents.cs @@ -45,7 +45,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData.Serialization case "mm": return ReadMergePaths(obj, in args); case "rd": - return ReadRoundedCorner(obj, in args); + return ReadRoundCorners(obj, in args); case "rp": return ReadRepeater(obj, in args); @@ -436,7 +436,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData.Serialization mergeMode); } - RoundedCorner ReadRoundedCorner( + RoundCorners ReadRoundCorners( in LottieJsonObjectElement obj, in ShapeLayerContent.ShapeLayerContentArgs shapeLayerContentArgs) { @@ -445,7 +445,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieData.Serialization var radius = ReadAnimatableFloat(obj.ObjectPropertyOrNull("r")); obj.AssertAllPropertiesRead(); - return new RoundedCorner( + return new RoundCorners( in shapeLayerContentArgs, radius); } diff --git a/source/LottieToWinComp/CompositeOpacity.cs b/source/LottieToWinComp/CompositeOpacity.cs index 471923e..f81cd50 100644 --- a/source/LottieToWinComp/CompositeOpacity.cs +++ b/source/LottieToWinComp/CompositeOpacity.cs @@ -38,7 +38,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp /// this . internal CompositeOpacity ComposedWith(in TrimmedAnimatable opacity) { - if (opacity.AlwaysEquals(Opacity.Opaque)) + if (opacity.Always(Opacity.Opaque)) { // Nothing to do. return this; diff --git a/source/LottieToWinComp/CompositionObjectFactory.cs b/source/LottieToWinComp/CompositionObjectFactory.cs index f239424..2c073c4 100644 --- a/source/LottieToWinComp/CompositionObjectFactory.cs +++ b/source/LottieToWinComp/CompositionObjectFactory.cs @@ -94,11 +94,12 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp internal CompositionPropertySet CreatePropertySet() => _compositor.CreatePropertySet(); - internal CompositionGeometry CreateRectangleGeometry(Sn.Vector2? size, Sn.Vector2? offset) + // Returns either a CompositionRectangleGeometry or a CompositionRoundedRectangleGeometry. + internal RectangleOrRoundedRectangleGeometry CreateRectangleGeometry() { const int c_rectangleGeometryIsUnreliableUntil = 12; - CompositionGeometry result; + RectangleOrRoundedRectangleGeometry result; if (_targetUapVersion < c_rectangleGeometryIsUnreliableUntil) { @@ -109,8 +110,6 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp // NOTE: magic tiny corner radius number - do not change! roundedRectangleGeometry.CornerRadius = new Sn.Vector2(0.000001F); - roundedRectangleGeometry.Size = size; - roundedRectangleGeometry.Offset = offset; result = roundedRectangleGeometry; } @@ -119,11 +118,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp // Later versions do not need the rounded rectangle workaround. ConsumeVersionFeature(c_rectangleGeometryIsUnreliableUntil); - var rectangleGeometry = _compositor.CreateRectangleGeometry(); - rectangleGeometry.Size = size; - rectangleGeometry.Offset = offset; - - result = rectangleGeometry; + result = _compositor.CreateRectangleGeometry(); } return result; diff --git a/source/LottieToWinComp/ExpressionFactory.cs b/source/LottieToWinComp/ExpressionFactory.cs index 6bd0bf7..155de65 100644 --- a/source/LottieToWinComp/ExpressionFactory.cs +++ b/source/LottieToWinComp/ExpressionFactory.cs @@ -27,6 +27,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp internal static readonly Vector2 MyPosition = MyVector2("Position"); internal static readonly Vector2 MySize = MyVector2("Size"); internal static readonly Matrix3x2 MyTransformMatrix = MyMatrix3x2("TransformMatrix"); + static readonly Scalar MyRadius = MyScalar("Radius"); static readonly Scalar MyRoundness = MyScalar("Roundness"); static readonly Scalar MyTStart = MyScalar("TStart"); static readonly Scalar MyTEnd = MyScalar("TEnd"); @@ -68,13 +69,34 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp internal static Scalar RootScalar(string propertyName) => Scalar(RootProperty(propertyName)); - internal static Vector2 ConstrainedCornerRadiusScalar(double roundness) + // Converts a RoundCorners.Radius value to CompositionRoundedRectangleGeometry.CornerRadius. + // RoundCorners corner radii are constrained to half of the coresponding side. + internal static Vector2 RadiusToCornerRadius(double radius) + => Vector2(Min(radius, MySize.X / 2), Min(radius, MySize.Y / 2)); + + // Converts a RoundCorners.Radius value to CompositionRoundedRectangleGeometry.CornerRadius. + // RoundCorners corner radii are constrained to half of the coresponding side. + internal static Vector2 RadiusToCornerRadius() + => Vector2(Min(MyRadius, MySize.X / 2), Min(MyRadius, MySize.Y / 2)); + + // Converts a RoundCorners.Radius value to CompositionRoundedRectangleGeometry.CornerRadius. + // RoundCorners corner radii are constrained to half of the coresponding side. + internal static Vector2 RadiusToCornerRadius(Sn.Vector2 size) + => Vector2(Min(MyRadius, size.X / 2), Min(MyRadius, size.Y / 2)); + + // Converts a Rectangle.Roundness value to CompositionRoundedRectangleGeometry.CornerRadius. + // Rectangle.Roundness corner radius is constrained to half of the smaller side. + internal static Vector2 RoundnessToCornerRadius(double roundness) => Vector2(Min(roundness, Min(MySize.X, MySize.Y) / 2), Min(roundness, Min(MySize.X, MySize.Y) / 2)); - internal static Vector2 ConstrainedCornerRadiusScalar() + // Converts a Rectangle.Roundness value to CompositionRoundedRectangleGeometry.CornerRadius. + // Rectangle.Roundness corner radius is constrained to half of the smaller side. + internal static Vector2 RoundessToCornerRadius() => Vector2(Min(MyRoundness, Min(MySize.X, MySize.Y) / 2), Min(MyRoundness, Min(MySize.X, MySize.Y) / 2)); - internal static Vector2 ConstrainedCornerRadiusScalar(Sn.Vector2 size) + // Converts a Rectangle.Roundness value to CompositionRoundedRectangleGeometry.CornerRadius. + // Rectangle.Roundness corner radius is constrained to half of the smaller side. + internal static Vector2 RoundnessToCornerRadius(Sn.Vector2 size) => Vector2(Min(MyRoundness, Math.Min(size.X, size.Y) / 2), Min(MyRoundness, Math.Min(size.X, size.Y) / 2)); // The value of a Color property stored as a Vector4 on the theming property set. diff --git a/source/LottieToWinComp/LottieToWinComp.projitems b/source/LottieToWinComp/LottieToWinComp.projitems index e3528cb..b2fb56d 100644 --- a/source/LottieToWinComp/LottieToWinComp.projitems +++ b/source/LottieToWinComp/LottieToWinComp.projitems @@ -11,11 +11,14 @@ + + + diff --git a/source/LottieToWinComp/LottieToWinCompTranslator.Rectangles.cs b/source/LottieToWinComp/LottieToWinCompTranslator.Rectangles.cs new file mode 100644 index 0000000..35149d6 --- /dev/null +++ b/source/LottieToWinComp/LottieToWinCompTranslator.Rectangles.cs @@ -0,0 +1,497 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; +using Microsoft.Toolkit.Uwp.UI.Lottie.LottieData; +using Microsoft.Toolkit.Uwp.UI.Lottie.WinCompData; +using Microsoft.Toolkit.Uwp.UI.Lottie.WinCompData.Mgcg; +using static Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp.ExpressionFactory; +using Expr = Microsoft.Toolkit.Uwp.UI.Lottie.WinCompData.Expressions.Expression; +using Sn = System.Numerics; + +namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp +{ +#pragma warning disable SA1205 // Partial elements should declare access +#pragma warning disable SA1601 // Partial elements should be documented + // Translation for Lottie rectangles. + sealed partial class LottieToWinCompTranslator + { + // Translates a Lottie rectangle to a CompositionShape. + CompositionShape TranslateRectangleContent(TranslationContext context, ShapeContext shapeContext, Rectangle rectangle) + { + var result = _c.CreateSpriteShape(); + var position = context.TrimAnimatable(rectangle.Position); + + if (IsNonRounded(shapeContext, rectangle)) + { + // Non-rounded rectangles are slightly more efficient, but they can only be used + // if there is no roundness or Round Corners. + TranslateAndApplyNonRoundedRectangleContent( + context, + shapeContext, + rectangle, + position, + result); + } + else + { + TranslateAndApplyRoundedRectangleContent( + context, + shapeContext, + rectangle, + position, + result); + } + + return result; + } + + // Translates a non-rounded Lottie rectangle to a CompositionShape. + void TranslateAndApplyNonRoundedRectangleContent( + TranslationContext context, + ShapeContext shapeContext, + Rectangle rectangle, + in TrimmedAnimatable position, + CompositionSpriteShape compositionShape) + { + Debug.Assert(IsNonRounded(shapeContext, rectangle), "Precondition"); + + var geometry = _c.CreateRectangleGeometry(); + compositionShape.Geometry = geometry; + + var size = AnimatableVector3Rewriter.EnsureOneEasingPerChannel(rectangle.Size); + if (size is AnimatableXYZ sizeXYZ) + { + var width = context.TrimAnimatable(sizeXYZ.X); + var height = context.TrimAnimatable(sizeXYZ.Y); + + if (!(width.IsAnimated || height.IsAnimated)) + { + geometry.Size = Vector2(width.InitialValue, height.InitialValue); + } + + geometry.Offset = InitialOffset(width, height, position: position); + + ApplyRectangleContentCommonXY(context, shapeContext, rectangle, compositionShape, width, height, position, geometry); + } + else + { + var size3 = context.TrimAnimatable((AnimatableVector3)size); + + if (!size3.IsAnimated) + { + geometry.Size = Vector2(size3.InitialValue); + } + + geometry.Offset = InitialOffset(size: size3, position: position); + + ApplyRectangleContentCommon(context, shapeContext, rectangle, compositionShape, size3, position, geometry); + } + } + + // Translates a Lottie rectangle to a CompositionShape containing a RoundedRectangle. + void TranslateAndApplyRoundedRectangleContent( + TranslationContext context, + ShapeContext shapeContext, + Rectangle rectangle, + in TrimmedAnimatable position, + CompositionSpriteShape compositionShape) + { + // Use a rounded rectangle geometry. + var geometry = _c.CreateRoundedRectangleGeometry(); + compositionShape.Geometry = geometry; + + // Get the corner radius. This will come from either Rectangle.Roundness + // or RoundCorners.Radius. + var cornerRadius = GetCornerRadius(context, shapeContext, rectangle, out var cornerRadiusIsRectangleRoundness); + + // Get the size, converted to an AnimatableXYZ if necessary to handle different easings per channel. + var size = AnimatableVector3Rewriter.EnsureOneEasingPerChannel(rectangle.Size); + if (size is AnimatableXYZ sizeXYZ) + { + var width = context.TrimAnimatable(sizeXYZ.X); + var height = context.TrimAnimatable(sizeXYZ.Y); + + ApplyCornerRadius( + context, + geometry, + cornerRadius, + initialWidth: width.InitialValue, + initialHeight: height.InitialValue, + isSizeAnimated: width.IsAnimated || height.IsAnimated, + cornerRadiusIsRectangleRoundness: cornerRadiusIsRectangleRoundness); + + geometry.Offset = InitialOffset(width, height, position: position); + + ApplyRectangleContentCommonXY(context, shapeContext, rectangle, compositionShape, width, height, position, geometry); + } + else + { + var size3 = context.TrimAnimatable((AnimatableVector3)size); + + ApplyCornerRadius( + context, + geometry, + cornerRadius, + initialWidth: size3.InitialValue.X, + initialHeight: size3.InitialValue.Y, + isSizeAnimated: size3.IsAnimated, + cornerRadiusIsRectangleRoundness: cornerRadiusIsRectangleRoundness); + + geometry.Offset = InitialOffset(size: size3, position: position); + + ApplyRectangleContentCommon(context, shapeContext, rectangle, compositionShape, size3, position, geometry); + } + } + + void ApplyCornerRadius( + TranslationContext context, + CompositionRoundedRectangleGeometry geometry, + in TrimmedAnimatable cornerRadius, + double initialWidth, + double initialHeight, + bool isSizeAnimated, + bool cornerRadiusIsRectangleRoundness) + { + var initialSize = Vector2(initialWidth, initialHeight); + + // In After Effects Rectangle.Roundness and RoundCorners.Radius are clamped to a value + // that depends on the size of the rectangle. + // If size or corner radius are animated, handle this with an expression. + if (cornerRadius.IsAnimated || isSizeAnimated) + { + WinCompData.Expressions.Vector2 cornerRadiusExpression; + + if (cornerRadius.IsAnimated) + { + InsertAndApplyScalarKeyFramePropertySetAnimation( + context, + cornerRadius, + geometry, + cornerRadiusIsRectangleRoundness ? "Roundness" : "Radius"); + + if (isSizeAnimated) + { + // Both size and cornerRadius are animated. + cornerRadiusExpression = cornerRadiusIsRectangleRoundness + ? RoundessToCornerRadius() + : RadiusToCornerRadius(); + } + else + { + // Only the cornerRadius is animated. + cornerRadiusExpression = cornerRadiusIsRectangleRoundness + ? RoundnessToCornerRadius(initialSize) + : RadiusToCornerRadius(initialSize); + } + } + else + { + // Only the size is animated. + cornerRadiusExpression = cornerRadiusIsRectangleRoundness + ? RoundnessToCornerRadius(cornerRadius.InitialValue) + : RadiusToCornerRadius(cornerRadius.InitialValue); + } + + var cornerRadiusAnimation = _c.CreateExpressionAnimation(cornerRadiusExpression); + cornerRadiusAnimation.SetReferenceParameter("my", geometry); + StartExpressionAnimation(geometry, "CornerRadius", cornerRadiusAnimation); + } + else + { + // Static size and corner radius. + if (cornerRadiusIsRectangleRoundness) + { + // Rectangle.Roundness corner radius is constrained to half of the smaller side. + var cornerRadiusValue = Math.Min(cornerRadius.InitialValue, Math.Min(initialWidth, initialHeight) / 2); + geometry.CornerRadius = Vector2((float)cornerRadiusValue); + } + else + { + // RoundCorners corner radii are constrained to half of the coresponding side. + geometry.CornerRadius = Vector2(Math.Min(cornerRadius.InitialValue, initialWidth / 2), Math.Min(cornerRadius.InitialValue, initialHeight / 2)); + } + } + + if (!isSizeAnimated) + { + geometry.Size = initialSize; + } + } + + void ApplyRectangleContentCommon( + TranslationContext context, + ShapeContext shapeContext, + Rectangle rectangle, + CompositionSpriteShape compositionRectangle, + in TrimmedAnimatable size, + in TrimmedAnimatable position, + RectangleOrRoundedRectangleGeometry geometry) + { + if (position.IsAnimated || size.IsAnimated) + { + Expr offsetExpression; + if (position.IsAnimated) + { + ApplyVector2KeyFrameAnimation(context, position, geometry, nameof(Rectangle.Position)); + geometry.Properties.InsertVector2(nameof(Rectangle.Position), Vector2(position.InitialValue)); + if (size.IsAnimated) + { + // Size AND position are animated. + offsetExpression = ExpressionFactory.PositionAndSizeToOffsetExpression; + ApplyVector2KeyFrameAnimation(context, size, geometry, nameof(Rectangle.Size)); + } + else + { + // Only Position is animated + offsetExpression = ExpressionFactory.HalfSizeToOffsetExpression(Vector2(size.InitialValue / 2)); + } + } + else + { + // Only Size is animated. + offsetExpression = ExpressionFactory.PositionToOffsetExpression(Vector2(position.InitialValue)); + ApplyVector2KeyFrameAnimation(context, size, geometry, nameof(Rectangle.Size)); + } + + var offsetExpressionAnimation = _c.CreateExpressionAnimation(offsetExpression); + offsetExpressionAnimation.SetReferenceParameter("my", geometry); + StartExpressionAnimation(geometry, "Offset", offsetExpressionAnimation); + } + + // Lottie rectangles have 0,0 at top right. That causes problems for TrimPath which expects 0,0 to be top left. + // Add an offset to the trim path. + + // TODO - this only works correctly if Size and TrimOffset are not animated. A complete solution requires + // adding another property. + var isPartialTrimPath = shapeContext.TrimPath != null && + (shapeContext.TrimPath.Start.IsAnimated || shapeContext.TrimPath.End.IsAnimated || shapeContext.TrimPath.Offset.IsAnimated || + shapeContext.TrimPath.Start.InitialValue.Value != 0 || shapeContext.TrimPath.End.InitialValue.Value != 1); + + if (size.IsAnimated && isPartialTrimPath) + { + // Warn that we might be getting things wrong + _issues.AnimatedRectangleWithTrimPathIsNotSupported(); + } + + var width = size.InitialValue.X; + var height = size.InitialValue.Y; + var trimOffsetDegrees = (width / (2 * (width + height))) * 360; + + TranslateAndApplyShapeContext( + context, + shapeContext, + compositionRectangle, + rectangle.DrawingDirection == DrawingDirection.Reverse, + trimOffsetDegrees: trimOffsetDegrees); + + if (_addDescriptions) + { + Describe(compositionRectangle, rectangle.Name); + Describe(compositionRectangle.Geometry, $"{rectangle.Name}.RectangleGeometry"); + } + } + + void ApplyRectangleContentCommonXY( + TranslationContext context, + ShapeContext shapeContext, + Rectangle rectangle, + CompositionSpriteShape compositionRectangle, + in TrimmedAnimatable width, + in TrimmedAnimatable height, + in TrimmedAnimatable position, + RectangleOrRoundedRectangleGeometry geometry) + { + if (position.IsAnimated || width.IsAnimated || height.IsAnimated) + { + Expr offsetExpression; + if (position.IsAnimated) + { + ApplyVector2KeyFrameAnimation(context, position, geometry, nameof(Rectangle.Position)); + geometry.Properties.InsertVector2(nameof(Rectangle.Position), Vector2(position.InitialValue)); + if (width.IsAnimated || height.IsAnimated) + { + // Size AND position are animated. + offsetExpression = ExpressionFactory.PositionAndSizeToOffsetExpression; + if (width.IsAnimated) + { + ApplyScalarKeyFrameAnimation(context, width, geometry, $"{nameof(Rectangle.Size)}.X"); + } + + if (height.IsAnimated) + { + ApplyScalarKeyFrameAnimation(context, height, geometry, $"{nameof(Rectangle.Size)}.Y"); + } + } + else + { + // Only Position is animated. + offsetExpression = ExpressionFactory.HalfSizeToOffsetExpression(Vector2(new Vector2(width.InitialValue, height.InitialValue) / 2)); + } + } + else + { + // Only Size is animated. + offsetExpression = ExpressionFactory.PositionToOffsetExpression(Vector2(position.InitialValue)); + if (width.IsAnimated) + { + ApplyScalarKeyFrameAnimation(context, width, geometry, $"{nameof(Rectangle.Size)}.X"); + } + + if (height.IsAnimated) + { + ApplyScalarKeyFrameAnimation(context, height, geometry, $"{nameof(Rectangle.Size)}.Y"); + } + } + + var offsetExpressionAnimation = _c.CreateExpressionAnimation(offsetExpression); + offsetExpressionAnimation.SetReferenceParameter("my", geometry); + StartExpressionAnimation(geometry, "Offset", offsetExpressionAnimation); + } + + // Lottie rectangles have 0,0 at top right. That causes problems for TrimPath which expects 0,0 to be top left. + // Add an offset to the trim path. + + // TODO - this only works correctly if Size and TrimOffset are not animated. A complete solution requires + // adding another property. + var isPartialTrimPath = shapeContext.TrimPath != null && + (shapeContext.TrimPath.Start.IsAnimated || shapeContext.TrimPath.End.IsAnimated || shapeContext.TrimPath.Offset.IsAnimated || + shapeContext.TrimPath.Start.InitialValue.Value != 0 || shapeContext.TrimPath.End.InitialValue.Value != 1); + + if ((width.IsAnimated || height.IsAnimated) && isPartialTrimPath) + { + // Warn that we might be getting things wrong. + _issues.AnimatedRectangleWithTrimPathIsNotSupported(); + } + + var initialWidth = width.InitialValue; + var initialHeight = height.InitialValue; + var trimOffsetDegrees = (initialWidth / (2 * (initialWidth + initialHeight))) * 360; + + TranslateAndApplyShapeContext( + context, + shapeContext, + compositionRectangle, + rectangle.DrawingDirection == DrawingDirection.Reverse, + trimOffsetDegrees: trimOffsetDegrees); + + if (_addDescriptions) + { + Describe(compositionRectangle, rectangle.Name); + Describe(compositionRectangle.Geometry, $"{rectangle.Name}.RectangleGeometry"); + } + } + + CanvasGeometry CreateWin2dRectangleGeometry( + TranslationContext context, + ShapeContext shapeContext, + Rectangle rectangle) + { + var position = context.TrimAnimatable(rectangle.Position); + var size = context.TrimAnimatable(rectangle.Size); + + var cornerRadius = GetCornerRadius(context, shapeContext, rectangle, out var cornerRadiusIsRectangleRoundness); + + if (position.IsAnimated || size.IsAnimated || cornerRadius.IsAnimated) + { + _issues.CombiningAnimatedShapesIsNotSupported(); + } + + var width = size.InitialValue.X; + var height = size.InitialValue.Y; + var radiusX = cornerRadius.InitialValue; + var radiusY = cornerRadius.InitialValue; + + // The radius is treated differently depending on whether it came from Rectangle.Roundness + // or RoundCorners.Radius. + if (cornerRadiusIsRectangleRoundness) + { + // Radius came from Rectangle.Radius. + // X and Y have the same radius (the corners are round) which is capped at half + // the length of the smallest side. + radiusX = radiusY = Math.Min(Math.Min(width, height) / 2, radiusY); + } + else + { + // Radius came from RoundCorners.Radius. + // X and Y radii are capped at half the length of their corresponding sides. + radiusX = Math.Min(width / 2, radiusX); + radiusY = Math.Min(width / 2, radiusY); + } + + var result = CanvasGeometry.CreateRoundedRectangle( + null, + (float)(position.InitialValue.X - (width / 2)), + (float)(position.InitialValue.Y - (height / 2)), + (float)width, + (float)height, + (float)radiusX, + (float)radiusY); + + var transformMatrix = CreateMatrixFromTransform(context, shapeContext.Transform); + if (!transformMatrix.IsIdentity) + { + result = result.Transform(transformMatrix); + } + + if (_addDescriptions) + { + Describe(result, rectangle.Name); + } + + return result; + } + + // Gets the corner radius and indicates whether the value came from Rectangle.Roundness (as + // opposed to RoundCorners.Radius). + TrimmedAnimatable GetCornerRadius( + TranslationContext context, + ShapeContext shapeContext, + Rectangle rectangle, + out bool cornerRadiusIsRectangleRoundness) + { + // Choose either Rectangle.Roundness or RoundCorners.Radius to control corner rounding. + // After Effects ignores RoundCorners.Radius when Rectangle.Roundness is non-0. + // + // If Rectangle.Roundness is ever non-0 and RoundCorners.Radius is ever non-0, we'd need to + // switch between Rectangle.Roundness and RoundCorners.Radius behaviors as RoundCorners.Radius + // switches between 0 and non-0. That is a rare case and would require a much more complicated + // set of expressions, so for now we don't support that case. + // + // If Rectangle.Roundness is ever non-0, choose it to define the rounding of the corners. + cornerRadiusIsRectangleRoundness = rectangle.Roundness.EverNot(0); + + // If we're using Rectangle.Roundness, check whether that might interfere with the + // RoundCorners.Radius values. + if (cornerRadiusIsRectangleRoundness && + rectangle.Roundness.Ever(0) && + shapeContext.RoundCorners.Radius.EverNot(0)) + { + // Report the issue about RoundCorners being ignored. + _issues.ConflictingRoundnessAndRadiusIsNotSupported(); + } + + return context.TrimAnimatable(cornerRadiusIsRectangleRoundness ? rectangle.Roundness : shapeContext.RoundCorners.Radius); + } + + // Convert the size and position for a geometry into an offset. + // This is necessary because a geometry's offset describes its + // top left corner, whereas a Lottie position describes its centerpoint. + static Sn.Vector2 InitialOffset( + in TrimmedAnimatable size, + in TrimmedAnimatable position) + => Vector2(position.InitialValue - (size.InitialValue / 2)); + + static Sn.Vector2 InitialOffset( + in TrimmedAnimatable width, + in TrimmedAnimatable height, + in TrimmedAnimatable position) + => Vector2(position.InitialValue - (new Vector3(width.InitialValue, height.InitialValue, 0) / 2)); + + // Returns true if the given rectangle ever has rounded corners. + static bool IsNonRounded(ShapeContext shapeContext, Rectangle rectangle) => + rectangle.Roundness.Always(0) && shapeContext.RoundCorners.Radius.Always(0); + } +} diff --git a/source/LottieToWinComp/LottieToWinCompTranslator.cs b/source/LottieToWinComp/LottieToWinCompTranslator.cs index baea81a..a4c67b3 100644 --- a/source/LottieToWinComp/LottieToWinCompTranslator.cs +++ b/source/LottieToWinComp/LottieToWinCompTranslator.cs @@ -23,6 +23,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.CompilerServices; using Microsoft.Toolkit.Uwp.UI.Lottie.LottieData; using Microsoft.Toolkit.Uwp.UI.Lottie.LottieData.Optimization; using Microsoft.Toolkit.Uwp.UI.Lottie.LottieMetadata; @@ -46,7 +47,8 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp #if PUBLIC public #endif - sealed class LottieToWinCompTranslator : IDisposable +#pragma warning disable SA1205 // Partial elements should declare access + sealed partial class LottieToWinCompTranslator : IDisposable { // Identifies the Lottie metadata in TranslationResult.SourceMetadata. static readonly Guid s_lottieMetadataKey = new Guid("EA3D6538-361A-4B1C-960D-50A6C35563A5"); @@ -882,7 +884,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp var inProgress = context.InPointAsProgress; var outProgress = context.OutPointAsProgress; - if (inProgress > 1 || outProgress <= 0 || inProgress >= outProgress || layerOpacity.AlwaysEquals(LottieData.Opacity.Transparent)) + if (inProgress > 1 || outProgress <= 0 || inProgress >= outProgress || layerOpacity.Always(LottieData.Opacity.Transparent)) { // The layer is never visible. Don't create anything. rootNode = null; @@ -963,7 +965,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp var inProgress = context.InPointAsProgress; var outProgress = context.OutPointAsProgress; - if (inProgress > 1 || outProgress <= 0 || inProgress >= outProgress || layerOpacity.AlwaysEquals(LottieData.Opacity.Transparent)) + if (inProgress > 1 || outProgress <= 0 || inProgress >= outProgress || layerOpacity.Always(LottieData.Opacity.Transparent)) { // The layer is never visible. Don't create anything. rootNode = null; @@ -1065,7 +1067,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp var inProgress = context.InPointAsProgress; var outProgress = context.OutPointAsProgress; - if (inProgress > 1 || outProgress <= 0 || inProgress >= outProgress || layerOpacity.AlwaysEquals(LottieData.Opacity.Transparent)) + if (inProgress > 1 || outProgress <= 0 || inProgress >= outProgress || layerOpacity.Always(LottieData.Opacity.Transparent)) { // The layer is never visible. Don't create anything. rootNode = null; @@ -1272,310 +1274,13 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp return referencedAsset; } - sealed class ShapeContentContext - { - readonly LottieToWinCompTranslator _owner; - - internal ShapeStroke Stroke { get; private set; } - - internal ShapeFill Fill { get; private set; } - - internal TrimPath TrimPath { get; private set; } - - internal RoundedCorner RoundedCorner { get; private set; } - - internal Transform Transform { get; private set; } - - // Opacity is not part of the Lottie context for shapes. But because WinComp - // doesn't support opacity on shapes, the opacity is inherited from - // the Transform and passed through to the brushes here. - internal CompositeOpacity Opacity { get; private set; } = CompositeOpacity.Opaque; - - internal ShapeContentContext(LottieToWinCompTranslator owner) - { - _owner = owner; - } - - internal void UpdateFromStack(Stack stack) - { - while (stack.Count > 0) - { - var popped = stack.Peek(); - switch (popped.ContentType) - { - case ShapeContentType.LinearGradientFill: - case ShapeContentType.RadialGradientFill: - case ShapeContentType.SolidColorFill: - Fill = ComposeFills(Fill, (ShapeFill)popped); - break; - - case ShapeContentType.LinearGradientStroke: - case ShapeContentType.RadialGradientStroke: - case ShapeContentType.SolidColorStroke: - Stroke = ComposeStrokes(Stroke, (ShapeStroke)popped); - break; - - case ShapeContentType.RoundedCorner: - RoundedCorner = ComposeRoundedCorners(RoundedCorner, (RoundedCorner)popped); - break; - - case ShapeContentType.TrimPath: - TrimPath = ComposeTrimPaths(TrimPath, (TrimPath)popped); - break; - - default: return; - } - - stack.Pop(); - } - } - - internal ShapeContentContext Clone() - { - return new ShapeContentContext(_owner) - { - Fill = Fill, - Stroke = Stroke, - TrimPath = TrimPath, - RoundedCorner = RoundedCorner, - Opacity = Opacity, - Transform = Transform, - }; - } - - internal void UpdateOpacityFromTransform(TranslationContext context, Transform transform) - { - if (transform is null) - { - return; - } - - Opacity = Opacity.ComposedWith(context.TrimAnimatable(transform.Opacity)); - } - - // Only used when translating geometries. Layers use an extra Shape or Visual to - // apply the transform, but geometries need to take the transform into account when - // they're created. - internal void SetTransform(Transform transform) - { - Transform = transform; - } - - ShapeFill ComposeFills(ShapeFill a, ShapeFill b) - { - if (a is null) - { - return b; - } - else if (b is null) - { - return a; - } - - if (a.FillKind != b.FillKind) - { - _owner._issues.MultipleFillsIsNotSupported(); - return b; - } - - switch (a.FillKind) - { - case ShapeFill.ShapeFillKind.SolidColor: - return ComposeSolidColorFills((SolidColorFill)a, (SolidColorFill)b); - } - - _owner._issues.MultipleFillsIsNotSupported(); - return b; - } - - SolidColorFill ComposeSolidColorFills(SolidColorFill a, SolidColorFill b) - { - if (!b.Color.IsAnimated && !b.Opacity.IsAnimated) - { - if (b.Opacity.InitialValue == LottieData.Opacity.Opaque && - b.Color.InitialValue.A == 1) - { - // b overrides a. - return b; - } - else if (b.Opacity.InitialValue.IsTransparent || b.Color.InitialValue.A == 0) - { - // b is transparent, so a wins. - return a; - } - } - - _owner._issues.MultipleFillsIsNotSupported(); - return b; - } - - ShapeStroke ComposeStrokes(ShapeStroke a, ShapeStroke b) - { - if (a is null) - { - return b; - } - else if (b is null) - { - return a; - } - - if (a.StrokeKind != b.StrokeKind) - { - _owner._issues.MultipleStrokesIsNotSupported(); - return b; - } - - switch (a.StrokeKind) - { - case ShapeStroke.ShapeStrokeKind.SolidColor: - return ComposeSolidColorStrokes((SolidColorStroke)a, (SolidColorStroke)b); - case ShapeStroke.ShapeStrokeKind.LinearGradient: - return ComposeLinearGradientStrokes((LinearGradientStroke)a, (LinearGradientStroke)b); - case ShapeStroke.ShapeStrokeKind.RadialGradient: - return ComposeRadialGradientStrokes((RadialGradientStroke)a, (RadialGradientStroke)b); - default: - throw new InvalidOperationException(); - } - } - - LinearGradientStroke ComposeLinearGradientStrokes(LinearGradientStroke a, LinearGradientStroke b) - { - Debug.Assert(a != null && b != null, "Precondition"); - - if (!a.StrokeWidth.IsAnimated && !b.StrokeWidth.IsAnimated && - a.Opacity.AlwaysEquals(LottieData.Opacity.Opaque) && b.Opacity.AlwaysEquals(LottieData.Opacity.Opaque)) - { - if (a.StrokeWidth.InitialValue >= b.StrokeWidth.InitialValue) - { - // a occludes b, so b can be ignored. - return a; - } - } - - _owner._issues.MultipleStrokesIsNotSupported(); - return a; - } - - RadialGradientStroke ComposeRadialGradientStrokes(RadialGradientStroke a, RadialGradientStroke b) - { - Debug.Assert(a != null && b != null, "Precondition"); - - if (!a.StrokeWidth.IsAnimated && !b.StrokeWidth.IsAnimated && - a.Opacity.AlwaysEquals(LottieData.Opacity.Opaque) && b.Opacity.AlwaysEquals(LottieData.Opacity.Opaque)) - { - if (a.StrokeWidth.InitialValue >= b.StrokeWidth.InitialValue) - { - // a occludes b, so b can be ignored. - return a; - } - } - - _owner._issues.MultipleStrokesIsNotSupported(); - return a; - } - - SolidColorStroke ComposeSolidColorStrokes(SolidColorStroke a, SolidColorStroke b) - { - Debug.Assert(a != null && b != null, "Precondition"); - - if (!a.StrokeWidth.IsAnimated && !b.StrokeWidth.IsAnimated && - !a.DashPattern.Any() && !b.DashPattern.Any() && - a.Opacity.AlwaysEquals(LottieData.Opacity.Opaque) && b.Opacity.AlwaysEquals(LottieData.Opacity.Opaque)) - { - if (a.StrokeWidth.InitialValue >= b.StrokeWidth.InitialValue) - { - // a occludes b, so b can be ignored. - return a; - } - } - - // The new stroke should be in addition to the existing stroke. And colors should blend. - _owner._issues.MultipleStrokesIsNotSupported(); - return b; - } - - RoundedCorner ComposeRoundedCorners(RoundedCorner a, RoundedCorner b) - { - if (a is null) - { - return b; - } - else if (b is null) - { - return a; - } - - if (!b.Radius.IsAnimated) - { - if (b.Radius.InitialValue >= 0) - { - // If b has a non-0 value, it wins. - return b; - } - else - { - // b is always 0. A wins. - return a; - } - } - - _owner._issues.MultipleAnimatedRoundedCornersIsNotSupported(); - return b; - } - - TrimPath ComposeTrimPaths(TrimPath a, TrimPath b) - { - if (a is null) - { - return b; - } - else if (b is null) - { - return a; - } - - if (!a.Start.IsAnimated && !a.Start.IsAnimated && !a.Offset.IsAnimated) - { - // a is not animated. - if (!b.Start.IsAnimated && !b.Start.IsAnimated && !b.Offset.IsAnimated) - { - // Both are not animated. - if (a.Start.InitialValue == b.End.InitialValue) - { - // a trims out everything. b is unnecessary. - return a; - } - else if (b.Start.InitialValue == b.End.InitialValue) - { - // b trims out everything. a is unnecessary. - return b; - } - else if (a.Start.InitialValue.Value == 0 && a.End.InitialValue.Value == 1 && a.Offset.InitialValue.Degrees == 0) - { - // a is trimming nothing. a is unnecessary. - return b; - } - else if (b.Start.InitialValue.Value == 0 && b.End.InitialValue.Value == 1 && b.Offset.InitialValue.Degrees == 0) - { - // b is trimming nothing. b is unnecessary. - return a; - } - } - } - - _owner._issues.MultipleTrimPathsIsNotSupported(); - return b; - } - } - // May return null if the layer does not produce any renderable content. CompositionSubGraph TranslateShapeLayer(TranslationContext.For context) { return new CompositionSubGraph.FromShapeLayer(this, context); } - CompositionShape TranslateGroupShapeContent(TranslationContext.For context, ShapeContentContext shapeContext, ShapeGroup group) + CompositionShape TranslateGroupShapeContent(TranslationContext.For context, ShapeContext shapeContext, ShapeGroup group) { var result = TranslateShapeLayerContents(context, shapeContext, group.Contents); @@ -1621,7 +1326,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp CompositionShape TranslateShapeLayerContents( TranslationContext.For context, - ShapeContentContext shapeContext, + ShapeContext shapeContext, IReadOnlyList contents) { // The Contents of a ShapeLayer is a list of instructions for a stack machine. @@ -1737,7 +1442,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp paths.Add(context.OptimizePath((Path)stack.Pop())); } - CheckForRoundedCornersOnPath(context, shapeContext); + CheckForRoundCornersOnPath(context, shapeContext); if (paths.Count == 1) { @@ -1787,7 +1492,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp case ShapeContentType.LinearGradientFill: case ShapeContentType.RadialGradientFill: case ShapeContentType.TrimPath: - case ShapeContentType.RoundedCorner: + case ShapeContentType.RoundCorners: throw new InvalidOperationException(); } } @@ -1797,7 +1502,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp // Merge the stack into a single shape. Merging is done recursively - the top geometry on the // stack is merged with the merge of the remainder of the stack. - CompositionShape TranslateMergePathsContent(TranslationContext context, ShapeContentContext shapeContext, Stack stack, MergePaths.MergeMode mergeMode) + CompositionShape TranslateMergePathsContent(TranslationContext context, ShapeContext shapeContext, Stack stack, MergePaths.MergeMode mergeMode) { var mergedGeometry = MergeShapeLayerContent(context, shapeContext, stack, mergeMode); if (mergedGeometry != null) @@ -1805,7 +1510,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp var result = _c.CreateSpriteShape(); result.Geometry = _c.CreatePathGeometry(new CompositionPath(mergedGeometry)); - TranslateAndApplyShapeContentContext( + TranslateAndApplyShapeContext( context, shapeContext, result, @@ -1820,7 +1525,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp } } - CanvasGeometry MergeShapeLayerContent(TranslationContext context, ShapeContentContext shapeContext, Stack stack, MergePaths.MergeMode mergeMode) + CanvasGeometry MergeShapeLayerContent(TranslationContext context, ShapeContext shapeContext, Stack stack, MergePaths.MergeMode mergeMode) { var pathFillType = shapeContext.Fill is null ? ShapeFill.PathFillType.EvenOdd : shapeContext.Fill.FillType; var geometries = CreateCanvasGeometries(context, shapeContext, stack, pathFillType).ToArray(); @@ -1919,7 +1624,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp IEnumerable CreateCanvasGeometries( TranslationContext context, - ShapeContentContext shapeContext, + ShapeContext shapeContext, Stack stack, ShapeFill.PathFillType pathFillType) { @@ -1959,7 +1664,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp case ShapeContentType.RadialGradientFill: case ShapeContentType.LinearGradientFill: case ShapeContentType.TrimPath: - case ShapeContentType.RoundedCorner: + case ShapeContentType.RoundCorners: // Ignore commands that set the context - we only want geometries. break; @@ -2074,7 +1779,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp CanvasGeometry CreateWin2dPathGeometryFromShape( TranslationContext context, - ShapeContentContext shapeContext, + ShapeContext shapeContext, Path path, ShapeFill.PathFillType fillType, bool optimizeLines) @@ -2102,7 +1807,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp return result; } - CanvasGeometry CreateWin2dEllipseGeometry(TranslationContext context, ShapeContentContext shapeContext, Ellipse ellipse) + CanvasGeometry CreateWin2dEllipseGeometry(TranslationContext context, ShapeContext shapeContext, Ellipse ellipse) { var ellipsePosition = context.TrimAnimatable(ellipse.Position); var ellipseDiameter = context.TrimAnimatable(ellipse.Diameter); @@ -2136,47 +1841,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp return result; } - CanvasGeometry CreateWin2dRectangleGeometry(TranslationContext context, ShapeContentContext shapeContext, Rectangle rectangle) - { - var position = context.TrimAnimatable(rectangle.Position); - var size = context.TrimAnimatable(rectangle.Size); - - // If a Rectangle is in the context, use it to override the corner radius. - var cornerRadius = context.TrimAnimatable(shapeContext.RoundedCorner != null ? shapeContext.RoundedCorner.Radius : rectangle.Roundness); - - if (position.IsAnimated || size.IsAnimated || cornerRadius.IsAnimated) - { - _issues.CombiningAnimatedShapesIsNotSupported(); - } - - var width = size.InitialValue.X; - var height = size.InitialValue.Y; - var radius = cornerRadius.InitialValue; - - var result = CanvasGeometry.CreateRoundedRectangle( - null, - (float)(position.InitialValue.X - (width / 2)), - (float)(position.InitialValue.Y - (height / 2)), - (float)width, - (float)height, - (float)radius, - (float)radius); - - var transformMatrix = CreateMatrixFromTransform(context, shapeContext.Transform); - if (!transformMatrix.IsIdentity) - { - result = result.Transform(transformMatrix); - } - - if (_addDescriptions) - { - Describe(result, rectangle.Name); - } - - return result; - } - - CompositionShape TranslateEllipseContent(TranslationContext context, ShapeContentContext shapeContext, Ellipse shapeContent) + CompositionShape TranslateEllipseContent(TranslationContext context, ShapeContext shapeContext, Ellipse shapeContent) { // An ellipse is represented as a SpriteShape with a CompositionEllipseGeometry. var compositionSpriteShape = _c.CreateSpriteShape(); @@ -2233,7 +1898,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp } } - TranslateAndApplyShapeContentContext( + TranslateAndApplyShapeContext( context, shapeContext, compositionSpriteShape, @@ -2243,388 +1908,17 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp return compositionSpriteShape; } - CompositionShape TranslateRectangleContent(TranslationContext context, ShapeContentContext shapeContext, Rectangle shapeContent) + void CheckForRoundCornersOnPath(TranslationContext context, ShapeContext shapeContext) { - var result = _c.CreateSpriteShape(); - var position = context.TrimAnimatable(shapeContent.Position); - - if (shapeContent.Roundness.AlwaysEquals(0) && shapeContext.RoundedCorner is null) + if (!context.TrimAnimatable(shapeContext.RoundCorners.Radius).Always(0)) { - TranslateAndApplyNonRoundedRectangleContent( - context, - shapeContext, - shapeContent, - position, - result); - } - else - { - TranslateAndApplyRoundedRectangleContent( - context, - shapeContext, - shapeContent, - position, - result); - } - - return result; - } - - void TranslateAndApplyNonRoundedRectangleContent( - TranslationContext context, - ShapeContentContext shapeContext, - Rectangle shapeContent, - in TrimmedAnimatable position, - CompositionSpriteShape compositionShape) - { - Debug.Assert(shapeContent.Roundness.AlwaysEquals(0) && shapeContext.RoundedCorner is null, "Precondition"); - - var size = AnimatableVector3Rewriter.EnsureOneEasingPerChannel(shapeContent.Size); - if (size is AnimatableXYZ sizeXYZ) - { - var width = context.TrimAnimatable(sizeXYZ.X); - var height = context.TrimAnimatable(sizeXYZ.Y); - - var geometry = _c.CreateRectangleGeometry( - size: (width.IsAnimated || height.IsAnimated) ? (Sn.Vector2?)null : Vector2(width.InitialValue, height.InitialValue), - offset: InitialOffset(width, height, position: position)); - - compositionShape.Geometry = geometry; - - ApplyRectangleContentCommonXY(context, shapeContext, shapeContent, compositionShape, width, height, position, geometry); - } - else - { - var size3 = context.TrimAnimatable((AnimatableVector3)size); - - var geometry = _c.CreateRectangleGeometry( - size: size3.IsAnimated ? (Sn.Vector2?)null : Vector2(size3.InitialValue), - offset: InitialOffset(size: size3, position: position)); - - compositionShape.Geometry = geometry; - - ApplyRectangleContentCommon(context, shapeContext, shapeContent, compositionShape, size3, position, geometry); - } - } - - void TranslateAndApplyRoundedRectangleContent( - TranslationContext context, - ShapeContentContext shapeContext, - Rectangle shapeContent, - in TrimmedAnimatable position, - CompositionSpriteShape compositionShape) - { - // Use a rounded rectangle geometry. - var geometry = _c.CreateRoundedRectangleGeometry(); - compositionShape.Geometry = geometry; - - // If a RoundedRectangle is in the context, use it to override the roundness unless the roundness is non-0. - var cornerRadius = context.TrimAnimatable( - shapeContext.RoundedCorner != null && shapeContent.Roundness.AlwaysEquals(0) - ? shapeContext.RoundedCorner.Radius - : shapeContent.Roundness); - - var size = AnimatableVector3Rewriter.EnsureOneEasingPerChannel(shapeContent.Size); - if (size is AnimatableXYZ sizeXYZ) - { - var width = context.TrimAnimatable(sizeXYZ.X); - var height = context.TrimAnimatable(sizeXYZ.Y); - - // In After Effects, the rectangle Roundness has no further effect once it reaches min(Size.X, Size.Y)/2. - // In Composition, the cornerRadius continues to affect the shape even beyond min(Size.X, Size.Y)/2. - // If size or corner radius are animated, handle this with an expression. - if (cornerRadius.IsAnimated || width.IsAnimated || height.IsAnimated) - { - WinCompData.Expressions.Vector2 cornerRadiusExpression; - if (cornerRadius.IsAnimated) - { - geometry.Properties.InsertScalar("Roundness", Float(cornerRadius.InitialValue)); - ApplyScalarKeyFrameAnimation(context, cornerRadius, geometry.Properties, "Roundness"); - - if (width.IsAnimated || height.IsAnimated) - { - // Both size and cornerRadius are animated. - cornerRadiusExpression = ConstrainedCornerRadiusScalar(); - } - else - { - // Only the cornerRadius is animated. - cornerRadiusExpression = ConstrainedCornerRadiusScalar(Vector2(width.InitialValue, height.InitialValue)); - } - } - else - { - // Only the size is animated. - cornerRadiusExpression = ConstrainedCornerRadiusScalar(cornerRadius.InitialValue); - } - - var cornerRadiusAnimation = _c.CreateExpressionAnimation(cornerRadiusExpression); - cornerRadiusAnimation.SetReferenceParameter("my", geometry); - StartExpressionAnimation(geometry, nameof(CompositionRoundedRectangleGeometry.CornerRadius), cornerRadiusAnimation); - } - else - { - // Static size and corner radius. - var cornerRadiusValue = Math.Min(cornerRadius.InitialValue, Math.Min(width.InitialValue, height.InitialValue) / 2); - geometry.CornerRadius = Vector2((float)cornerRadiusValue); - } - - geometry.Offset = InitialOffset(width, height, position: position); - - if (!width.IsAnimated || !height.IsAnimated) - { - geometry.Size = Vector2(width.InitialValue, height.InitialValue); - } - - ApplyRectangleContentCommonXY(context, shapeContext, shapeContent, compositionShape, width, height, position, geometry); - } - else - { - var size3 = context.TrimAnimatable((AnimatableVector3)size); - - // In After Effects, the rectangle Roundness has no further effect once it reaches min(Size.X, Size.Y)/2. - // In Composition, the cornerRadius continues to affect the shape even beyond min(Size.X, Size.Y)/2. - // If size or corner radius are animated, handle this with an expression. - if (cornerRadius.IsAnimated || size3.IsAnimated) - { - WinCompData.Expressions.Vector2 cornerRadiusExpression; - - if (cornerRadius.IsAnimated) - { - geometry.Properties.InsertScalar("Roundness", Float(cornerRadius.InitialValue)); - ApplyScalarKeyFrameAnimation(context, cornerRadius, geometry.Properties, "Roundness"); - - if (size3.IsAnimated) - { - // Both size and cornerRadius are animated. - cornerRadiusExpression = ConstrainedCornerRadiusScalar(); - } - else - { - // Only the cornerRadius is animated. - cornerRadiusExpression = ConstrainedCornerRadiusScalar(Vector2(size3.InitialValue)); - } - } - else - { - // Only the size is animated. - cornerRadiusExpression = ConstrainedCornerRadiusScalar(cornerRadius.InitialValue); - } - - var cornerRadiusAnimation = _c.CreateExpressionAnimation(cornerRadiusExpression); - cornerRadiusAnimation.SetReferenceParameter("my", geometry); - StartExpressionAnimation(geometry, "CornerRadius", cornerRadiusAnimation); - } - else - { - // Static size and corner radius. - var cornerRadiusValue = Math.Min(cornerRadius.InitialValue, Math.Min(size3.InitialValue.X, size3.InitialValue.Y) / 2); - geometry.CornerRadius = Vector2((float)cornerRadiusValue); - } - - geometry.Offset = InitialOffset(size: size3, position: position); - - if (!size3.IsAnimated) - { - geometry.Size = Vector2(size3.InitialValue); - } - - ApplyRectangleContentCommon(context, shapeContext, shapeContent, compositionShape, size3, position, geometry); - } - } - - // Convert the size and position for a geometry into an offset. - // This is necessary because a geometry's offset describes its - // top left corner, whereas a Lottie position describes its centerpoint. - static Sn.Vector2 InitialOffset( - in TrimmedAnimatable size, - in TrimmedAnimatable position) - => Vector2(position.InitialValue - (size.InitialValue / 2)); - - static Sn.Vector2 InitialOffset( - in TrimmedAnimatable width, - in TrimmedAnimatable height, - in TrimmedAnimatable position) - => Vector2(position.InitialValue - (new Vector3(width.InitialValue, height.InitialValue, 0) / 2)); - - void ApplyRectangleContentCommon( - TranslationContext context, - ShapeContentContext shapeContext, - Rectangle shapeContent, - CompositionSpriteShape compositionRectangle, - in TrimmedAnimatable size, - in TrimmedAnimatable position, - CompositionGeometry geometry) - { - if (position.IsAnimated || size.IsAnimated) - { - Expr offsetExpression; - if (position.IsAnimated) - { - ApplyVector2KeyFrameAnimation(context, position, geometry, nameof(Rectangle.Position)); - geometry.Properties.InsertVector2(nameof(Rectangle.Position), Vector2(position.InitialValue)); - if (size.IsAnimated) - { - // Size AND position are animated. - offsetExpression = ExpressionFactory.PositionAndSizeToOffsetExpression; - ApplyVector2KeyFrameAnimation(context, size, geometry, nameof(Rectangle.Size)); - } - else - { - // Only Position is animated - offsetExpression = ExpressionFactory.HalfSizeToOffsetExpression(Vector2(size.InitialValue / 2)); - } - } - else - { - // Only Size is animated. - offsetExpression = ExpressionFactory.PositionToOffsetExpression(Vector2(position.InitialValue)); - ApplyVector2KeyFrameAnimation(context, size, geometry, nameof(Rectangle.Size)); - } - - var offsetExpressionAnimation = _c.CreateExpressionAnimation(offsetExpression); - offsetExpressionAnimation.SetReferenceParameter("my", geometry); - StartExpressionAnimation(geometry, "Offset", offsetExpressionAnimation); - } - - // Lottie rectangles have 0,0 at top right. That causes problems for TrimPath which expects 0,0 to be top left. - // Add an offset to the trim path. - - // TODO - this only works correctly if Size and TrimOffset are not animated. A complete solution requires - // adding another property. - var isPartialTrimPath = shapeContext.TrimPath != null && - (shapeContext.TrimPath.Start.IsAnimated || shapeContext.TrimPath.End.IsAnimated || shapeContext.TrimPath.Offset.IsAnimated || - shapeContext.TrimPath.Start.InitialValue.Value != 0 || shapeContext.TrimPath.End.InitialValue.Value != 1); - - if (size.IsAnimated && isPartialTrimPath) - { - // Warn that we might be getting things wrong - _issues.AnimatedRectangleWithTrimPathIsNotSupported(); - } - - var width = size.InitialValue.X; - var height = size.InitialValue.Y; - var trimOffsetDegrees = (width / (2 * (width + height))) * 360; - - TranslateAndApplyShapeContentContext( - context, - shapeContext, - compositionRectangle, - shapeContent.DrawingDirection == DrawingDirection.Reverse, - trimOffsetDegrees: trimOffsetDegrees); - - if (_addDescriptions) - { - Describe(compositionRectangle, shapeContent.Name); - Describe(compositionRectangle.Geometry, $"{shapeContent.Name}.RectangleGeometry"); - } - } - - void ApplyRectangleContentCommonXY( - TranslationContext context, - ShapeContentContext shapeContext, - Rectangle shapeContent, - CompositionSpriteShape compositionRectangle, - in TrimmedAnimatable width, - in TrimmedAnimatable height, - in TrimmedAnimatable position, - CompositionGeometry geometry) - { - if (position.IsAnimated || width.IsAnimated || height.IsAnimated) - { - Expr offsetExpression; - if (position.IsAnimated) - { - ApplyVector2KeyFrameAnimation(context, position, geometry, nameof(Rectangle.Position)); - geometry.Properties.InsertVector2(nameof(Rectangle.Position), Vector2(position.InitialValue)); - if (width.IsAnimated || height.IsAnimated) - { - // Size AND position are animated. - offsetExpression = ExpressionFactory.PositionAndSizeToOffsetExpression; - if (width.IsAnimated) - { - ApplyScalarKeyFrameAnimation(context, width, geometry, $"{nameof(Rectangle.Size)}.X"); - } - - if (height.IsAnimated) - { - ApplyScalarKeyFrameAnimation(context, height, geometry, $"{nameof(Rectangle.Size)}.Y"); - } - } - else - { - // Only Position is animated. - offsetExpression = ExpressionFactory.HalfSizeToOffsetExpression(Vector2(new Vector2(width.InitialValue, height.InitialValue) / 2)); - } - } - else - { - // Only Size is animated. - offsetExpression = ExpressionFactory.PositionToOffsetExpression(Vector2(position.InitialValue)); - if (width.IsAnimated) - { - ApplyScalarKeyFrameAnimation(context, width, geometry, $"{nameof(Rectangle.Size)}.X"); - } - - if (height.IsAnimated) - { - ApplyScalarKeyFrameAnimation(context, height, geometry, $"{nameof(Rectangle.Size)}.Y"); - } - } - - var offsetExpressionAnimation = _c.CreateExpressionAnimation(offsetExpression); - offsetExpressionAnimation.SetReferenceParameter("my", geometry); - StartExpressionAnimation(geometry, "Offset", offsetExpressionAnimation); - } - - // Lottie rectangles have 0,0 at top right. That causes problems for TrimPath which expects 0,0 to be top left. - // Add an offset to the trim path. - - // TODO - this only works correctly if Size and TrimOffset are not animated. A complete solution requires - // adding another property. - var isPartialTrimPath = shapeContext.TrimPath != null && - (shapeContext.TrimPath.Start.IsAnimated || shapeContext.TrimPath.End.IsAnimated || shapeContext.TrimPath.Offset.IsAnimated || - shapeContext.TrimPath.Start.InitialValue.Value != 0 || shapeContext.TrimPath.End.InitialValue.Value != 1); - - if ((width.IsAnimated || height.IsAnimated) && isPartialTrimPath) - { - // Warn that we might be getting things wrong. - _issues.AnimatedRectangleWithTrimPathIsNotSupported(); - } - - var initialWidth = width.InitialValue; - var initialHeight = height.InitialValue; - var trimOffsetDegrees = (initialWidth / (2 * (initialWidth + initialHeight))) * 360; - - TranslateAndApplyShapeContentContext( - context, - shapeContext, - compositionRectangle, - shapeContent.DrawingDirection == DrawingDirection.Reverse, - trimOffsetDegrees: trimOffsetDegrees); - - if (_addDescriptions) - { - Describe(compositionRectangle, shapeContent.Name); - Describe(compositionRectangle.Geometry, $"{shapeContent.Name}.RectangleGeometry"); - } - } - - void CheckForRoundedCornersOnPath(TranslationContext context, ShapeContentContext shapeContext) - { - if (shapeContext.RoundedCorner != null) - { - var trimmedRadius = context.TrimAnimatable(shapeContext.RoundedCorner.Radius); - if (trimmedRadius.IsAnimated || trimmedRadius.InitialValue != 0) - { - // TODO - can rounded corners be implemented by composing cubic Beziers? - _issues.PathWithRoundedCornersIsNotSupported(); - } + // TODO - can round corners be implemented by composing cubic Beziers? + _issues.PathWithRoundCornersIsNotSupported(); } } // Groups multiple Shapes into a D2D geometry group. - CompositionShape TranslatePathGroupContent(TranslationContext context, ShapeContentContext shapeContext, IEnumerable paths) + CompositionShape TranslatePathGroupContent(TranslationContext context, ShapeContext shapeContext, IEnumerable paths) { var groupingSucceeded = PathGeometryGroup.TryGroupPaths(context, paths, out var grouped); @@ -2654,7 +1948,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp Describe(compositionPathGeometry, $"{shapeContentName}.PathGeometry"); } - TranslateAndApplyShapeContentContext( + TranslateAndApplyShapeContext( context, shapeContext, compositionSpriteShape, @@ -2664,7 +1958,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp return compositionSpriteShape; } - CompositionShape TranslatePathContent(TranslationContext context, ShapeContentContext shapeContext, Path path) + CompositionShape TranslatePathContent(TranslationContext context, ShapeContext shapeContext, Path path) { // A path is represented as a SpriteShape with a CompositionPathGeometry. var geometry = _c.CreatePathGeometry(); @@ -2679,7 +1973,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp Describe(geometry, $"{path.Name}.PathGeometry"); } - TranslateAndApplyShapeContentContext( + TranslateAndApplyShapeContext( context, shapeContext, compositionSpriteShape, @@ -2689,9 +1983,9 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp return compositionSpriteShape; } - void TranslateAndApplyShapeContentContext( + void TranslateAndApplyShapeContext( TranslationContext context, - ShapeContentContext shapeContext, + ShapeContext shapeContext, CompositionSpriteShape shape, bool reverseDirection, double trimOffsetDegrees) @@ -2849,22 +2143,12 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp { // Add properties that will be animated. The TrimStart and TrimEnd properties // will be set by these values through an expression. - geometry.Properties.InsertScalar("TStart", Float(startTrim.InitialValue)); - if (startTrim.IsAnimated) - { - ApplyTrimKeyFrameAnimation(context, startTrim, geometry.Properties, "TStart", "TStart", null); - } - + InsertAndApplyTrimKeyFramePropertySetAnimation(context, startTrim, geometry, "TStart"); var trimStartExpression = _c.CreateExpressionAnimation(ExpressionFactory.MinTStartTEnd); trimStartExpression.SetReferenceParameter("my", geometry); StartExpressionAnimation(geometry, nameof(geometry.TrimStart), trimStartExpression); - geometry.Properties.InsertScalar("TEnd", Float(endTrim.InitialValue)); - if (endTrim.IsAnimated) - { - ApplyTrimKeyFrameAnimation(context, endTrim, geometry.Properties, "TEnd", "TEnd", null); - } - + InsertAndApplyTrimKeyFramePropertySetAnimation(context, endTrim, geometry, "TEnd"); var trimEndExpression = _c.CreateExpressionAnimation(ExpressionFactory.MaxTStartTEnd); trimEndExpression.SetReferenceParameter("my", geometry); StartExpressionAnimation(geometry, nameof(geometry.TrimEnd), trimEndExpression); @@ -2928,7 +2212,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp return; } - if (shapeStroke.StrokeWidth.AlwaysEquals(0)) + if (shapeStroke.StrokeWidth.Always(0)) { return; } @@ -4222,6 +3506,49 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp } } + // Adds and animates a CompositionPropertySet value on the target object. + void InsertAndApplyScalarKeyFramePropertySetAnimation( + TranslationContext context, + in TrimmedAnimatable value, + CompositionObject targetObject, + string targetPropertyName, + string longDescription = null, + string shortDescription = null) + { + if (targetObject.Properties == targetObject) + { + throw new ArgumentException("targetObject must not be a CompositionPropertySet"); + } + + targetObject.Properties.InsertScalar(targetPropertyName, Float(value.InitialValue)); + + if (value.IsAnimated) + { + ApplyScaledScalarKeyFrameAnimation(context, value, 1, targetObject, targetPropertyName, longDescription, shortDescription); + } + } + + void InsertAndApplyTrimKeyFramePropertySetAnimation( + TranslationContext context, + in TrimmedAnimatable value, + CompositionObject targetObject, + string targetPropertyName, + string longDescription = null, + string shortDescription = null) + { + if (targetObject.Properties == targetObject) + { + throw new ArgumentException("targetObject must not be a CompositionPropertySet"); + } + + targetObject.Properties.InsertScalar(targetPropertyName, Float(value.InitialValue)); + + if (value.IsAnimated) + { + ApplyTrimKeyFrameAnimation(context, value, targetObject, targetPropertyName, longDescription, shortDescription); + } + } + void ApplyRotationKeyFrameAnimation( TranslationContext context, in TrimmedAnimatable value, @@ -5310,11 +4637,11 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp } internal override bool IsShape => - !_context.Layer.Masks.Any() || _context.Layer.IsHidden || _context.Layer.Transform.Opacity.AlwaysEquals(LottieData.Opacity.Transparent); + !_context.Layer.Masks.Any() || _context.Layer.IsHidden || _context.Layer.Transform.Opacity.Always(LottieData.Opacity.Transparent); internal override CompositionShape GetShapeRoot() { - if (_context.Layer.IsHidden || _context.Layer.Transform.Opacity.AlwaysEquals(LottieData.Opacity.Transparent)) + if (_context.Layer.IsHidden || _context.Layer.Transform.Opacity.Always(LottieData.Opacity.Transparent)) { // The layer does not render anything. Nothing to translate. This can happen when someone // creates a solid layer to act like a Null layer. @@ -5329,9 +4656,11 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp var rectangle = _owner._c.CreateSpriteShape(); - rectangle.Geometry = _owner._c.CreateRectangleGeometry( - size: new Sn.Vector2(_context.Layer.Width, _context.Layer.Height), - offset: null); + var rectangleGeometry = _owner._c.CreateRectangleGeometry(); + + rectangleGeometry.Size = new Sn.Vector2(_context.Layer.Width, _context.Layer.Height); + + rectangle.Geometry = rectangleGeometry; containerContentNode.Shapes.Add(rectangle); @@ -5352,7 +4681,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp internal override Visual GetVisualRoot(Sn.Vector2 maximumSize) { // Translate the SolidLayer to a Visual. - if (_context.Layer.IsHidden || _context.Layer.Transform.Opacity.AlwaysEquals(LottieData.Opacity.Transparent)) + if (_context.Layer.IsHidden || _context.Layer.Transform.Opacity.Always(LottieData.Opacity.Transparent)) { // The layer does not render anything. Nothing to translate. This can happen when someone // creates a solid layer to act like a Null layer. @@ -5420,7 +4749,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp return null; } - var shapeContext = new ShapeContentContext(_owner); + var shapeContext = new ShapeContext(_owner._issues); // Update the opacity from the transform. This is necessary to push the opacity // to the leaves (because CompositionShape does not support opacity). @@ -5443,7 +4772,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp return null; } - var shapeContext = new ShapeContentContext(_owner); + var shapeContext = new ShapeContext(_owner._issues); contentsNode.Shapes.Add(_owner.TranslateShapeLayerContents(_context, shapeContext, _context.Layer.Contents)); diff --git a/source/LottieToWinComp/RectangleOrRoundedRectangleGeometry.cs b/source/LottieToWinComp/RectangleOrRoundedRectangleGeometry.cs new file mode 100644 index 0000000..0e38766 --- /dev/null +++ b/source/LottieToWinComp/RectangleOrRoundedRectangleGeometry.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Numerics; +using Microsoft.Toolkit.Uwp.UI.Lottie.WinCompData; + +namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp +{ + /// + /// Helper for abstracting and + /// so that they can be + /// treated the same in some circumstances. + /// + abstract class RectangleOrRoundedRectangleGeometry + { + readonly CompositionGeometry _compositionGeometry; + + RectangleOrRoundedRectangleGeometry(CompositionGeometry compositionGeometry) + { + _compositionGeometry = compositionGeometry; + } + + public abstract bool IsRoundedRectangle { get; } + + public CompositionPropertySet Properties => _compositionGeometry.Properties; + + public abstract Vector2? Offset { get; set; } + + public abstract Vector2? Size { get; set; } + + sealed class WrappedRectangleGeometry : RectangleOrRoundedRectangleGeometry + { + readonly CompositionRectangleGeometry _wrapped; + + internal WrappedRectangleGeometry(CompositionRectangleGeometry wrapped) + : base(wrapped) + { + _wrapped = wrapped; + } + + public override bool IsRoundedRectangle => false; + + public override Vector2? Offset { get => _wrapped.Offset; set => _wrapped.Offset = value; } + + public override Vector2? Size { get => _wrapped.Size; set => _wrapped.Size = value; } + } + + sealed class WrappedRoundedRectangleGeometry : RectangleOrRoundedRectangleGeometry + { + readonly CompositionRoundedRectangleGeometry _wrapped; + + internal WrappedRoundedRectangleGeometry(CompositionRoundedRectangleGeometry wrapped) + : base(wrapped) + { + _wrapped = wrapped; + } + + public override bool IsRoundedRectangle => true; + + public override Vector2? Offset { get => _wrapped.Offset; set => _wrapped.Offset = value; } + + public override Vector2? Size { get => _wrapped.Size; set => _wrapped.Size = value; } + } + + public static implicit operator RectangleOrRoundedRectangleGeometry(CompositionRectangleGeometry rectangle) => new WrappedRectangleGeometry(rectangle); + + public static implicit operator RectangleOrRoundedRectangleGeometry(CompositionRoundedRectangleGeometry roundedRectangle) => new WrappedRoundedRectangleGeometry(roundedRectangle); + + public static implicit operator CompositionGeometry(RectangleOrRoundedRectangleGeometry rectangle) => rectangle._compositionGeometry; + } +} \ No newline at end of file diff --git a/source/LottieToWinComp/ShapeContext.cs b/source/LottieToWinComp/ShapeContext.cs new file mode 100644 index 0000000..9539eef --- /dev/null +++ b/source/LottieToWinComp/ShapeContext.cs @@ -0,0 +1,315 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Microsoft.Toolkit.Uwp.UI.Lottie.LottieData; + +namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp +{ + /// + /// Describes the environment in which a Lottie shape should be interpreted. + /// + sealed class ShapeContext + { + // A RoundCorners with a radius of 0. + static readonly RoundCorners s_defaultRoundCorners = + new RoundCorners(new ShapeLayerContent.ShapeLayerContentArgs { }, new Animatable(0, null)); + + readonly TranslationIssues _issues; + + internal ShapeContext(TranslationIssues issues) => _issues = issues; + + internal ShapeStroke Stroke { get; private set; } + + internal ShapeFill Fill { get; private set; } + + internal TrimPath TrimPath { get; private set; } + + /// + /// Never null. If there is no set, a default + /// 0 will be returned. + /// + internal RoundCorners RoundCorners { get; private set; } = s_defaultRoundCorners; + + internal Transform Transform { get; private set; } + + // Opacity is not part of the Lottie context for shapes. But because WinComp + // doesn't support opacity on shapes, the opacity is inherited from + // the Transform and passed through to the brushes here. + internal CompositeOpacity Opacity { get; private set; } = CompositeOpacity.Opaque; + + internal void UpdateFromStack(Stack stack) + { + while (stack.Count > 0) + { + var popped = stack.Peek(); + switch (popped.ContentType) + { + case ShapeContentType.LinearGradientFill: + case ShapeContentType.RadialGradientFill: + case ShapeContentType.SolidColorFill: + Fill = ComposeFills(Fill, (ShapeFill)popped); + break; + + case ShapeContentType.LinearGradientStroke: + case ShapeContentType.RadialGradientStroke: + case ShapeContentType.SolidColorStroke: + Stroke = ComposeStrokes(Stroke, (ShapeStroke)popped); + break; + + case ShapeContentType.RoundCorners: + RoundCorners = ComposeRoundCorners(RoundCorners, (RoundCorners)popped); + break; + + case ShapeContentType.TrimPath: + TrimPath = ComposeTrimPaths(TrimPath, (TrimPath)popped); + break; + + default: return; + } + + stack.Pop(); + } + } + + internal void UpdateOpacityFromTransform(TranslationContext context, Transform transform) + { + if (transform is null) + { + return; + } + + Opacity = Opacity.ComposedWith(context.TrimAnimatable(transform.Opacity)); + } + + // Only used when translating geometries. Layers use an extra Shape or Visual to + // apply the transform, but geometries need to take the transform into account when + // they're created. + internal void SetTransform(Transform transform) + { + Transform = transform; + } + + internal ShapeContext Clone() => + new ShapeContext(_issues) + { + Fill = Fill, + Stroke = Stroke, + TrimPath = TrimPath, + RoundCorners = RoundCorners, + Opacity = Opacity, + Transform = Transform, + }; + + ShapeFill ComposeFills(ShapeFill a, ShapeFill b) + { + if (a is null) + { + return b; + } + else if (b is null) + { + return a; + } + + if (a.FillKind != b.FillKind) + { + _issues.MultipleFillsIsNotSupported(); + return b; + } + + switch (a.FillKind) + { + case ShapeFill.ShapeFillKind.SolidColor: + return ComposeSolidColorFills((SolidColorFill)a, (SolidColorFill)b); + } + + _issues.MultipleFillsIsNotSupported(); + return b; + } + + SolidColorFill ComposeSolidColorFills(SolidColorFill a, SolidColorFill b) + { + if (!b.Color.IsAnimated && !b.Opacity.IsAnimated) + { + if (b.Opacity.InitialValue == LottieData.Opacity.Opaque && + b.Color.InitialValue.A == 1) + { + // b overrides a. + return b; + } + else if (b.Opacity.InitialValue.IsTransparent || b.Color.InitialValue.A == 0) + { + // b is transparent, so a wins. + return a; + } + } + + _issues.MultipleFillsIsNotSupported(); + return b; + } + + ShapeStroke ComposeStrokes(ShapeStroke a, ShapeStroke b) + { + if (a is null) + { + return b; + } + else if (b is null) + { + return a; + } + + if (a.StrokeKind != b.StrokeKind) + { + _issues.MultipleStrokesIsNotSupported(); + return b; + } + + switch (a.StrokeKind) + { + case ShapeStroke.ShapeStrokeKind.SolidColor: + return ComposeSolidColorStrokes((SolidColorStroke)a, (SolidColorStroke)b); + case ShapeStroke.ShapeStrokeKind.LinearGradient: + return ComposeLinearGradientStrokes((LinearGradientStroke)a, (LinearGradientStroke)b); + case ShapeStroke.ShapeStrokeKind.RadialGradient: + return ComposeRadialGradientStrokes((RadialGradientStroke)a, (RadialGradientStroke)b); + default: + throw new InvalidOperationException(); + } + } + + LinearGradientStroke ComposeLinearGradientStrokes(LinearGradientStroke a, LinearGradientStroke b) + { + Debug.Assert(a != null && b != null, "Precondition"); + + if (!a.StrokeWidth.IsAnimated && !b.StrokeWidth.IsAnimated && + a.Opacity.Always(LottieData.Opacity.Opaque) && b.Opacity.Always(LottieData.Opacity.Opaque)) + { + if (a.StrokeWidth.InitialValue >= b.StrokeWidth.InitialValue) + { + // a occludes b, so b can be ignored. + return a; + } + } + + _issues.MultipleStrokesIsNotSupported(); + return a; + } + + RadialGradientStroke ComposeRadialGradientStrokes(RadialGradientStroke a, RadialGradientStroke b) + { + Debug.Assert(a != null && b != null, "Precondition"); + + if (!a.StrokeWidth.IsAnimated && !b.StrokeWidth.IsAnimated && + a.Opacity.Always(LottieData.Opacity.Opaque) && b.Opacity.Always(LottieData.Opacity.Opaque)) + { + if (a.StrokeWidth.InitialValue >= b.StrokeWidth.InitialValue) + { + // a occludes b, so b can be ignored. + return a; + } + } + + _issues.MultipleStrokesIsNotSupported(); + return a; + } + + SolidColorStroke ComposeSolidColorStrokes(SolidColorStroke a, SolidColorStroke b) + { + Debug.Assert(a != null && b != null, "Precondition"); + + if (!a.StrokeWidth.IsAnimated && !b.StrokeWidth.IsAnimated && + !a.DashPattern.Any() && !b.DashPattern.Any() && + a.Opacity.Always(LottieData.Opacity.Opaque) && b.Opacity.Always(LottieData.Opacity.Opaque)) + { + if (a.StrokeWidth.InitialValue >= b.StrokeWidth.InitialValue) + { + // a occludes b, so b can be ignored. + return a; + } + } + + // The new stroke should be in addition to the existing stroke. And colors should blend. + _issues.MultipleStrokesIsNotSupported(); + return b; + } + + RoundCorners ComposeRoundCorners(RoundCorners a, RoundCorners b) + { + if (a is null) + { + return b; + } + else if (b is null) + { + return a; + } + + if (!b.Radius.IsAnimated) + { + if (b.Radius.InitialValue >= 0) + { + // If b has a non-0 value, it wins. + return b; + } + else + { + // b is always 0. A wins. + return a; + } + } + + _issues.MultipleAnimatedRoundCornersIsNotSupported(); + return b; + } + + TrimPath ComposeTrimPaths(TrimPath a, TrimPath b) + { + if (a is null) + { + return b; + } + else if (b is null) + { + return a; + } + + if (!a.Start.IsAnimated && !a.Start.IsAnimated && !a.Offset.IsAnimated) + { + // a is not animated. + if (!b.Start.IsAnimated && !b.Start.IsAnimated && !b.Offset.IsAnimated) + { + // Both are not animated. + if (a.Start.InitialValue == b.End.InitialValue) + { + // a trims out everything. b is unnecessary. + return a; + } + else if (b.Start.InitialValue == b.End.InitialValue) + { + // b trims out everything. a is unnecessary. + return b; + } + else if (a.Start.InitialValue.Value == 0 && a.End.InitialValue.Value == 1 && a.Offset.InitialValue.Degrees == 0) + { + // a is trimming nothing. a is unnecessary. + return b; + } + else if (b.Start.InitialValue.Value == 0 && b.End.InitialValue.Value == 1 && b.Offset.InitialValue.Degrees == 0) + { + // b is trimming nothing. b is unnecessary. + return a; + } + } + } + + _issues.MultipleTrimPathsIsNotSupported(); + return b; + } + } +} diff --git a/source/LottieToWinComp/TranslationIssues.cs b/source/LottieToWinComp/TranslationIssues.cs index 20c575c..ebfb3e3 100644 --- a/source/LottieToWinComp/TranslationIssues.cs +++ b/source/LottieToWinComp/TranslationIssues.cs @@ -53,7 +53,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp internal void MergingALargeNumberOfShapesIsNotSupported() => Report("LT0010", "Merging a large number of shape is not supported."); - internal void MultipleAnimatedRoundedCornersIsNotSupported() => Report("LT0011", "Multiple animated rounded corners is not supported."); + internal void MultipleAnimatedRoundCornersIsNotSupported() => Report("LT0011", "Multiple animated round corners is not supported."); internal void MultipleFillsIsNotSupported() => Report("LT0012", "Multiple fills is not supported."); @@ -64,7 +64,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp // LT0015 has been deprecated. // Was: Opacity and color animated at the same time is not supported. - internal void PathWithRoundedCornersIsNotSupported() => Report("LT0016", "Path with rounded corners is not supported."); + internal void PathWithRoundCornersIsNotSupported() => Report("LT0016", "Path with round corners is not supported."); internal void PolystarIsNotSupported() => Report("LT0017", "Polystar is not supported."); @@ -110,6 +110,8 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp internal void CombiningMultipleAnimatedPathsIsNotSupported() => Report("LT0036", "Combining multiple animated paths is not supported."); + internal void ConflictingRoundnessAndRadiusIsNotSupported() => Report("LT0037", "Rectangle roundness with round corners is not supported."); + void Report(string code, string description) { _issues.Add((code, description)); diff --git a/source/LottieToWinComp/TrimmedAnimatable.cs b/source/LottieToWinComp/TrimmedAnimatable.cs index f46061b..1c5f61a 100644 --- a/source/LottieToWinComp/TrimmedAnimatable.cs +++ b/source/LottieToWinComp/TrimmedAnimatable.cs @@ -51,7 +51,7 @@ namespace Microsoft.Toolkit.Uwp.UI.Lottie.LottieToWinComp /// Returns true if this value is always equal to the given value. /// /// true if this value is always equal to the given value. - internal bool AlwaysEquals(T value) => !IsAnimated && value.Equals(InitialValue); + internal bool Always(T value) => !IsAnimated && value.Equals(InitialValue); internal TranslationContext Context { get; } }