A little arcTo() and geometry help?



  • So, I'm hoping to code a little animated tribute to Bridget Riley, and I'm running across a problem interpolating between two curves with arcTo().

    Here's what I'm trying to do: I want to draw a curve with limits here

    riley_tribute_test_01_2019-11-06 05:44:29.711689.jpg

    and here

    riley_tribute_test_01_2019-11-06 05:44:59.701345.jpg

    with the curve smoothly transitioning from concave to convex; at the midpoint of the transition there would be a straight line between those points of the triangle.

    I've found that I can't just define the control point as an extrapolation between the two extremes, though—if I do that, in the middle I get this:

    riley_tribute_test_01_2019-11-06 05:49:43.095175.jpg

    It seems to my not-very-curve-and-mathematics-savvy brain like both the radius of the arc and the control point need to change in the transition; in the middle, with a straight line, the radius of the curve would be at infinity. Is there someone with a better knowledge of drawing arcs and geometry who can tell me what I have to do here?

    Here's my code:

    import math
    canvas = 500
    nFrames = 60
    h = canvas/2
    side = (2 * h)/sqrt(3)
    c = (side/sqrt(3))/2
    
    from datetime import *
    time = datetime.now()
    
    def drawPt(pos, r=5):
        x, y = pos
        oval(x-r, y-r, r*2, r*2)
    
    def interpolate(pt1, pt2, ratio):
        assert ratio <= 1
        return (pt1[0] + (ratio * (pt2[0] - pt1[0])), pt1[1] + (ratio * (pt2[1] - pt1[1])))
    
    newPage(canvas, canvas)
    fill(1)
    rect(0, 0, canvas, canvas)
    translate(canvas/2, canvas/2)
    fill(None)
    
    start = (-side/2, h/2)
    pivot = (0, h/2 - (c * 3))
    target = (side/2, h/2)
    #control = (side/2, -c/2) #right extreme
    #control = (0, h/2 - c) #left extreme
    control = interpolate((side/2, -c/2), (0, h/2 - c), .5) #straight interpolation, does not work
    radius = side
    
    triangle = BezierPath()
    triangle.moveTo(start)
    triangle.lineTo(pivot)
    triangle.arcTo(control, target, radius)
    triangle.closePath()
    stroke(0, 1, 1)
    drawPath(triangle)
    
    fill(None)
    stroke(1, 0, 0)
    polygon(start, pivot, target)
    stroke(0, 1, 1)
    polygon(pivot, control, target)
    for pt in [pivot, control, target]:
        drawPt(pt)
    stroke(1, 0, 1)
    for pt in triangle.onCurvePoints:
        drawPt(pt, r=3)
    for pt in triangle.offCurvePoints:
        drawPt(pt, r=2)
        
    stroke(None)
    fill(0, .15)
    drawPath(triangle)
    
    saveImage('~/Desktop/riley_tribute_test_01_%s.jpg' %time)
    


  • Okay, I thought I'd figured it out—use curveTo() instead of arcTo()—but now my trig and knowledge of curves is failing me.

    Using arcTo(), with the control point at the outside position, I get the curve I want: a curve defined by the arc of a circle (shown here in red), radius equal to the sides of the triangle, centered on the point opposite the curve and passing through the end points of the curve. With the control point at the other defined extreme, I get the curve concave to the triangle instead of convex.

    riley_tribute_test_01_2019-11-06 09:54:08.784127.jpg

    riley_tribute_test_01_2019-11-06 09:55:54.675663.jpg

    But, as I said in the OP, I can't extrapolate points for arcTo() that would move the curve back and forth between those two positions.

    Using curveTo(), on the other hand, does allow that kind of extrapolation. But here's the problem: if I put the off-curve points at the defined extreme, I don't get the curve I want.

    riley_tribute_test_01_2019-11-06 09:59:57.429015.jpg

    Clearly they need to fall further back towards their respective on-curve points. But where, to get the curve matching the circle? Using trial and error I can approximate the curve with off-curve points .625 of the distance between the defined control point and the on-curve points:

    riley_tribute_test_01_2019-11-06 10:03:48.898945.jpg

    But it's almost certainly not exact, and why should I have any reason to think there's anything trigonometrically significant about the ratio 5/8 here anyway? It seems like there should be a trigonometrically-defined solution for the off-curve points here that I'm missing …



  • And I was right. Looking at the diagram I made suggested to me that the answer probably had something to do with tangents of the relevant angles. Searching for how to define circles with Bezier curves and throwing 'tangent' into search led me to this Stackoverflow thread, in which I learned:

    1 that it is impossible to exactly represent a circle with Bezier curves, and

    2 that the optimal distance of off-curve from on-curve points to define a circle with n segments is r * ((4/3) * tan(pi/(2n))). In my example, the ratio of this distance to the total distance of the on-curve to the defined extreme (for n = 6, the number of 60° arcs in a circle) is 0.6188021535170061, not very far from my guess of .625. The outside extreme/convex state:

    riley_tribute_test_01_2019-11-06 19:22:29.873347.jpg

    and the inside extreme/concave state:

    riley_tribute_test_01_2019-11-06 19:22:54.556404.jpg

    Extrapolating between the curves just means extrapolating from 0 to 1 in line 28 of the code below. As soon as the Riley tribute I can now make is done, I'll post it.

    Thanks for watching this episode of Maurice Solving Problems Aloud.

    import math
    canvas = 500
    nFrames = 60
    h = canvas/2
    side = (2 * h)/sqrt(3)
    c = (side/sqrt(3))/2
    
    from datetime import *
    time = datetime.now()
    
    def drawPt(pos, r=5):
        x, y = pos
        oval(x-r, y-r, r*2, r*2)
    
    newPage(canvas, canvas)
    fill(1)
    rect(0, 0, canvas, canvas)
    translate(canvas/2, canvas/2)
    fill(None)
    
    def interpolate(pt1, pt2, ratio):
        assert ratio <= 1
        return (pt1[0] + (ratio * (pt2[0] - pt1[0])), pt1[1] + (ratio * (pt2[1] - pt1[1])))
    
    start = (-side/2, h/2)
    pivot = (0, h/2 - (c * 3))
    target = (side/2, h/2)
    control = interpolate((side/2, -c/2), (0, h/2 - c), 1) #0 is outside extreme, 1 inside
    radius = side
    # optimal distance to control points is (4/3)*tan(pi/(2n); thank you https://stackoverflow.com/users/16673/suma for answering this question https://stackoverflow.com/questions/1734745/how-to-create-circle-with-b%C3%A9zier-curves
    control_ratio = ((4/3 * tan(pi/12)) * radius) / (2 * c)
    controlP = interpolate(pivot, control, control_ratio)
    controlT = interpolate(target, control, control_ratio)
    
    
    triangle = BezierPath()
    triangle.moveTo(start)
    triangle.lineTo(pivot)
    triangle.curveTo(controlP, controlT, target)
    triangle.closePath()
    stroke(0, 1, 1)
    drawPath(triangle)
    
    fill(None)
    stroke(1, 0, 0)
    polygon(start, pivot, target)
    stroke(0, 1, 1)
    polygon(pivot, control, target)
    for pt in [pivot, control, target]:
        drawPt(pt)
    stroke(1, 0, 1)
    for pt in triangle.onCurvePoints:
        drawPt(pt, r=3)
    for pt in triangle.offCurvePoints:
        drawPt(pt, r=2)
        
    stroke(None)
    fill(0, .15)
    drawPath(triangle)
    
    fill(None)
    stroke(1, 0, 0)
    oval(start[0] - side, start[1] - side, side * 2, side * 2)
    
    saveImage('~/Desktop/riley_tribute_test_01_%s.jpg' %time)
    


  • @MauriceMeilleur Have a look at https://github.com/typemytype/outlinerRoboFontExtension/blob/master/Outliner.roboFontExt/lib/outlinePen.py#L518 which solves more or less the same problem. Esp. the handleLength part should be of interest to you.



  • Partial explainer of what that code does: https://twitter.com/justvanrossum/status/1192324084656476160



  • I solved a outlining problem from scratch, specifically with rounding corners of an outline with a variable radius, that I talked about on this earlier post here on the forum: https://forum.drawbot.com/topic/181/using-arcto-for-variable-radius-corner-rounding

    I wound up using arcTo() to do that (if I get around to it, I'll post the code) and it didn't occur to me that one reason the solution worked was because I wasn't trying to draw anything but a circle-based curve that stayed convex in the same orientation.



  • The finished product, a tribute to Bridget Riley's Rustle 6 (2015).

    riley_tribute_test_01_2019-11-07 07:20:53.494025.gif

    import math
    
    canvasX = 600
    canvasY = 600
    nFrames = 120
    side = canvasX/14
    height = (side * sqrt(3))/2
    center = (side/sqrt(3))/2
    nX = 14
    nY = 15
    offsetX = side
    offsetY = height
    
    from datetime import *
    time = datetime.now()
    
    def interpolate(pt1, pt2, ratio):
        assert ratio <= 1
        return (pt1[0] + (ratio * (pt2[0] - pt1[0])), pt1[1] + (ratio * (pt2[1] - pt1[1])))
        
    def triangle(s, h, c, inout):
        assert inout >= 0 and inout <= 1
        start = (-s/2, h/2)
        pivot = (0, h/2 - (c * 3))
        target = (s/2, h/2)
        control = interpolate((s/2, -c/2), (0, h/2 - c), inout)
        radius = s
        # optimal distance to control points is (4/3)*tan(pi/(2n); thank you https://stackoverflow.com/users/16673/suma for answering this question https://stackoverflow.com/questions/1734745/how-to-create-circle-with-b%C3%A9zier-curves
        control_ratio = ((4/3 * tan(pi/12)) * radius) / (2 * c)
        controlP = interpolate(pivot, control, control_ratio)
        controlT = interpolate(target, control, control_ratio)
        triangle = BezierPath()
        triangle.moveTo(start)
        triangle.lineTo(pivot)
        triangle.curveTo(controlP, controlT, target)
        triangle.closePath()
        drawPath(triangle)
    
    def frame(x, y, m):
        f = BezierPath()
        g = BezierPath()
        f.rect(0, 0, x, y)
        g.rect(m, m, x - (2 * m), y - (2 * m))
        f = f.difference(g)
        f.closePath()
        drawPath(f)
    
    # original Riley plan for Rustle 6    
    plan = [
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0, 1, 0, 0],
        [-1, 0, -1, 0, 0, 0, 0, 0, 1, 0, -1, 0, 0],
        [1, 0, 1, -1, 0, 0, -1, 0, 0, 0, 1, 0, 1, 0],
        [0, -1, 0, -1, 0, 0, 0, 1, 0, -1, 0, -1, 0],
        [0, 1, -1, 1, 0, 1, 0, 0, 0, 0, -1, 1, 0, 0],
        [-1, 0, -1, 0, 0, 0, 0, 0, 0, 0, -1, 0, -1],
        [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0],
        [0, -1, 0, 0, 0, 0, 0, 0, 0, -1, 0, -1, 0],
        [0, -1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, -1, 0],
        [-1, 0, -1, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1],
        [-1, 0, -1, 0, 1, 0, 1, 0, 0, 0, 1, -1, -1, 0],
        [0, -1, 0, 0, 0, 0, 0, 0, 0, -1, 0, -1, -1, 0],
        [0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        ]
    
    for n in range(nFrames):
        newPage(canvasX, canvasY)
        frameDuration(1/30)
        ex = sin(radians(n * (360/nFrames)))
        fill(1)
        rect(0, 0, canvasX, canvasY)
        with savedState():
            translate(canvasX/2, canvasY/2)
            fill(0)
            translate(-(nX - 1) * offsetX/2, (nY - 1) * offsetY/2)
            for i in range(nY):
                with savedState():
                    translate(0, i * -offsetY)
                    if i % 2 == 0:
                        for j in range(nX - 1):
                            with savedState():
                                translate(offsetX/2 + (j * offsetX), 0)
                                triangle(side, height, center, .5 - (ex * (plan[i][j] * .5)))
                    else:
                        for j in range(nX):
                            with savedState():
                                translate(j * offsetX, 0)
                                triangle(side, height, center, .5 - (ex * (plan[i][j] * .5)))
        fill(1)
        frame(canvasX, canvasY, side/2)
    
    saveImage('~/Desktop/riley_tribute_test_01_%s.gif' %time)
    

    The reference image:

    riley_rustle_6_2015.jpg