Jump to content
Search Community

MorphSVG question

Julius Friedman test
Moderator Tag

Warning: Please note

This thread was started before GSAP 3 was released. Some information, especially the syntax, may be out of date for GSAP 3. Please see the GSAP 3 migration guide and release notes for more information about how to update the code to GSAP 3's syntax. 

Recommended Posts

Hello Guys,

 

First of all just a quick kudos on the API and performance of GSAP; I have been using it for a project I am prototyping and I find it quite pleasant to work with.


The question:

Please see the attached codepen, there appears to be a bug in the set function of `morphsvg` from what I can see however I wanted to ask this question to make sure.

The easiest way to replicate the issue is to just attempt a morph on that path, in order to simplify the replication I have extracted the code required here:

 

<svg id="graphic">
<path id="source" style="" d="m256 421h30l60-135h-150zm0 0"/>
<path id="dest" d="M139.911,237.64c-40.751,40.701-72.005,18.178-110.759,56.751  C-16.039,339.37-6.793,417.925,43.784,468.264c52.284,52.039,130.479,58.57,174.667,14.589  c38.638-38.457,16.203-69.146,57.208-110.041c7.348-7.328,54.912-24.028,79.847-56.991c60.491-79.964-144.48,38.09-56.838-99.987  C198.803,116.092,155.828,221.742,139.911,237.64z" />
</svg>
TweenMax.to('.source', 1, {
                            morphSVG: '.dest',                            
                            ease: Power2.easeInOut
                        });

The error can be seen in the console

Error: <path> attribute d: Unexpected end of attribute. Expected number, "…6 196 286 M0 0 C

 

The fix?!?

 

`morphsvg` defines a set method which I have extracted and placed here for illustration.

 

set: function (e) {
            var X, L, t, r, n, o, i, a, h, s, l, f, g, p, c, u = this._rawPath, d = this._controlPT, m = this._anchorPT, _ = this._rnd, y = this._target;
            if (this._super.setRatio.call(this, e),
                1 === e && this._apply)
                for (n = this._firstPT; n;)
                    n.end && (this._prop ? y[this._prop] = n.end : y.setAttribute(n.endProp, n.end)),
                        n = n._next;
            else if (u) {
                for (; m;)
                    a = m.sa + e * m.ca,
                        i = m.sl + e * m.cl,
                        m.t[m.i] = this._origin.x + U(a) * i,
                        m.t[m.i + 1] = this._origin.y + q(a) * i,
                        m = m._next;
                for (r = e < .5 ? 2 * e * e : (4 - 2 * e) * e - 1; d;)
                    c = (h = d.i) + (h === (o = u[d.j]).length - 4 ? 7 - o.length : 5),
                        a = C(o[c] - o[h + 1], o[c - 1] - o[h]),
                        g = q(a),
                        p = U(a),
                        l = o[h + 2],
                        f = o[h + 3],
                        i = d.l1s + r * d.l1c,
                        o[h] = l - p * i,
                        o[h + 1] = f - g * i,
                        i = d.l2s + r * d.l2c,
                        o[c - 1] = l + p * i,
                        o[c] = f + g * i,
                        d = d._next;
                //When there are only 2 points the " C" is not part of the path.
                if (y._gsRawPath = u,
                    this._apply) {
                    for (t = "", s = 0; s < u.length; s++)                        
                        for (i = (o = u[s]).length,
                            L = o.length,
                            //Move to the x, y and setup a curve to the next point OR mark this at the end of the path?
                            X = "M" + (o[0] * _ | 0) / _ + " " + (o[1] * _ | 0) / _ + (L > 2 ? " C" : ""),
                            t += X,
                            h = 2; h < i; h++)
                            t += (o[h] * _ | 0) / _ + " ";
                    this._prop ? y[this._prop] = t : y.setAttribute("d", t)
                }
            }
            this._render && u && this._render.call(this._tween, u, y)
        }

As you can see I have added 'X and L' vars at the top, I use those to inspect the variables being built in the loops below, what I think is occurring here is as I have annotated in the code, we are going through the points to build a path string, we use the first 2 values in the array as the X, Y positions and then it seems we expect more points as we create a C(urve) to the remaining points `(h = 2; h < i; h++)`.

 

Without my modification the 't' variable ends up with a string like:

"M ${X} ${Y} C", where ${X} and ${Y} are given by the first 2 points in the path and then  that loop ends and we proceed to the next with the following logic:

 

for (t = "", s = 0; s < u.length; s++)                        
    for (i = (o = u).length,

 

That seems straight forward as we have just used the first 2 points in the loop, however it seems that there are occasions where there can be less  or equal to 2 points? In such case a string will be resulting which is not valid and will cause that frame of the animation to fail on that frame.

 

Obviously without an X and Y the path would likely be incomplete however there are definitely more points in the path, so I attempted to debug the loop and it seems the animation recovers on the next steps so I imagine this has something to do with the way the points and ratio are calculated in the loop above however I have not dived in enough to determine exactly the root cause although I have a suspicion which I will indicate below.

 

FYI, My fix seems to work as there are no more errors in the console however I just wanted to bring this to your attention as it seemed like possibly something which could be fixed in the library itself.

 

E.g. one thing I wanted to change but didn't want to dig myself to far in was in the algorithm:

I wanted to make more use of the L variable however it seems that all the points get normalized to 0 even if they are undefined in that loop.

 

e.g. of o.length = 0 then h + 1 is out of bounds and so is c - 1 in the following logic (depending on the ease I suppose)
 

a = C(o[c] - o[h + 1], o[c - 1] - o[h]), ...
l = o[h + 2], ...
f = o[h + 3], ..

 

Perhaps as `| 0` there can be useful to force the possibly undefined value into a number as we do when we calculate the points for the string:

 

(o[0] * _ | 0)

 

Please do let me know if I have missed something or what you guys determine!

 

Thanks for your library and assistance!

See the Pen zgWYXp by juliusfriedman (@juliusfriedman) on CodePen

Link to comment
Share on other sites

Before you start digging into source code, you should at least try troubleshooting your SVG code first. You have have a pointless m0 0 at the end here.

 

<path id="source" style="" d="m256 421h30l60-135h-150zm0 0"/>

 

Remove it, and it seems to work fine.

 

<path id="source" style="" d="m256 421h30l60-135h-150z"/>

 

 

  • Like 3
Link to comment
Share on other sites

Thank you for your input Blake!

I can attempt to simplify the points and remove those which I do not require however some of these points are converted using the `MorphSVGPlugin.convertToPath` function which I would have imagined would have either formatted them as required or removed the points which it did not need, either way the issue still seems to be valid based on the information I provided....
 

I am not the Author of most of the graphics in use at the current time, I am using stock graphics for the prototyping and hence why I didn't attempt to fiddle with the point data therein.

 

In short, I agree with what your saying however I feel like there other cases which may cause the issue to exhibit itself as I have attempted to explain in the post above e.g. point data with only 1 point etc.

 

In such cases it can be handled to either use the transform origin or a 0 coordinate etc but I just wanted to make the issue known such that it could be fixed.

 

Please let me know if my code above didn't solve this issue in the correct way and what your take on the correct logic therein would be for such cases.

 

Regards!

  • Like 1
Link to comment
Share on other sites

I would agree that any data that is redundant or pointless should be removed; During the prototyping I am trying to avoid having to modify any graphics or at least note where things needs to be modified to accommodate special requirements of design later on.
I just wanted to ask because it seemed trivial to address in what was an otherwise very pleasant experience when using the API and I am quite sure that others would benefit from the minimal change this would require.


I appreciate your kind words and additionally if there is anything else I can do to help please just let me know!

The only other feedback it seems I would like to provide is that when using the `MorphSVGPlugin.convertToPath` function, it would be helpful if one would take into account transforms on the source and target path, possibly with a custom function if someone desire or even more options but the main importance seems to be coordinate origin related `transform` attribute on the object being converted.

One can seem to override this behavior only currently by hijacking `e.getBBox` which is used during the `h` function to calculate the points of the path data being created or doing so afterwards the conversion but before the `morphSvg` is used in an animation.

 

I found most of this writing a function to morph a graphic from a source to a destination when I found this among a few other quirks which GSAP might be able to help with if your interested...

 

I have included that function below which is definitely NOT complete but functions as expected minus the coordinate transform issue I am describing.

 

Others may find it useful when morphing from one thing to another...

 

//from is the source, to is the destination, onComplete is called when the timeline is elapsed.
function morphFromTo(from, to, onComplete) {

                //Have to query the paths or polygons or other supported objects. (Todo, could create a clipPath or other cool effect from the path data... or could translate the shape to a clipPath...)
                const f = from.querySelectorAll('path, circle, rect, ellipse, line, polygon, polyline'), tt = to.querySelectorAll('path, circle, rect, ellipse, line, polygon, polyline')

                //NodeList length
                const ffMax = f.length;

                //The resulting timeline
                const timeline = new TimelineMax({ paused: true, onComplete: onComplete });

                //The forward index and the lastUsed index within to
                let fIndex = 0, lastMorphIndex = tt.length;

                //Loop for every object to morph to
                for (let i = 0; i < tt.length; ++i) {
                    //If there is an existing path which can be morphed
                    if (fIndex < ffMax) {                        
                        const t = tt[i]; //Get the path we are morphing to
                        //If we won't use them then skip them.
                        if (t.style && t.style.display === 'none') {
                            //Decrease the morph index
                            --lastMorphIndex;
                            continue;
                        }
                        //Get the path we are converting and move the index
                        const p = f[fIndex++],
                            //Compute the style
                            style = context.getComputedStyle(t),
                            //Convert both to paths
                            c = MorphSVGPlugin.convertToPath(t),
                            d = MorphSVGPlugin.convertToPath(p);
                        //needed because we might have transformed the path previously...
                        //timeline.set(d, { clearProps: 'transform display' });
                        //timeline.set(d, { attr: { trasform: t.style.transform } });
                        timeline.to(d, 1, {
                            morphSVG: c[0],                            
                            autoAlpha: true,
                            scale: 1,
                            opacity: 1,//getOpacityOf(t),                            
                            //opacity: t.getAttribute('opacity') || t.style.opacity || style.opacity,
                            fill: getFillOf(t) || style.fill,
                            ease: Power2.easeInOut
                        }, 0);

                        //if (style.stroke && style.strokeWidth) {
                        //    timeline.to(d, 1, {
                        //        drawSVG : true,
                        //        ease: Expo.easeInOut
                        //    }, 1);
                        //}

                    } else {
                        const t = tt[i];
                        if (t.style && t.style.display === 'none') {
                            //t.parentElement.removeChild(t);
                            --lastMorphIndex;
                            continue;
                        }
                        const style = context.getComputedStyle(t), np = MorphSVGPlugin.convertToPath(t);
                        //from.appendChild(np[0]);                        
                        //timeline.fromTo(np, 1, { scale: 0, opacity: 1, autoAlpha: true }, {
                        //    scale: 1,
                        //    opacity: 1,
                        //    autoAlpha: true,
                        //    attr: {
                        //        fill: getFillOf(t) || style.fill,
                        //    },
                        //    ease: Power2.easeInOut
                        //}, 0);
                        const emptyPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
                        emptyPath.setAttribute('d', 'm0,0v0');
                        from.appendChild(emptyPath);
                        timeline.to(emptyPath, 1, {
                            morphSVG: np[0],
                            autoAlpha: true,
                            scale: 1,
                            opacity: 1,//getOpacityOf(t),                            
                            fill: getFillOf(t) || style.fill,
                            ease: Power2.easeInOut
                        }, 0);

                        //if (style.stroke && style.strokeWidth) {
                        //    timeline.to(np, 1, {
                        //        drawSVG: true,
                        //        ease: Expo.easeInOut
                        //    }, 1);
                        //}
                    }
                }    

                //Find any unsed paths and scale them out.
                for (let i = lastMorphIndex; i < ffMax; ++i) {
                    const ff = f[i];
                    timeline.to(ff, 1, {
                        scale: 0, opacity: 0, ease: Power2.easeInOut, onComplete: function () {
                            ff.parentElement.removeChild(ff);
                        }
                    }, 0);
                }

                return timeline;
            };

 

You can see where I am having some difficultly with the `needed because we might have transformed the path previously...` comment. I also noticed that getBoundingClientRect seems to provide a good value even when transforms are applied but the coordinate systems / units still have to be converted...

 

This is common in my current scenario because the graphics are [much] larger than then the graphic they are being inserted into, as a result I must first scale and position the graphics when inserting them.


One can definitely improve that function in some way to allow for control at each stage of the morph, custom tweens and also the transforming of coordinates as required and when I get further along in my project I can revisit the function as well as the required `getFillOf` function to make it more general and return the style(s) associated for use in the morphing function. 

 

//Todo, modify this function to also return fill modified by opacity if needed or have a function which can compute that       
            function getFillOf(w) {
                //I would just use getComputedStyle but it doesn't always seem to return the correct value on my graphics especially when more than 1 class is used...
                //Todo, return whole style so it's opacity can be used
                //Todo, support multiple styles...
                let r = w.getAttribute('fill') || w.style.fill;
                if (r) return r;
                let className = w.className.baseVal;
                if (className && className.length && className !== 'tmp') r = CSSRulePlugin.getRule('.' + className).fill;
                if (r) return r;
                let p = w.parentElement;
                while (p) {
                    r = p.getAttribute('fill') || p.style.fill;
                    if (r) return r;
                    className = p.className.baseVal;
                    if (className && className.length && className !== 'tmp') r = CSSRulePlugin.getRule('.' + className).fill;
                    if(r) return r;
                    p = p.parentElement;
                }
                //When there is no fill return transparent or background color et
            };


That function is used because of some color difficulties I was noticing where fill and opacity were also needing to be set on some paths of the graphic because they were different from source and target. I noted my struggles in the code and made use of the CSSRulePlugin however I imagine that getComputedStyle would be able to accurately determine the fill and opacity especially when there are multiple classes but alas I get values that don't make sense like:
`rbga(0,0,0,0)` or `rbg(0,0,0)` for fill when the fill is easily found on the attribute or the style as found in the function.... I was thinking that the ColorProps plugin might be able to also compute the values I needed with respect to opacity since it seemed like a common task.

 

If you test it out then be sure to note how `getComputedStyle.fill` and `getFillOf` differ for an unknown reason.

 

That doesn't seem to be a problem with your code however I thought you would find it interesting especially since you work around browser issues and possibly others might find it useful for the same type of technique.

 

The last tid bit is that removal ceremony is so common that possibly there could be an options 'remove' or 'destroy' which will ensure that that the element is removed after completion, it would save having to create and or bind a function for such trivial things.

 

Let me know what you think and thank you again for your kind words!

Link to comment
Share on other sites

1 hour ago, Julius Friedman said:

The only other feedback it seems I would like to provide is that when using the `MorphSVGPlugin.convertToPath` function, it would be helpful if one would take into account transforms on the source and target path, possibly with a custom function if someone desire or even more options but the main importance seems to be coordinate origin related `transform` attribute on the object being converted.

 

Hm, would you mind providing a reduced test case in codepen that demonstrates a scenario where this would be useful? The tricky thing here is that transforms could be applied on the element and/or any of its ancestor elements, and they all stack up, so it wouldn't be as simple as "factor in the transforms". It would need a way of knowing how far up the chain you're asking it to go, you know? 

 

Again, I think it'd help me a lot to se a very simple reduced test case that shows a practical example if you don't mind. Same goes for the fill issue. The convertToPath() function is intended to just convert the coordinates and attributes over which, in most cases at least, would ensure styling also gets applied accordingly but I'm sure there are some edge cases (like CSS with selectors like "circle {...}"), but hopefully that's a pretty easy CSS fix. Once I see a demo in codepen, perhaps that'll provide additional understanding for me. 

 

Thanks!

Link to comment
Share on other sites

I can definitely do that although I would also have to hijack set as I described previously so please don't get caught off guard by that.

Check out 

See the Pen oKaNPj by juliusfriedman (@juliusfriedman) on CodePen


 

In that example I haven't even touched scale or position of the source and yet the morphed locations are off.

 

If you remove the transform on the group in which those paths are contained then the result looks like it should.


Scroll down to review the original for reference.

Please let me know if that helps!
 

I also added a graphic which exhibited the opacity and color issue, its included in the pen under example_3 (the coins)

You can see there that when getFillOfT is called on an element which has the classes used e.g. 'st0' or otherwise the fill doesn't get calculated with respect to the opacity, one would have to edit the graphic or calculate the resulting color from the opacity used in the styles.... 

Link to comment
Share on other sites

Yikes! Would you possibly have a reduced test case that doesn't have almost 1,000 lines of SVG? Maybe a simple Rectangle? :) Sorry, I've just got a VERY full plate right now and don't have much bandwidth to try to pull apart all the variables, sift through mountains of SVG data, etc.

Link to comment
Share on other sites

2 minutes ago, GreenSock said:

Yikes! Would you possibly have a reduced test case that doesn't have almost 1,000 lines of SVG? Maybe a simple Rectangle? :) Sorry, I've just got a VERY full plate right now and don't have much bandwidth to try to pull apart all the variables, sift through mountains of SVG data, etc.

I can try my best, it seems the issue would be present if I just took any path and then applied a transform via the attribute and then subsequently used that then it should show the problem manifest however I have to write that test case to show it.

I will see if I can make another pen or modify that one to just have the data that counts and keep you updated.

Same thing with the fill issue.

 

Didn't mean to bog you down further, just wanted to try and show you what I was talking about :)

  • Like 1
Link to comment
Share on other sites

Hows that @ 

See the Pen oKaNPj by juliusfriedman (@juliusfriedman) on CodePen



I kept the other graphics for when you had more time but I added two simple graphics at the top which morph to each other (a circle to a square)

 

Both have matrix transforms which are similar but in most cases I would image the locations to not match and make the issue more noticeable.

 

(had to add a scale to get it to manifest...)

 

Let me know if you need something different.

I was also able to update the logic in the morphFromTo function to remove the matrix which was causing the issue and as a result the morph looks as it's supposed to in the end now.

 

The logic I needed was:

 

//Get all groups to remove their transforms..
                const adjust = from.querySelectorAll('g');
                for (let i = 0; i < adjust.length; ++i) {
                    const x = adjust[i], tr = x.getAttribute('transform');
                    if (tr && tr.startsWith('matrix')) x.removeAttribute('transform');
                }

And should probably be modified to translate the coordinates but for my purposes it just works to remove them for now.

 

Hopefully others find this logic useful and you can better see what I mean now.

 

I also added more morphs to the CodePen so you can see the fill issue as well as the positions issue.

 

The position issue is mostly resolved expect where it's left right now as can be seen on the Circle and Square as well as the coins which were morphed to people, somehow I get lucky in that the center position of the head ends up where the transformed position of the right most body should be but they are off in some morphs due to the matrix.

 

Let me know if I can do anything else!

 

About to head to sleep but I wanted to give you another way to visualize that the problem...

If you fork my pen and change it to do this you will see even more the fill issue with the coins as well as the location issue, it seems that when scale is applied that calculations are made to the points which can be thrown off if the transforms on the element or it's group etc are not taken into account.

I believe the advice is to remove transforms from graphics before you do stuff like this but just encase someone hit this early on like I did (with un-optimized stock graphics)  then perhaps this post may come in useful.

 

//Morph the square to a circle, reverse the simpleExample
const firstExample = morphFromTo(document.getElementById('example_0'), document.getElementById('example_a'), simpleExample.reverse).play();

//Morph the people into other people, reverse the firstExample
const nextExample = morphFromTo(document.getElementById('example_1'), document.getElementById('example_2'), firstExample.reverse).play();

//Morph the coins into the people
const lastExample = morphFromTo(document.getElementById('example_3'), document.getElementById('example_1'), function(){
//Reverse the morph
lastExample.reverse();
//When that morph is completed then start another morph  
lastExample.vars.onComplete = function(){ 
//Start another morph
morphFromTo(document.getElementById('example_2'), document.getElementById('example_3')).play();
}
}).play();

 

Regards!

Link to comment
Share on other sites

Hm, that demo still seems to have almost 1000 lines of SVG and 200 lines of JS, with custom hacks of MorphSVGPlugin. I was really hoping for a reduced test case like: 

See the Pen mNzPLY?editors=1010 by GreenSock (@GreenSock) on CodePen

 

Can you fork that and do the minimum to recreate the issue you're referencing please? That'd be super helpful. Or if you've already resolved your issue and you don't think there's anything that needs resolving in MorphSVGPlugin, no need to press further. I just want to make sure we address any issues with GreenSock products. I'll definitely implement that fix regarding the extra "M0,0" at the end of your paths in the next release. 

 

Happy tweening!

  • Like 1
Link to comment
Share on other sites

Here you go

See the Pen jgeBGJ by juliusfriedman (@juliusfriedman) on CodePen



I think that should be equivalent although the example is a bit contrived, its only really noticeable at the end when you morph back.

 

I didn't think the morphSVG Hack was exactly a hack as it rectified the issue and reduced the number of errors in the console completely after use but non the less, I digress...

 

For complete recognition of the issue I am attempting to describe I think we might need a SVG with 2 shapes e.g. a rect and a circle which are in different groups.

 

If the morph is applied to a shape within the group then the group transform is not taken into account during the morph and it's position would not be with respect to the group, someone would have to manually position the morphed path either before or another the morphing starts.

 

Hence why I was using scale to manifest the issue in these examples when in reality it might not be needed at all.

 

If that is known and expected than it's fine, one would just have to determine their graphics don't have needless transforms and do the math to ensure their path coordinates have been transformed respective to the group they reside in. (which is why I call my example contrived)

 

This was more of a hey, it would be cool if GSAP either could do this for us automatically (via option) or allow us to customize further with a function, it seems trivial to allow an option for transformDepth (to determine how far up) and a an optional function which would allow the resulting transform to be modified further.

 

As I have already shown in my examples one can simply remove the group matrix or even take it a step further and apply those transforms to the paths or integrate those transforms into the point data.

 

You guys already do a lot of work to provide options like xPercent and yPercent as well as support for transformOrigin with percentage based values so I just thought this would extend to that naturally and would otherwise be useful...

 

As an example of how it could be useful given a Reflection scenario (like a mirror), lets say I have a an object and I want to show the reflection of the object but reversed and at a different scale and angle.

 

Currently I would have to duplicate the object, transform it, position it and then create 2 morphs, one for the object and one for it's reflection, (Unless I did some use trickery, I suppose I could only morph the one and the reflection would then also morph however it would morph in exactly the same way)

 

With this methodology I would only need to create the 1 morph and then apply it with the transformations of the 'reflection' to get both to morph in the same way but at angles respective to each if that makes sense.

 

E.g. the main object is not rotated or scaled much but the reflection object is half opaque and rotated arbitrarily and at a different angle.

 

There are other uses but they are even more complex and mostly benefit from re-using the morph and tweens rather than creating new ones.

 

The color issue I also have worked around but thought it would also be a good enhancement since GSAP also already supports relative tweening with HSL, e.g. "hsl(+=0, +=0%, -=30%)", e.g. if you can say withOpacity(1, style) or better yet computeStyle(withClasses) to get a computed style of only the classes you want / will use. 

 

Perhaps when I get more time I can work on a plug-in which offers those options unless you get around to it first.

 

Let me know if that makes sense and thank you again for your assistance!

 

Please do let me know if I can do anything else!

 

Regards

 

  • Like 1
Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...