Skip to content

Conversation

Kevinpgalligan
Copy link
Contributor

@Kevinpgalligan Kevinpgalligan commented Apr 16, 2024

Attempting to fix the polyline bug, see: #53

Visual artifacts were appearing for polylines with sharp corners. This is a known problem when using the miter method of joining 2 lines. The join points are calculated by extending the left and right sides of the lines until they intersect. If there's a small angle between the lines, however, the intersection points go to infinity and beyond (they're almost parallel), creating visual artifacts.

The solution implemented here is to switch to a different join method when there's a small angle between the lines. I arbitrarily chose a cutoff of 20 degrees to switch from meter join to bevel join. I find that the transition from miter to bevel is too jarring if the cutoff angle is smaller than that, but I'm open to changing it. I also added a cutoff of 2 degrees to switch from the bevel method to a "simple" join, because bevel also gets buggy when the lines are parallel-ish.

In the future, we could make the cutoff angles configurable. It would also be possible to allow the user the choose the join they want, and add more joins like rounded.

Will share some of the experimenting I've done to confirm the fix.

@Kevinpgalligan
Copy link
Contributor Author

Kevinpgalligan commented Apr 16, 2024

Testing.

(defsketch polytest
            ((width 520)
             (height 110))
          (with-pen (make-pen :stroke +white+ :weight 2)
            (loop for i from 0
                  for gap in '(0 5 10 25 50)
                  do (polyline (+ 10 (* i 100)) 10
                               (+ 10 (* i 100) (/ gap 2)) 100
                               (+ 10 (* i 100) gap) 10))))

Miter join is used on the right, bevel is used to the left of that, and the "simple" join is used (at least) on the far left.

polyline-debug

Here's what it was like before. Note that for small enough angles, the line width isn't consistent and the edge is so pointy that it goes off the screen.

old-polyline-debug

@kchanqvq
Copy link

This does indeed work and make polyline much more useful, I really hope this can get merged! 🙏
Meanwhile, I found that the 20 deg threshold to bezel still results in some flicker/jiggling when doing animation. I think that's because the "extruding" length of miter and bezel differs quite a bit at 20 deg. Is it possible to switch to a larger threshold to fix the issue? What about 90 deg?

@Kevinpgalligan
Copy link
Contributor Author

The ideal solution would be to make everything configurable, since it's possible that users will have different preferences.

But yes, it sounds like the threshold I picked may not be ideal! Happy to change it when I get time to work on Sketch again.

@kchanqvq
Copy link

But yes, it sounds like the threshold I picked may not be ideal! Happy to change it when I get time to work on Sketch again.

I looked at how other vector graphics (e.g. nanovg) does it, and I now think the 20deg threshold is reasonable enough (whose purpose is solely to avoid graphic glitch for miter join). Instead, there should be another line-join option to choose between :bevel and :miter. Maybe this options should live inside pen. How does that sound?

@Kevinpgalligan
Copy link
Contributor Author

Kevinpgalligan commented Apr 29, 2025

That makes sense to me! I'd love to implement a :round option, too.

@Kevinpgalligan
Copy link
Contributor Author

Kevinpgalligan commented Jun 2, 2025

Finally getting around to this! It does look bad when animated...

(defsketch polyline-test ((deg 0))
    (translate (floor width 2) (floor height 2))
    (with-pen (:stroke +white+ :weight 10)
      (polyline -100 0 0 0 (* 100 (cos deg)) (* 100 (sin deg))))
    (incf deg 0.1))
line-join.mp4

First I'll get the default behaviour to look less crap, then extend the pen API to allow specifying the join.

(Edit: the flickering is only noticeable when the stroke weight is chonky).

@Kevinpgalligan
Copy link
Contributor Author

Kevinpgalligan commented Jun 2, 2025

Some useful code for debugging, press space to nudge the line:

(defsketch polyline-test ((deg 3))
  (translate (floor width 2) (floor height 2))
  (let ((coords (list -100 0 0 0 (* 150 (cos deg)) (* 150 (sin deg)))))
    (with-pen (:stroke +white+ :weight 10)
      (apply #'polyline coords))
    (let ((lines (edges (group coords) nil)))
      (multiple-value-bind (lefts rights)
          (join-lines lines 5 -5)
        (format t "=========~%")
        (format t "LEFTS: ~a~%" lefts)
        (format t "RIGHTS: ~a~%" rights)
        (let ((lines (sketch::edges (sketch::group coords) nil)))
          (format t "ANGLE: ~a~%" (sketch::interior-angle-between-lines
                                   (first lines) (second lines))))
        (with-pen (:stroke +red+)
          (loop for (x y) in lefts
                for i from 0
                do (circle x y 5)
                do (with-font (make-font :color +red+)
                     (text (format nil "~a" i) (- x 5) (- y 5)))))
        (with-pen (:stroke +blue+)
          (loop for (x y) in rights
                for i from 0
                do (circle x y 5)
                do (with-font (make-font :color +blue+)
                     (text (format nil "~a" i) (- x 5) (- y 5))))))))
  (incf deg 0.001)
  (stop-loop))

(defmethod on-key ((inst polyline-test) key state)
    (when (and (eq key :space) (eq state :up))
      (start-loop)))

@kchanqvq
Copy link

kchanqvq commented Jun 3, 2025

First I'll get the default behaviour to look less crap

I think this is ok if we introduce line-join option. If user use miter, it's expected they don't animated through small angles. But maybe the default line join should be bevel instead.

The artifact protruding to the left of the screen in your video (opposite side of the join) does worry me though...

@Kevinpgalligan
Copy link
Contributor Author

Yes, gotta fix the artifact! I've updated the debug sketch above so that it shows the angle between the lines. It seems that both bevel join and the "simple" join are buggy. My suspicion is that one of the problems is that, when a join has an angle of <90 degrees, the right and left sides switch. So the left side of the first line should be joined up with the right side of the second line. Currently, I think we're joining the left side with the left side.

@Kevinpgalligan
Copy link
Contributor Author

Kevinpgalligan commented Jun 4, 2025

I think I've identified two separate bugs, one in bevel join and one in the "simple" join. It's a bit hard to explain, and I'm not sure what the solutions are just yet.

  1. "Simple" join: from my understanding, in the triangle strip mode of drawing, OpenGL takes a list of vertices and iterates over the triples in the list, so (p1 p2 p3 p4) results in the triangles (p1 p2 p3) and (p2 p3 p4). In polyline, we mix together the vertices on the left and right sides of the "thickened" line so that the entire line gets filled in by triangles. The problem is that, when the line doubles back on itself, the left and right sides get flipped. This is fine for bevel join & miter join because they very carefully construct vertices on the left and right side so that the polyline gets filled by triangles. The "simple" join jumbles up points on the left & right side, resulting in the line not getting completely filled by triangles. Only noticed this now because it was hard to spot for thin lines.

  2. Bevel join: when two connected line segments take a sharp turn to the right, bevel join intersects the right sides of both "thickened" line segments to find the vertices on the right side of the polyline, while it "bevels" the left side so that it doesn't go off into infinity (which is what happens to miter join for sharp angles). The opposite is true for a sharp turn to the left. I THINK the problem is that, when the polyline is thick and the turn angle is sharp enough, the intersection point can go way outside the line segments themselves, hence the artifact. Try drawing the line segments as two thick rectangles that heavily overlap each other, then it's easy to see how the intersection point can be outside the rectangles themselves.

Anyway, these bugs shouldn't prevent the user being able to override the join type, so maybe I'll add that relatively simple feature first.

@Kevinpgalligan
Copy link
Contributor Author

Kevinpgalligan commented Jun 4, 2025

Done:

Testing appreciated! Now just to fix the buggy line joins...

@Kevinpgalligan
Copy link
Contributor Author

Example of the tricky bevel case, where the thickened line segments are overlapping.

join

I confirmed that the HTML5 canvas API does handle this case gracefully. It LOOKS like we could just draw the two line segments separately and the result would be indistinguishable.

<!DOCTYPE html>  
       
    <html lang="en" xmlns="http://www.w3.org/1999/xhtml">  
    <head>  
        <meta charset="utf-8" />  
        <title>LineJoin Example</title>  
        <script type="text/javascript">  
            function draw() {  
                var canvas = document.getElementById("MyCanvas");  
                if (canvas.getContext) {  
                    // Draw some lines with different line joins.  
                    var ctx = canvas.getContext("2d");  
                    var lStart = 50;  
                    var lEnd = 200;  
                    var yStart = 50;  
                    ctx.beginPath();  
                    ctx.lineWidth = "50";  
                    // Use a bevel corner.  
                    ctx.lineJoin = "bevel";  
                    ctx.moveTo(lStart, yStart);  
                    ctx.lineTo(lEnd, yStart);  
                    ctx.lineTo(lStart-40, yStart + 5);  
                    ctx.stroke();  
                    // Use a round corner.              
                    ctx.beginPath();  
                    ctx.lineJoin = "round";  
                    ctx.moveTo(lStart + 200, yStart);  
                    ctx.lineTo(lEnd + 200, yStart);  
                    ctx.lineTo(lEnd + 200, yStart + 200);  
                    ctx.stroke();  
                    // Use a miter.       
                    ctx.beginPath();  
                    ctx.lineJoin = "miter";  
                    ctx.moveTo(lStart + 400, yStart);  
                    ctx.lineTo(lEnd + 400, yStart);  
                    ctx.lineTo(lEnd + 400, yStart + 200);  
                    ctx.stroke();  
                    // Annotate each corner.        
                    addText("bevel", lStart + 50, yStart + 50, "blue");  
                    addText("round", lStart + 250, yStart + 50, "blue");  
                    addText("miter", lStart + 450, yStart + 50, "blue");  
                }  
                function addText(text, x, y, color) {  
                    ctx.save(); // Save state of lines and joins  
                    ctx.font = "400 16px/2 Unknown Font, sans-serif";  
                    ctx.fillStyle = color;  
                    ctx.fillText(text, x, y);  
                    ctx.restore(); // restore state of lines and joins  
                }  
            }  
        </script>  
    </head>  
    <body onload="draw();">  
        <canvas id="MyCanvas" width="800" height="300"></canvas>  
    </body>  
    </html> 

Also tracked down what might be the place where line joins are handled in the Cairo graphics framework, although I don't have a clue what the code is doing:

https://github.com/msteinert/cairo/blob/60cfc867fc0af81585e5b6d29f9f1abfd6cccb69/src/cairo-path-stroke-traps.c#L219

@Kevinpgalligan
Copy link
Contributor Author

Kevinpgalligan commented Jun 5, 2025

FINALLY got the bugs ironed out, as far as I can tell. Commit here: Kevinpgalligan@734bba6

Here's a sample sketch showing that bevel join appears to be free of artifacts/flicker. The:line-join parameter can also be :dynamic or :miter.

(defsketch polyline-test ((deg 3))
  (translate (floor width 2) (floor height 2))
  (let ((coords (list -100d0 0d0 0d0 0d0 (* 150d0 (cos deg)) (* 150d0 (sin deg)))))
    (with-pen (:stroke +white+ :weight 20 :line-join :bevel)
      (apply #'polyline coords)))
  (incf deg 0.01))
polyline.mp4

This was a major pain in the ass and I'm not feeling motivated to work on it any further right now, but possible future additions would be:

  • A pen property to set the angle for switching from miter->bevel in the :dynamic join type. Maybe call it :miter-angle.
  • A:round join type.
  • A :none join type? The equivalent of individually calling line on each pair of segments. I think this is available in other drawing APIs I've seen. (Kevinpgalligan@0e0032f)

Also, regarding this pull request, it will need to be updated with the commits I've linked from my dev branch. I'm not sure if it'll be a clean merge or if there'll be issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants