CSCI 480 / 580 - Lab: Spline Curves

Scott Wehrwein

Fall 2024

In this lab, you’ll implement algorithms to draw Bézier spline curves.

Getting Started

Environment: The code is implemented in Javascript using an HTML Canvas, so all you need is a text editor and a browser.

Collaboration: You should write your own code, but please collaborate freely with your classmates on this lab. If you’ve finished tasks 1-3, help out those who haven’t; conversely, if you’re stuck, check in with your neighbors to see if they have any helpful suggestions.

Extensions: There are plenty of ways to extend this lab if you have extra time. These are optional, and listed at the end of this handout. If you can’t find anyone who needs help, work on some of the Optional Extra Fun tasks at the bottom of this writeup.

Submission: I anticipate that you’ll be able to finish this during class time, but I’m giving you until 10pm Friday 11/22 to submit your code on Github. If you finish during class, let me know so I can assign your grade right away.

Skeleton

This lab will be done in a new repository, created via Github Classroom as usual. Accept the invite (found on Canvas) and clone your repo. You’ll be working in splines.js, which loosely mirrors one of the task?.js files from A3. The splines.html file just contains the canvas we’ll draw on, and matrix.js contains modified version of the matrix.js library from A3. I made three significant changes to the matrix library:

  1. I renamed the class to SimplerMatrix (Simpler, with an r)
  2. I changed the implementation of multiplyVector to not assume the fourth component of the input vector is 1.
  3. I removed functionality that we don’t need.

Tasks - Overview

  1. Implement enough to draw a sequence of dots along the curve:
    1. Fill in the Bézier control matrix in the Spline object’s constructor.
    2. Pre-compute the product of the Bézier matrix with the control points.
    3. Implement eval_direct to directly compute the position of the curve at time t
  2. Implement eval_dcj to evaluate the position using de Casteljau’s algorithm.
  3. Implement draw_dcj to draw the curve using piecewise linear subdivision.

Tasks - Detail

  1. Start by looking over the code from the beginning until the comment marker that says “end of lab code”. Everything after that can be safely ignored - it’s just setup code and event handling code to make the control points click-and-draggable.

  2. Part A: In the Spline object’s “constructor”, fill in the (hard-coded) Bézier control matrix that we derived in lecture. This matrix converts the control point coordinates into polynomial coefficients.

    Part B: For a given call to the render function, the control points are fixed, and we’ll want to evaluate potentially many points along the curve. For efficiency, we’ll start by pre-computing the polynomial coefficients by multiplying the Bézier matrix by the control points and saving them for repeated use when evaluating points on the curve. At the top of the render function, complete the two lines that set this.Ax and this.Ay. Recall that the vector \(\vec{a}\) of coefficients is given by multiplying the Bézier matrix \(B\) by the positions of the control points \(\vec{p}\). Part C: Finally, implement eval_direct, which takes a t value and computes the position on the curve by directly evaluating the polynomial; recall that since Bézier curves are cubic, the position at \(t\) is \(a_0 + a_1 t + a_2 t^2 + a_3 t^3\).

    If you’ve correctly implemented Task 1, you should be able to uncomment the first commented-out block of the render function. This function draws a series of dots at evenly spaced values of \(t\) along the curve. With this task complete, I get a result that looks like this:

    You should be able to click and drag the blue dots to change the control points and watch the red dots adjust. You can also click anywhere in the canvas and the nearest control point to your cursor will jump to that location.

  3. Instead of direct evaluation, we will now evaluate points using de Casteljau’s algorithm. Recall that this algorithm computes a point on the curve by linearly interpolating between control points, then linearly interpolating between those interpolated points, and so on until you arrive at a single point. I find the following figure is good to keep in mind for visual intuition: The left figure is easier to parse, but just keep in mind that the algorithm works for any value of \(t\) (the book is using \(u\) instead of \(t\)).

    Implement eval_dcj. Make use of the lerp helper function just above; if you give it \(x\), \(y\), and \(t\), it gives you the value that is \(100t\%\) of the way from \(x\) to \(y\). I don’t recommend trying to put this in a loop - it’s likely to be more complicated than just writing out the (somewhat verbose) sequence of 12 (6, but one each for \(x\) and \(y\)) lerp operations.

    Uncomment the second block in the render function to test this. That code is similar to the Task 1 code, except it draws green dots at offset positions. My result looks like this:

  4. Finally, let’s actually draw the curve as a line. We could do this by drawing many points with sufficiently tight spacing so that there are no gaps, but this is inefficient and it’s also not always clear how tight the spacing needs to be, since equally spaced \(t\) values do not result in equally spaced points. The approach we’ll use leverages a nifty property that’s unique to Bézier curves: when you compute the interpolated points in de Casteljau’s algorithm, you’re actually generating the control points for two sub-curves that comprise the original curve. Here’s the figure to keep in mind:

    In this picture, the original curve has been subdivided into two new curves. The first has control points A, AB, AC, and AD, while the second has control points AD, BD, CD, and D.

    Drawing a curve can be accomplished by performing this subdivision recursively; at some point, a sub-curve becomes sufficiently flat that we won’t know the difference between a line segment and the sub-curve; at this point, we can draw line segments connecting the control points and terminate the recursion.

    Take a look at the draw_dcj method. The base case, which draws line segments and returns whether the given curve is sufficiently line-segment-like, is implemented for you. Your job is to implement the recursive case: subidvide the curve into two pieces by splitting it at \(t = 0.5\), then make two recursive calls to draw_dcj. When this is working, you should see a result like this:

    When you’re convinced it works, you can comment out the dot-drawing pieces in the render function so you can just see the un-decorated curve. Drag the control points around and see if there’s any configuration where you can tell that it’s a piecewise linear approximation. Try changing the maxDepth argument too draw_dcj to a smaller number to limit the recursion levels and then see if you can spot inaccuracies.

Implementation Notes

Much of the math in this codebase operates on 2D points; lacking nice features like the ability to easily define a Vec2 type and have elementwise operations Just Work, the code often repeats the same line once for the \(x\) coordinate and once for the \(y\) coordinate. It’s a little verbose, but at least we’re only in 2D.

In Javascript, a variable is global unless you specify otherwise. Who knew? Not me, until I started impelmenting Task 4 and my turns-out-not-local variables were getting modified by the first recursive call before the second one was made. Pro tip: declare local variables using var, as in var x = 4, to give them local scope.

Debugging this code can be a hassle, because the render function is called over and over again. I don’t normally advocate for the use of debuggers, but I found it helpful to be able to set breakpoints. I was using Firefox, and the debugger is pretty intuitive to use - going to the Debugger tab of the developer tools and clicking a code line number sets a breakpoint there; once stopped, you can inspect values.

In addition to the nearly-collinear base case in draw_dcj (Task 3), I also included an escape hatch: you can specify a maxDepth argument to limit how deep the recursion goes. Setting this to something small in the render method’s call too draw_dcj can help when debugging.

Rubric

This lab is worth 10 points in the Surveys, Labs, and Participation category. Grading is pretty chill: if all the tasks work, you’ll get full credit. The three tasks will be worth 7, 2, and 1, respectively. If you finish them during class, please let me know so I can assign your grade right away.

Optional Extra Fun

Add multiple Spline objects. Add support for Spline objects that share one control point (so that their ends are joined). Add support for Splines that also have their second control points linked such that continuity is maintained at the point where they join (in other words, the point’s tangent is the same on both curves). Add UI elements to let a user draw new curves. Add a button that lets a user split a curve using de Casteljau.

Implement other kinds of splines:

\[ \begin{align*} \mathbf{f}(0) &= \mathbf{p}_1\\ \mathbf{f}(1) &= \mathbf{p}_2\\ \mathbf{f}'(0) &= \frac{1}{2}(1-t)(\mathbf{p}_2 - \mathbf{p}_0)\\ \mathbf{f}'(1) &= \frac{1}{2}(1-t)(\mathbf{p}_3 - \mathbf{p}_1)\\ \end{align*} \]