Fundamentals of Computer Science I: Media Computing (CS151.02 2007F)

Geometric Art


Summary: As we have seen in our various explorations, scripting can help us make interesting images in a number of ways. One particularly appropriate use of scripting is making what we call “geometric art”, images which include regularly generated geometric figures. In this reading, we consider some simple forms of geometric art.

Introduction

From antiquity to the present day, artists have experimented with ways in which repetition of simple geometric forms, such as lines, squares, and circles, can create interesting effects. Scripting provides an excellent opportunity to explore such geometric images, since DrFu already provides techniques for drawing simple geometric forms and we can easily write scripts that draw these forms in several places (perhaps even modified in various ways).

For example, consider the problem of drawing three parallel lines, with their starting coordinates spaced horizontally by twenty columns. We might express that with a sequence of DrFu commands as.

> (define canvas (image.new 200 200))
> (image.show canvas)
> (envt.set-brush! "Circle Fuzzy (05)")
> (envt.set-fgcolor! "red")
> (define start-col 10)
> (define start-row 10)
> (define end-col 20)
> (define end-row 100)
> (image.draw-line! canvas start-col start-row end-col end-row)
> (image.draw-line! canvas (+ 20 start-col) start-row (+ 20 end-col) end-row)
> (image.draw-line! canvas (+ 40 start-col) start-row (+ 40 end-col) end-row)
> (envt.update-displays!)

Of course, if drawing three regularly-spaced parallel lines is a task we expect to do a lot, we might write these instructions as a separate procedure.

;;; Procedure:
;;;   draw-three-parallel-lines!
;;; Parameters:
;;;   image, an image
;;;   start-col, an integer
;;;   start-row, an integer
;;;   end-col, an integer
;;;   end-row, an integer
;;;   hoffset, an integer
;;;   voffset, an integer
;;; Purpose:
;;;   Draw three parallel lines, with the first from (start-col,start-row)
;;;     to (end-col,end-row) and the starting point of the next two offset
;;;     horizontally by hoffset (and 2*hoffset) and vertically by voffset
;;;     (and 2*voffset).
;;; Produces:
;;;   [Nothing; Called for the side effect.]
;;; Preconditions:
;;;   All three parallel lines can be drawn on the image.
;;; Postcondtions:
;;;   The image has been appropriately modified.
(define draw-three-parallel-lines!
  (lambda (image start-col start-row end-col end-row hoffset voffset)
    (image.draw-line! image 
                      start-col start-row 
                      end-col end-row)
    (image.draw-line! image 
                      (+ hoffset start-col) (+ voffset start-row)
                      (+ hoffset end-col) (+ voffset end-row))
    (image.draw-line! image 
                      (+ (* 2 hoffset) start-col) (+ (* 2 voffset) start-row)
                      (+ (* 2 hoffset) end-col) (+ (* 2 voffset) end-row))))

We can then uses this procedure to draw a variety of parallel lines, either using the same brush and color (by default) or by changing the brushes and colors.

> (envt.set-fgcolor! "black")
> (envt.set-brush! "Circle Fuzzy (11)")
> (draw-three-parallel-lines! canvas 10 10 30 200 10 50)
> (envt.set-fgcolor! "red")
> (envt.set-brush! "Circle Fuzzy (05)")
> (draw-three-parallel-lines! canvas 10 10 30 200 10 50)
> (envt.set-fgcolor! "blue")
> (draw-three-parallel-lines! canvas 50 0 50 80 20 20)
> (envt.set-fgcolor! "green")
> (draw-three-parallel-lines! canvas 60 90 200 90 0 30)
> (envt.update-displays!)

We can use similar techniques to draw concentric circles. In this case, it may help to first write a procedure that draws centered circles (in effect, encapsulating the selection, computation of points, and stroking).

;;; Procedure:
;;;   draw-circle!
;;; Parameters:
;;;   image, an image
;;;   col, an integer
;;;   row, an integer
;;;   radius, an integer
;;; Purpose:
;;;   Draws a circle with the specified in the current brush and color, centered at (col,row).
;;; Produces:
;;;   [Nothing; Called for the side effect]
;;; Preconditions:
;;;   0 <= col < (image.width image)
;;;   0 <= row < (image.height image)
;;;   0 < radius
;;; Postconditions:
;;;   The image now contains the specified circle.  (The circle may not be visible.)
(define draw-circle!
  (lambda (image col row radius)
    (image.select-ellipse! image selection.replace
                           (- col radius) (- row radius)
                           (+ radius radius) (+ radius radius))
    (image.stroke! image)
    (image.select-nothing! image)))

So, to draw three concentric circles on our canvas, centered at (100,100) and with radii of 30, 50, and 70, we might write something like

> (envt.set-fgcolor! "black")
> (envt.set-brush! "Circle (11)")
> (draw-circle! canvas 100 100 30)
> (draw-circle! canvas 100 100 50)
> (draw-circle! canvas 100 100 70)
> (envt.update-displays!)

We might also offset the centers of the circles slightly, as in the following.

> (envt.set-fgcolor! "grey")
> (envt.set-brush! "Calligraphic Brush")
> (draw-circle! canvas 80 100 40)
> (draw-circle! canvas 90 100 60)
> (draw-circle! canvas 100 100 80)
> (envt.update-displays!)

Again, we might encapsulate this technique in a procedure. The particular details of that procedure are left as an exercise to the reader. Of course, once we draw more than a few concentric circles or a few parallel lines, it becomes useful to write more general procedures, procedures in which we specify not just the change in location or radius, but even the number of items to draw. In the sections that follow, and in the corresponding lab, you will have the opportunity to explore such variants.

Exploring Parallel Lines

Let's begin by considering some of the ways in which we might draw parallel lines. We'll start by looking at the parameters of the draw-three-parallel-lines! procedure. That procedure took as parameters an image, a starting position (represented by start-col and start-row), an ending position (represented by end-col and end-row), and horizontal and vertical offsets. We want to add another parameter that keeps track of the number of repetitions to do. We'll call that parameter n, and put it early in the parameter list. So, the procedure header will look something like

(define draw-parallel-lines!
  (lambda (image n start-col start-row end-col end-row hoffset voffset)
     ...))

Now, if we're going to have this draw an arbitrary number of lines, we will probably need to use recursion to repeat the actions. (It may be possible to do this using map and iota, but it's useful for you to see some more examples of recursion.) What is the base case of this recursion? Presumably, when we have no lines left to draw. What should we do in the recursive case? Draw one line, and then draw the remaining lines. How many lines do we have left to draw after the first line? One fewer. Putting all of this together, we get the following.

;;; Procedure:
;;;   draw-parallel-lines!
;;; Parameters:
;;;   image, an image
;;;   n, an integer
;;;   start-col, an integer
;;;   start-row, an integer
;;;   end-col, an integer
;;;   end-row, an integer
;;;   hoffset, an integer
;;;   voffset, an integer
;;; Purpose:
;;;   Draw n parallel lines, with the first from (start-col,start-row)
;;;     to (end-col,end-row) and each subsequent one offset by the 
;;;     appropriate multiple of hoffset and voffset.
;;; Produces:
;;;   [Nothing; Called for the side effect.]
;;; Preconditions:
;;;   All parallel lines can be drawn on the image.
;;; Postcondtions:
;;;   The image has been appropriately modified.
(define draw-parallel-lines!
  (lambda (image n start-col start-row end-col end-row hoffset voffset)
    (cond
      ((> n 1)
       (image.draw-line! image 
                         start-col start-row 
                         end-col end-row)
       (draw-parallel-lines! image
                             (- n 1)
                             (+ hoffset start-col) (+ voffset start-row)
                             (+ hoffset end-col) (+ voffset end-row)
                             hoffset voffset)))))

Note that we're using cond rather than if because cond explicitly allows multiple actions in the consequent, while if does not.

What can we do once we've written a procedure like this? As you might expect, in addition to drawing various collections of parallel lines, we might consider interesting variants. We'll consider four: Varying the color, varying the brush, varying the length of individual lines, and varying the spacing between lines. Each may suggest some useful design and programming techniques.

Varying the Color

Suppose we want the color of the lines to vary, so that each line is somewhat different from its neighbor. How can we make such variation? We can certainly build a color that depends on the row and column. You've already experimented with a variety of techniques for choosing such colors. Here's a new one.

;;; Procedure:
;;;   position->color
;;; Parameters:
;;;   col, an integer
;;;   row, an integer
;;; Purpose:
;;;   Compute a color based on col and row.
;;; Produces:
;;;   color, an RGB color
;;; Preconditions:
;;;   [No additional]
;;; Postconditions:
;;;   Different col/row combinations are likely to give different colors.
;;;   Nearby col/row combinations may give similar colors.
(define position->color
  (lambda (col row)
    (rgb.new (+ 128 (* 128 (sin (* pi row 0.0625))))
             (+ 128 (* 128 (cos (* pi col 0.0625))))
             (+ 128 (* 128 (sin (* pi (+ row col) 0.0625)))))))

What's going on here? Well, we know that sin and cos give results between -1 and 1. By multiplying that value by 128, we get numbers between -128 and 128. By adding 128, we get numbers between 0 and 256, where are essentially the range of valid component values. This certainly isn't the only technique to use, but it gives us some interesting results.

Now that we can choose colors, we need only add a line to draw-parallel-lines to make draw-colored-parallel-lines.

(define draw-colored-parallel-lines!
  (lambda (image n start-col start-row end-col end-row hoff voff)
    (cond
      ((> n 1)
       (envt.set-fgcolor! (position->color start-col start-row))
       (image.draw-line! image 
                         start-col start-row 
                         end-col end-row)
       (draw-colored-parallel-lines! image
                                     (- n 1)
                                     (+ hoff start-col) (+ voff start-row)
                                     (+ hoff end-col) (+ voff end-row)
                                     hoff voff)))))

We can see the effects of the coloring by drawing thin lines close together.

> (envt.set-brush! "Circle (01)")
> (draw-colored-parallel-lines! canvas 50 0 0 0 100 2 4)
> (envt.update-displays! canvas)

Varying the Brush

Suppose that instead of varying the color, we want to vary the width of the brush used to draw each line. We might make provide a list of possible brushes as a parameter and use n to select a brush. Since we have an integer that may be outside the range of valid brushes, we can use modulo to restrict that number to the number of valid indices.

    (envt.set-brush! (list-ref brushes (modulo n (length brushes))))

You will have the opportunity to explore the use of this technique in the laboratory.

Varying the Length

Varying the color and brush provides us with the opportunity to create some interesting images. However, we might want to use parallel lines to explore other concepts, such as the values of a function at various x values. In this case, we'll draw vertical lines, using the column as the x value and the height of the line as the y value. (If the function produces negative values, it may be helpful to place the x axis in the middle of the image.) For example, here's a simple procedure that draws n parallel lines, spaced by offset, with the height of each line computed via a variant of the sine function.

;;; Procedure:
;;;   draw-sin-with-parallel-lines!
;;; Parameters:
;;;   image, an image
;;;   n, an integer
;;;   offset, an integer
;;;   start-col, an integer
;;;   mid-row, an integer
;;; Purpose:
;;;   Draw a sequence of parallel lines, with the height of the
;;;   parallel line dependent on the column.
;;; Produces:
;;;   [Nothing, called for the side effect.]
(define draw-sin-with-parallel-lines!
  (lambda (image n offset start-col mid-row)
     (cond 
       ((> n 0)
        (image.draw-line! image 
                          start-col mid-row
                          start-col (+ mid-row (* mid-row (sin (* n pi .01)))))
        (draw-sin-with-parallel-lines! 
           image (- n 1) offset 
           (+ start-col offset) mid-row)))))

Varying the Spacing

Here's another interesting variant of drawing parallel vertical lines. Rather than spacing them equally, let's vary the spacing by making the spacing between a pair of lines half the spacing between the preceding pairs, stopping when the spacing gets below some value, which we call close-enough.

;;; Procedure:
;;;   draw-parallel-lines-with-decreasing-spacing!
;;; Parameters:
;;;   image, an image
;;;   col, an integer
;;;   start-row, an integer
;;;   end-row, an integer
;;;   spacing
;;;   close-enough
;;; Purpose:
;;;   Draw a sequence of vertical lines (each running from start-row
;;;   to end-row) starting at col, then spaced by spacing from col,
;;;   then by spacing/2 from that column, then spacing/4 from that
;;;   column, and so on and so forth until the distance between columns 
;;;   is less than or equal to close-enough.
;;; Produces:
;;;   [Nothing. Called for the side effects.]
(define draw-parallel-lines-with-decreasing-spacing!
  (lambda (image col start-row end-row spacing close-enough)
    (image.draw-line! image col start-row col end-row)
    (if (> spacing close-enough)
        (draw-parallel-lines-with-decreasing-spacing!
          image 
          (+ col spacing) start-row end-row
          (/ spacing 2)
          close-enough))))

There are several things to note in this procedure. First, we always draw at least one line. Next, because there's only one thing we need to do if we continue, we use if rather than cond. Most importantly, even though we've previously used “subtract one” and “stop at 0” as our simplification and base-case in numeric recursion, here we use “divide by two” and “stop when small enough” as our simplification and base case.

Creative Commons License

Samuel A. Rebelsky, rebelsky@grinnell.edu

Copyright 2007 Janet Davis, Matthew Kluber, and Samuel A. Rebelsky. (Selected materials copyright by John David Stone and Henry Walker and used by permission.)

This material is based upon work partially supported by the National Science Foundation under Grant No. CCLI-0633090. Any opinions, findings, and conclusions or recommendations expressed in this material are those of the author(s) and do not necessarily reflect the views of the National Science Foundation.

This work is licensed under a Creative Commons Attribution-NonCommercial 2.5 License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc/2.5/ or send a letter to Creative Commons, 543 Howard Street, 5th Floor, San Francisco, California, 94105, USA.