paperjs / paper.js

The Swiss Army Knife of Vector Graphics Scripting – Scriptographer ported to JavaScript and the browser, using HTML5 Canvas. Created by @lehni & @puckey
http://paperjs.org
Other
14.5k stars 1.23k forks source link

path.unite() fails to resolve intersections #899

Closed iconexperience closed 8 years ago

iconexperience commented 8 years ago

In this example calling unite() on a path fails to resolve the self intersections of the path. The result is a path that still has self intersections.

var path = new Path({segments:[[349.31714952528665, 176.97192499841802, 2.0667935742653185, -10.850610233997372, 0, 0], [308.23418424120047, 394.39737504104323, 0, 0, -0.3852007263769224, 2.0386172397749647], [273.60284923271973, 415.7138856106701, 9.194683617471242, 1.7373535056759692, 0, 0], [340.6967045975081, 152.3115389894092, -4.006231967752797, -3.6687868382265094, 5.694026309937783, 5.214418174126138], [349.72123243229134, 170.85618880187394, -0.36146393999291604, -6.612268801346318, 0.1110858449889065, 2.0320961120115726], [349.31714952528654, 176.97192499841861, 0.3852007263769224, -2.038617239775249, -2.0667935742656027, 10.850610233997315], [333.4126425432937, 153.58289639999975, -10.850610233997315, -2.0667935742653754, 10.850610233997372, 2.0667935742653754]], closed:true});
var path2 = path.unite();
console.log(path2.className);
path2.translate(200, 0);
path.strokeColor = "green";
path2.strokeColor = "red";

image

lehni commented 8 years ago

Uh oh, here we go again : )

Looks like it does find all the intersections... Next I'm looking at resolveCrossings()

lehni commented 8 years ago

Ok, so this intersections get wrongly filtered out as not a crossing:

screen shot 2016-01-10 at 16 18 18

The problem is in isTouching(): https://github.com/paperjs/paper.js/blob/develop/src/path/CurveLocation.js#L384

Or better, the way it gets used in isCrossing() currently to detect the case where two touchings curves are not an intersection. This is of course wrong...: https://github.com/paperjs/paper.js/blob/develop/src/path/CurveLocation.js#L421

lehni commented 8 years ago

But even when I I deactivate this check, (which doesn't seem to add any issues btw), it still gets wrongly flagged as not a crossing by the code further down. The reason being all the tangents pointing in the same direction. This is where this comment here becomes important:

https://github.com/paperjs/paper.js/blob/develop/src/path/CurveLocation.js#L476

// NOTE: VectorBoolean has code that slowly shifts these points inwards
// until the resulting tangents are not ambiguous. Do we need this too?

The question then also is: Is there a better way to do this? E.g. use a solver to find the first location where the slope changes enough?

iconexperience commented 8 years ago

Isn't this exactly what Cary Clark talks about here?

https://www.youtube.com/watch?v=OmfliNQsk88&feature=youtu.be&t=1372

lehni commented 8 years ago

Yes it appears to be. And it's quite interesting... It sounds like he's calculating the winding contributions differently to what we're doing, based on just the found intersections and the curves going through it, while we're calling getWinding(). I guess his approach is faster.

lehni commented 8 years ago

Oh but after he discussed about propagation, which is what we are doing. I should listen to this properly again.

iconexperience commented 8 years ago

Unfortunately his solution for ordering the curves is for quadratic curves, and he says at the end: "There may be a better one [solution]". So we cannot use this.

lehni commented 8 years ago

Yeah we shouldn't switch now. = ) I also don't feel that our approach is too slow, so that's fine.

lehni commented 8 years ago

But if the two things would happen together (finding intersections and calculating winding contributions) we could probably use the winding numbers to figure out the answer. I think what @kuribas is doing here is smarter in that respect and would help with that: https://github.com/paperjs/paper.js/issues/761#issuecomment-137562021

But we're too far down our current road probably.

iconexperience commented 8 years ago

Uuuh, I do not want to think about this now :) Just for your information, I found this issue only because I made a mistake when playing with offsetting, which accidentially caused a loop. So this edge case should have never happened (but it did).

lehni commented 8 years ago

hehe, as they always do!

So here is the code mentioned above: https://github.com/adamwulf/vectorboolean/blob/master/VectorBoolean/FBContourOverlap.m#L287

I think we can use the isTouching() check to figure out initially if we need to do this at all or not, nothing else. Then if we need to find non-ambiguous tangents, I wonder if there's a better approach than simply shifting the offsets on the curves (in curve length) away from the intersection point until we find a unambiguous pair of tangents? Do you have a suggestions here?

lehni commented 8 years ago

Ha, interesting commit message here. It looks like we're not the only ones out there : ) https://github.com/adamwulf/vectorboolean/commit/582e65dab9e40066a39daaaf743426906fba7f80

iconexperience commented 8 years ago

I am not sure if I fully understand this. So far I tried to stay away from that part of the code. But is the use of getCurvatureAt() of any help here?

Here is an example how curvature could help

lehni commented 8 years ago

Well, it's simple: In this case, both involved curves have collinear tangents at the intersection. The intersection happens to be in existing segments, and the segments are smooth, meaning their in- & out-handles are collinear, too.

So I was wrong about isTouching() being involved, since that only gets called when the intersection is within curves, not in segments at their beginnings / ends.

isCrossing() then gets all the involved tangents and their angles, and compares these to figure out if it is a crossing or not. But since in this example, all the tangents are identical, they are ambiguous.

The only solution that I can think of right now is the same as in VectorBoolean: Slowly moving away until we find unambiguous tangents, meaning they are not collinear anymore, the curves unveil in which direction they are turning after they touch in the intersection. The offset could be a fraction of the total curve length of the shorter curve perhaps, rather than a fixed amount as in their code.

I was wondering if there could be a better solution, at the same time, this happens so rarely that I guess I shouldn't care?

iconexperience commented 8 years ago

I initially forgot the example in my previous post. Wouldn't this be better than slowly moving away from the point?

lehni commented 8 years ago

Oh wow, yes! Let me look into this a bit! : )

lehni commented 8 years ago

Hmm not really... There is no distinction between this sketch and that sketch in terms of curvature values.

iconexperience commented 8 years ago

I wouldn't expect there to be a difference, because in your sketches you only look at the second curve (the one to the right), which is the same.

Look at this example. To be honest, it surprises me a bit that the curvature is different at the same point, depending on which curve you look at.

lehni commented 8 years ago

Yes, you're of course right! I shall give this a go, good thinking.

And I guess that's just how curvature works?

lehni commented 8 years ago

But what about this situation?

They both change signs, and they are actually crossing. Does the amount of curvature then decide? Can we be certain?

iconexperience commented 8 years ago

Doesn't the curvature indicate how fast the curve is crawling away from the tangent and to which direction? I think it should work in your example, but I cannot give you the correct algorithm at the moment.

lehni commented 8 years ago

Yea I was thinking the same. It's one for another day, with a fresher head : ) But definitely solvable.

iconexperience commented 8 years ago

As a first step I have created This little sketch that shows the relation of the curvature at t=0 and the direction in which two curves with identical tangent separate.

The curve with the larger (signed) curvature will always curl away to the right, from the curve with the smaller curvature value. In the example below, the green curve has the larger curvature value.

image

iconexperience commented 8 years ago

And just as a reminder, the angle in getAngle() is calculated clockwise:

image

lehni commented 8 years ago

Yes, because: radius = 1 / curvature for the circle that can be fit tightly into the location that the curvature was calculated for: updated sketch

lehni commented 8 years ago

But you're aware that you're comparing signed curvatures, yes?

iconexperience commented 8 years ago

You are correct, the console output is wrong. It should read "red/green curve is to the right". I updated the sketch in my comment above.

iconexperience commented 8 years ago

So if you have three curves c0, c1 and c2 starting at the same point, then c0 will be between c1 and c2 if

(angle0 > angle1 || (angle0 == angle1 && curvature0 > curvature1)) && (angle0 < angle2 || (angle0 == angle2 && curvature0 < curvature2))

where angle is the angle at t=0 and curvature is the curvature at t=0. For comparing angles, negative angles have to be taken into account (as it is done in isInRange().

Do you think this will work?

lehni commented 8 years ago

I don't think that will cover it yet: We have four curves in this case, two of one path and two of the other, both pairs connected in one segment, the location of the intersection.

iconexperience commented 8 years ago

I have modified the relevant part in CurveIntersection, but this does not improve anything. Probably because the angles are not exactly the same, just very close.

        function isInRange(angle, angleMin, angleMax, curv, curvMin, curvMax) {
            var gtMin = angle > angleMin || (angle === angleMin && curv > curvMin),
                ltMax = angle < angleMax || (angle === angleMax && curv < curvMax);
            return angleMin < angleMax
                ? gtMin && ltMax
                : gtMin && angle <= 180 || angle >= -180 && ltMax;
        }

        var v2 = c2.getTangentAt(t1Inside ? t1 : tMin, true),
            v1 = (t1Inside ? v2 : c1.getTangentAt(tMax, true)).negate(),
            v4 = c4.getTangentAt(t2Inside ? t2 : tMin, true),
            v3 = (t2Inside ? v4 : c3.getTangentAt(tMax, true)).negate(),
            a1 = v1.getAngle(),
            a2 = v2.getAngle(),
            a3 = v3.getAngle(),
            a4 = v4.getAngle(),
            r2 = c2.getCurvatureAt(t1Inside ? t1 : tMin, true),
            r1 = t1Inside ? -r2 : -c1.getCurvatureAt(tMax, true),
            r4 = c4.getCurvatureAt(t2Inside ? t2 : tMin, true),
            r3 = t2Inside ? -r4 : -c3.getCurvatureAt(tMax, true);
        console.log(a1 + ", " + a2 + ", " + a3 + ", " + a4);
        return !!(t1Inside
                ? (isInRange(a1, a3, a4, r1, r3, r4) ^ isInRange(a2, a3, a4, r2, r3, r4)) &&
                  (isInRange(a1, a4, a3, r1, r4, r3) ^ isInRange(a2, a4, a3, r2, r4, r3))
                : (isInRange(a3, a1, a2, r3, r1, r2) ^ isInRange(a4, a1, a2, r4, r1, r2)) &&
                  (isInRange(a3, a2, a1, r3, r2, r1) ^ isInRange(a4, a2, a1, r4, r2, r1)));

Well, that would have been too easy.

lehni commented 8 years ago

That's a great start though! Shall I take it from here?

iconexperience commented 8 years ago

If you want, to, you are welcome. I am a bit stuck at the moment.

lehni commented 8 years ago

Yeah the angles aren't quite the same, even when I go from tmin / tmax to 0 / 1 (which we now should, as this was a sort of cheap look-ahead before)... This is strange...

iconexperience commented 8 years ago

When I zoom in (using Firefox, Chrome will not do in this case), I get the feeling that there are two intersections, but of course this is difficult to see. I was thinking that maybe we do not find the real intersection here, but this is only a wild guess.

iconexperience commented 8 years ago

Here is a simplified sketch that has only the erroneous intersection and allows for zooming in (use Firefox instead of Chrome). Could be helpful for further investigations.

lehni commented 8 years ago

Here the same sketch with drawing of the tangents and pre-zoomed onto the action. It's really hard to see, but if you use the zoom tool button, and then press the space-bar for panning / scrolling, you can go down and up along the tangents and you will see that on either side, the red one is to the right of the blue one, so they indeed do not seem to cross. but they do. What's happening here?

lehni commented 8 years ago

Regarding panning / scrolling, it's a bit buggy right now: If your cursor is blinking in the code editor, you have to click into the console first to loose that focus, then you can pan with the space bar on the canvas. It works exactly like Illustrator then. I shall fix this properly.

lehni commented 8 years ago

Apologies for sending you on a wild goose chase without realizing that this tangents weren't actually collinear to begin with : /

iconexperience commented 8 years ago

No problem, was still interesting and maybe its useful for further development.

Thanks for the hint on panning, I was looking for that (btw, zoomin out does not seem to work often).

If you look at my last sketch, zoom into the intersection and then scroll up, you will see how the curves diverge and then converge again. I have the feeling that there is the real crossing, but we do not find it.

lehni commented 8 years ago

Can you post a screenshot of what I shall be looking for? And yes, I am aware that things are a bit broken on sketch. One of too many construction sites, and I'e learned to work around it without understanding what causes it...

lehni commented 8 years ago

BTW, this:

new Path({segments: [[point.x, point.y], [point.x, point.y]], fullySelected: true});

Can become that:

new Path({segments: [point], fullySelected: true});

And it still does what you expect it to : )

iconexperience commented 8 years ago

Here are some screenshots, all with the same zoom factor. This is the found intersection:

image

If you move up, the curves look like this:

image

Moving further up, the curves look like this again:

image

lehni commented 8 years ago

When you're on the right spot and zoom level, could you open the development console, and then enter this into the prompt, press enter, and paste the results here:

paper.view._matrix.getValues()

From that I can then recreated the zoom, e.g.:

view._matrix = new Matrix([2489206.111144464, 0, 0, 2489206.111144464, -869521971.2587767, -440519283.81372666]);
iconexperience commented 8 years ago

This may be a really stupid question, but how do I open the development console?

lehni commented 8 years ago

Haha! You are on IE / Edge, correct? This hopefully gets you started: https://msdn.microsoft.com/en-us/library/dd565628(v=vs.85).aspx

I think you press F12 to get there? Otherwise, Google should be full of pointers. Just search whatever bowser you are using + JavaScript console or development console / tools.

lehni commented 8 years ago

Here you go (pixelated non-HiDPI Windows 10 screenshots from Parallels running on a HiDPI MacBook):

screen shot 2016-01-12 at 11 15 27

And then:

screen shot 2016-01-12 at 11 15 49

To run it, I had to press the green play button int he lower right corner of the console window.

iconexperience commented 8 years ago

Aehm, no, IE is only if I really need to test something. Usually it's Chrome, but here I am using Firefox. But I found it.

Here we go. I think the real intersection is here (does not work on Chrome).

iconexperience commented 8 years ago

Oh wow, if I set the max recursions in addCurveIntersections() to 25, everything works. :open_mouth:

iconexperience commented 8 years ago

Fantastic test case for fat line clipping!!!

lehni commented 8 years ago

Ah yeah, those zooms are also a great way to find the limits of the canvas renderers. At great zoom levels, strange things start to happen, and it becomes quite clear that we're not the only ones struggling with edge cases. The best is when you then export these as SVGs and try to open them in Illustrator. I usually get a crash right away. But why am I still surprised?