Bezier Circular Arcs

http://www.ece.ualberta.ca/~wyard/wiki.tcl.tk/bezierarc_animated.gif

LWS 2015-03-24. I recently had the need to draw polygons with filled circular arc segments. Rather than dealing with piecewise parametric generation of these arcs, I thought that I would try to let Tk do the heavy lifting with its native cubic Bezier support. The problem then became how to generate the appropriate control points for arbitrary arcs. The following code uses a so-called "Magic Number" to achieve this. This magic number, intended for use with 90 degree arcs, seems to work appropriately when scaled by the arc angle.

The animated gif, above, might not seem terribly interesting, but the fact that the arc is represented by data that can be used with line or polygon item types opens up some possibilities; drawing of an arrow, for instance, is not currently supported by the arc item type.

This package and the demonstration code make use of 2D Coordinate Transformations, as package Trans2D.

# This is a package that is used to generate vectors suitable
# to draw relatively accurate arcs using TCL's canvas item
# -smooth raw option (which uses cubic Beziers).
#
# The approach taken here is to generate the Bezier vectors for
# arcs by (linearly) scaling the so-called "magic number" used
# to draw 90 degree arcs.  I am not sure if this is a standard 
# approach, but it seems to (visually) work reasonably well.

package require Tcl 8.5
package require Trans2D

set NS BezierArc
package provide $NS 0.0

###################################################################### 
namespace eval $NS {
    
    namespace export Arc

    # The derivation of this number (and slight variations to it)
    # can be found from various online sources such as
    #    http://spencermortensen.com/articles/bezier-circle/
    # This is the number for a unit circle, 90 degree arcs.
    variable MAGICNUMBER 0.551915024494


    variable PI
    set PI [expr {acos(-1)}]
}

######################################################################
proc ${NS}::Arc {arccenter arcradius arcstartangle arcstopangle direction} {
    # Returns a vector intended for use in canvas line and polygon item
    # creation with the "-smooth raw" option.  Angles are in radians.

    variable MAGICNUMBER
    variable PI
    
    # Ensure the arc angles are positive.
    set arcstartangle [MakePosAngle $arcstartangle]
    set arcstopangle [MakePosAngle $arcstopangle]
    
    # Determine arc magnitude.
    switch -exact -- [string tolower $direction] {
        cw {
            set arcmagnitude [MakePosAngle [expr {$arcstartangle - $arcstopangle}]]
        }
        ccw {
            set arcmagnitude [MakePosAngle [expr {$arcstopangle - $arcstartangle}]]
        }
        default {
            error "Invalid arc direction, $direction.  Limited to cw|ccw."
        }
    }
    
    # First generate an arc from 0 degrees.
    set nfullquadrants [expr int(floor($arcmagnitude * 2.0 / $PI))]
    set partialquadrant [expr {$arcmagnitude - ($nfullquadrants * $PI)/2}]

    # ...starting with the partial quadrant.  This should work even if
    # there is no partial.  A close-to-zero angle here could likely be
    # filtered out, but that is left to the calling scope.
    set m_adj [expr {$MAGICNUMBER * $partialquadrant * 2.0 / $PI}]
    set fixedcontrolpoints [list 1.0 0.0 1.0 $m_adj]
    set adjcontrolpoints [list 1.0 -$m_adj 1.0 0.0]
    # Rotate adjcontrolpoints to match the desired angle.
    set R [Trans2D::Rotation $partialquadrant]
    set unitarc [list {*}$fixedcontrolpoints {*}[Trans2D::ApplyTransform $R $adjcontrolpoints]]

    # ...and then rotating this by 90 degrees and prepending with
    #    a full-quadrant arc, deleting the intermediary "knot" point.
    
    set R [Trans2D::Rotation [expr {$PI / 2.0}]]
    for {set i 0} {$i < $nfullquadrants} {incr i} {
        set unitarc [Trans2D::ApplyTransform $R $unitarc]
        set unitarc [list 1.0 0.0 1.0 $MAGICNUMBER $MAGICNUMBER 1.0 {*}$unitarc]
    }
    
    # unitarc now contains a ccw circular vector of the appropriate angle
    # Rotate to make this match the arcstartangle, scale to the appropriate
    # radius and translate to the arc center, in that order.
    set R [Trans2D::Rotation $arcstartangle]
    set S [Trans2D::Scale $arcradius]
    set T [Trans2D::Position $arccenter]

    # A clockwise direction requires flipping the data y coordinate since the arc 
    # was generated in a counter-clockwise fashion.
    if {[string match cw [string tolower $direction]]} {
        set F [Trans2D::Reflection y]
        set Tnet [Trans2D::CompoundTransforms $T $S $R $F]
    } else {
        # CCW arc.
        set Tnet [Trans2D::CompoundTransforms $T $S $R]
    }
    
    set unitarc [Trans2D::ApplyTransform $Tnet $unitarc]        
    return $unitarc
}

######################################################################
proc ${NS}::MakePosAngle { angle_rad } {
    # Ensures/converts a negative angle to positive by adding 
    # 360 degrees.
    
    variable PI

    while {$angle_rad < 0.0} {
        set angle_rad [expr {$angle_rad + 2.0 * $PI}]
    }
    return $angle_rad
}

The following demonstration code creates a canvas that is replicated in the animated gif at the top of this page.

#!/bin/sh
# the next line restarts using tclsh \
    exec wish "$0" ${1+"$@"}

package require Trans2D
package require BezierArc

set PI [expr {acos(-1)}]

set c [canvas .c -width 150 -height 150 -background black]
pack .c

# Right-handed coordinate system with origin at the canvas centre.
set cTw [Trans2D::CompoundTransforms [Trans2D::Position 75 75] [Trans2D::Reflection y]]

# Create a static circle with a slightly smaller radius than the arc
# we are about to draw - for comparison purposes.
$c create oval {*}[Trans2D::ApplyTransform $cTw {-45 -45 45 45}] -fill purple

# Create a dummy line item type that will be used to draw the arc.
# Note the required  smooth "raw" option.
set arcID [$c create line 0 0 0 0 0 0 0 0 -smooth raw -fill orange -arrow last]

# ... and another one that will be used to show the control points.
set lineID [$c create line 0 0 0 0 0 0 0 0 -fill grey -dash .]

set ang_step [expr {(2.0 * $PI/75)}]
for {set ang_rad 0.0} {$ang_rad < (2.0 * $PI)} {set ang_rad [expr {$ang_rad + $ang_step}]} {
    set coords [Trans2D::ApplyTransform $cTw [BezierArc::Arc {0.0 0.0} 50.0 0.0 $ang_rad ccw]]
    $c coords $arcID {*}$coords
    $c coords $lineID {*}$coords
    update
    after 100
}