Drawing Celtic Knotwork 5

I bet you feel really cheated by now. I've been going on for all this time about celtic knotwork and drawing pictures with the computer, and all you've seen is a bunch of dots and crosses that kind of look like a picture if you go cross-eyed for a second. You can cheer up, folks, because you've finished the boring part. Now we want to make a real live picture!

But how are we going to do it? You know that I'm not about to go making my own image creation library for a little project like this - or any other project, if I can help it. There's no need, either, because there are at least two great choices available:

  • Ruby/GD, a wrapper for the GD library.
  • RMagick, a wrapper for the ImageMagick library.
  • There's also Imlib2, but I couldn't get some of the dependencies to compile. That made it pretty easy to eliminate this particular option. Still, I might look at Imlib2 later.

Each of these options are Ruby-based interfaces to very powerful image generation and manipulation libraries which have been around for ... ages, really. Either one would provide more than enough muscle to squish bitmaps together. Don't you love it when I get technical?

Since it's a coin-toss for either library based on utility, we have to move on to the next measure: documentation. Ruby/GD might have some great documents hidden around somewhere, but I sure can't find them. RMagick, on the other hand, has a useful manual accessible online. See, I've started to learn that most great programmers aren't really all-knowing - they just know how to look stuff up quickly. If I have a good manual handy, then that makes it easier to become a great programmer. Or at least I can make myself look useful...

require 'RMagick'
include Magick
  
# I am a single small section of a knotwork image. I know about my 
# dimensions, and can describe myself on a pixel-by-pixel basis.
class Tile
  
  def initialize(str = nil)
    @pixels = []
    (0..8).each { 
      row = []
      (0..8).each { row << nil }
      @pixels << row
    }
    
    if str then
      set_from_string(str)
    end
  end
  
  def at(x, y)
    return @pixels[x][y]
  end
  alias is_set? at
  
  def set(x, y, value=true)
    @pixels[x][y] = value
  end
  
  def unset(x, y)
    @pixels[x][y] = nil
    
    return true
  end
  
  def set_from_string(str)
    str.split("\n").each_with_index do |line, row|
      line.split(' ').each_with_index do |pixel, col|
        set(row, col, pixel)
      end
    end
  end
  
  def to_s
    str = ""
    @pixels.each { |row|
      str += "|"
      row.each { |pixel| 
        pixel ||= " "
        str += "#{pixel}|" 
      }
      str += "\n"
    }
    return str
  end
end
  
# I am an arranged collection of Tiles. I know how to add and remove
# Tiles along a 2-d grid, and can also present myself as if I were a single
# large Tile.
class Grid
  def initialize(rows, columns)
    @tile_size = 9
    @rows      = rows
    @columns   = columns
    @pixels    = Array.new(rows*@tile_size) { |i|
      Array.new(columns*@tile_size)
    }
  end
  
  def set_tile(row, column, tile)
    if row >= @rows or column >= @columns then
      raise ArgumentError, \
      "set_tile at #{row}, #{column} outside of Grid area " \
      "(#{@rows}, #{@columns})"
    end
    
    pixel_origin_x = row * @tile_size
    pixel_origin_y = column * @tile_size
    (0...@tile_size).each { |tile_x|
      x = pixel_origin_x + tile_x
      (0...@tile_size).each { |tile_y|
        y = pixel_origin_y + tile_y
        @pixels[x][y] = tile.at(tile_x, tile_y)
      }
    }
  end
  
  def at(row, column)
    return @pixels[row][column]
  end
  
  def to_s
    str = ""
    @pixels.each { |row|
      str += row.join(' ')
      str += "\n"
    }
    
    return str
  end
  
end
  
# I am a lovely Celtic knotwork panel. I know my dimensions, and can 
# output myself as ASCII art.
class KnotworkPanel
  @@top_left = Tile.new(%{. . . . x x x x x
                          . . . . x . . . .
                          . . . . x . . . .
                          . . . . x . . . x
                          . . . . x . . x .
                          . . . . x . . x .
                          . . . . . x . . x
                          . . . . . x . . x
                          . . . . . . x x .}.gsub(/^\s+/, "))
  
  @@top      = Tile.new(%{x x . . . . . x x
                          . . x x . x x . .
                          . . . . x . . . .
                          x x . . . x . x x
                          . . x . . . x . .
                          . x . x . . . x .
                          x . . . x . . . x
                          . . . x . x . . x
                          . . x . . . x x .}.gsub(/^\s+/, ")
                        )
  
  @@topright = Tile.new(%{x x x x x . . . .
                          . . . . x . . . .
                          . . . . x . . . .
                          x . . . x . . . .
                          . x . . x . . . .
                          . x . . x . . . .
                          x . . x . . . . .
                          . . . x . . . . .
                          . . x . . . . . .}.gsub(/^\s+/, ")
                        )
  
  @@left     = Tile.new(%{. . . . . . x . .
                          . . . . . x . . .
                          . . . . . x . . .
                          . . . . x . . . x
                          . . . . x . . x .
                          . . . . x . . x .
                          . . . . . x . . x
                          . . . . . x . . x
                          . . . . . . x x .}.gsub(/^\s+/, ")
                        )
  
  @@center   = Tile.new(%{. . x . . . x . .
                          . x . x . x . . .
                          x . . . x . . . x
                          . x . . . x . x .
                          . . x . . . x . .
                          . x . x . . . x .
                          x . . . x . . . x
                          . . . x . x . x .
                          . . x . . . x . .}.gsub(/^\s+/, ")
                        )
  
  @@right    = Tile.new(%{. x x . . . . . .
                          x . . x . . . . .
                          x . . x . . . . .
                          . x . . x . . . .
                          . x . . x . . . .
                          . x . . x . . . .
                          x . . x . . . . .
                          . . . x . . . . .
                          . . x . . . . . .}.gsub(/^\s+/, ")
                        )
  
  @@bot_left = Tile.new(%{. . . . . . x . .
                          . . . . . x . . .
                          . . . . . x . . x
                          . . . . x . . x .
                          . . . . x . . x .
                          . . . . x . . . x
                          . . . . x . . . .
                          . . . . x . . . .
                          . . . . x x x x x}.gsub(/^\s+/, ")
                        )
  
  @@bottom   = Tile.new(%{. x x . . . x . .
                          x . . x . x . . .
                          x . . . x . . . x
                          . x . . . x . x .
                          . . x . . . x . .
                          x x . x . . . x x
                          . . . . x . . . .
                          . . x x . x x . .
                          x x . . . . . x x}.gsub(/^\s+/, ")
                        )
  
  @@botright = Tile.new(%{. x x . . . . . .
                          x . . x . . . . .
                          x . . x . . . . .
                          . x . . x . . . .
                          . x . . x . . . .
                          x . . . x . . . .
                          . . . . x . . . .
                          . . . . x . . . .
                          x x x x x . . . .}.gsub(/^\s+/, ")
                        )
  
  def initialize(rows, columns=rows)
    @row_size = rows + 2
    @col_size = columns + 2
    
    @grid = Grid.new(@row_size, @col_size)
    
    # Set the top row
    @grid.set_tile(0, 0, @@top_left)
    (1...@col_size-1).each do |i|
      @grid.set_tile(0, i, @@top)
    end
    @grid.set_tile(0, @col_size-1, @@topright)
    
    # Set the center rows.
    (1...@row_size-1).each do |i|
      @grid.set_tile(i, 0, @@left)
      (1...@col_size-1).each do |j|
        @grid.set_tile(i, j, @@center)
      end
      @grid.set_tile(i, @col_size-1, @@right)
    end
    
    # Set the bottom row
    @grid.set_tile(@row_size-1, 0, @@bot_left)
    (1...@col_size-1).each do |i|
      @grid.set_tile(@row_size-1, i, @@bottom)
    end
    @grid.set_tile(@row_size-1, @col_size-1, @@botright)
  end
  
  def to_aa()
    return @grid.to_s
  end
  
  def to_image()
    
    filename = "panel-#{@row_size}x#{@col_size}.png"
    
    max_x = 9 * @row_size
    max_y = 9 * @col_size
    
    image = Image.new(max_x, max_y) { self.background_color = "white" }
    (0...max_y).each do |y|
      (0...max_x).each do |x|
        pixel = @grid.at(x, y)
        if pixel == "x" then
          image.pixel_color(x, y, "black")
        end
      end
    end
    image.write(filename)
    
  end
  
end
  
#####
# Test code
#####
$source_string =<<HERE
      x . . . . . . . x
      . x . . . . . x .
      . . x . . . x . .
      . . . x . x . . .
      . . . . x . . . .
      . . . x . x . . .
      . . x . . . x . .
      . x . . . . . x .
      x . . . . . . . x
HERE
$source_string.gsub!(/^\s+/m, ")
  
require 'test/unit'
  
class TC_Tile < Test::Unit::TestCase
  def setup
    @@tile = Tile.new()
  end
  
  def test_pixels
    assert_equal(nil, @@tile.is_set?(0, 0), 
      "By default, any pixel in a Tile is blank")
    assert(@@tile.set(0, 0), 
      "Use Tile#set(row, col) to set a pixel at coordinates (row, col)")
    assert(@@tile.is_set?(0, 0), 
      "A pixel (row, col) is set after Tile#set(row, col) has been called")
    assert(@@tile.unset(0, 0), 
      "Use Tile#unset(row, col) to clear a pixel at coordinates (row, col)")
    assert_equal(nil, @@tile.is_set?(0, 0), 
      "An unset pixel has no set value")
    @@tile.set(1, 1)
    assert_equal(nil, @@tile.is_set?(0, 0), 
      "Setting one pixel has no effect on other pixels in a Tile")
    assert(@@tile.is_set?(1, 1), 
      "Tile remembers the set status of each pixel in its confines.")
    
    assert(@@tile.set_from_string($source_string),
      "You can use ASCII art strings to set the pixels in a Tile")
    assert(@@tile.is_set?(0, 0))
    assert(@@tile.is_set?(1, 0))
    assert_equal('x', @@tile.at(0, 0),
      "A Tile remembers the value assigned, if given, " \
      "during Tile#set(row, col, val)")
  end
end
  
class TC_Grid < Test::Unit::TestCase
  def test_simple_grid
    grid = Grid.new(1, 1)
    tile = Tile.new($source_string)
    grid.set_tile(0, 0, tile)
    assert_equal("x", grid.at(0, 0),
      "Use Grid#pixel_at(row, col) to access pixel at (row, col) " \
      "distance from upper left corner")
    assert_equal($source_string, grid.to_s)
    assert_raise(ArgumentError, "You cannot set a Tile outside of the Grid." { 
                   grid.set_tile(1, 1, tile) 
                 }
  end
  def test_large_grid
    grid = Grid.new(1, 2)
    tile1 = Tile.new($source_string)
    tile2 = Tile.new($source_string)
    grid.set_tile(0, 0, tile1)
    grid.set_tile(0, 1, tile2)
    assert_equal("x", grid.at(0, 0))
    assert_equal("x", grid.at(0, 9),
                 "Grid#pixel_at uses whole grid as coordinate system")
    expected_output =<<HERE
      x . . . . . . . x x . . . . . . . x
      . x . . . . . x . . x . . . . . x .
      . . x . . . x . . . . x . . . x . .
      . . . x . x . . . . . . x . x . . .
      . . . . x . . . . . . . . x . . . .
      . . . x . x . . . . . . x . x . . .
      . . x . . . x . . . . x . . . x . .
      . x . . . . . x . . x . . . . . x .
      x . . . . . . . x x . . . . . . . x
HERE
    expected_output.gsub!(/^\s+/, ")
    assert_equal(expected_output, grid.to_s)
  end
end
  
class TestKnotworkPanel < Test::Unit::TestCase
  def test_ascii
    panel = KnotworkPanel.new(1)
    ascii_output_1 =<<HERE
      . . . . x x x x x x x . . . . . x x x x x x x . . . .
      . . . . x . . . . . . x x . x x . . . . . . x . . . .
      . . . . x . . . . . . . . x . . . . . . . . x . . . .
      . . . . x . . . x x x . . . x . x x x . . . x . . . .
      . . . . x . . x . . . x . . . x . . . x . . x . . . .
      . . . . x . . x . . x . x . . . x . . x . . x . . . .
      . . . . . x . . x x . . . x . . . x x . . x . . . . .
      . . . . . x . . x . . . x . x . . x . . . x . . . . .
      . . . . . . x x . . . x . . . x x . . . x . . . . . .
      . . . . . . x . . . . x . . . x . . . x x . . . . . .
      . . . . . x . . . . x . x . x . . . x . . x . . . . .
      . . . . . x . . . x . . . x . . . x x . . x . . . . .
      . . . . x . . . x . x . . . x . x . . x . . x . . . .
      . . . . x . . x . . . x . . . x . . . x . . x . . . .
      . . . . x . . x . . x . x . . . x . . x . . x . . . .
      . . . . . x . . x x . . . x . . . x x . . x . . . . .
      . . . . . x . . x . . . x . x . x . . . . x . . . . .
      . . . . . . x x . . . x . . . x . . . . x . . . . . .
      . . . . . . x . . . x x . . . x . . . x x . . . . . .
      . . . . . x . . . x . . x . x . . . x . . x . . . . .
      . . . . . x . . x x . . . x . . . x x . . x . . . . .
      . . . . x . . x . . x . . . x . x . . x . . x . . . .
      . . . . x . . x . . . x . . . x . . . x . . x . . . .
      . . . . x . . . x x x . x . . . x x x . . . x . . . .
      . . . . x . . . . . . . . x . . . . . . . . x . . . .
      . . . . x . . . . . . x x . x x . . . . . . x . . . .
      . . . . x x x x x x x . . . . . x x x x x x x . . . .
HERE
    
    ascii_output_1.gsub!(/^\s+/, ")
    real_output = panel.to_aa.split("\n")
    ascii_output_1.split("\n").each_with_index do |line, i|
      assert_equal(line, real_output[i],
                   "line #{i} doesn't match")
    end
    
  end
  
  def test_large_panels
    ascii_output_1 =<<HERE
    . . . . x x x x x x x . . . . . x x x x x x x . . . .
    . . . . x . . . . . . x x . x x . . . . . . x . . . .
    . . . . x . . . . . . . . x . . . . . . . . x . . . .
    . . . . x . . . x x x . . . x . x x x . . . x . . . .
    . . . . x . . x . . . x . . . x . . . x . . x . . . .
    . . . . x . . x . . x . x . . . x . . x . . x . . . .
    . . . . . x . . x x . . . x . . . x x . . x . . . . .
    . . . . . x . . x . . . x . x . . x . . . x . . . . .
    . . . . . . x x . . . x . . . x x . . . x . . . . . .
    . . . . . . x . . . . x . . . x . . . x x . . . . . .
    . . . . . x . . . . x . x . x . . . x . . x . . . . .
    . . . . . x . . . x . . . x . . . x x . . x . . . . .
    . . . . x . . . x . x . . . x . x . . x . . x . . . .
    . . . . x . . x . . . x . . . x . . . x . . x . . . .
    . . . . x . . x . . x . x . . . x . . x . . x . . . .
    . . . . . x . . x x . . . x . . . x x . . x . . . . .
    . . . . . x . . x . . . x . x . x . . . . x . . . . .
    . . . . . . x x . . . x . . . x . . . . x . . . . . .
    . . . . . . x . . . . x . . . x . . . x x . . . . . .
    . . . . . x . . . . x . x . x . . . x . . x . . . . .
    . . . . . x . . . x . . . x . . . x x . . x . . . . .
    . . . . x . . . x . x . . . x . x . . x . . x . . . .
    . . . . x . . x . . . x . . . x . . . x . . x . . . .
    . . . . x . . x . . x . x . . . x . . x . . x . . . .
    . . . . . x . . x x . . . x . . . x x . . x . . . . .
    . . . . . x . . x . . . x . x . x . . . . x . . . . .
    . . . . . . x x . . . x . . . x . . . . x . . . . . .
    . . . . . . x . . . x x . . . x . . . x x . . . . . .
    . . . . . x . . . x . . x . x . . . x . . x . . . . .
    . . . . . x . . x x . . . x . . . x x . . x . . . . .
    . . . . x . . x . . x . . . x . x . . x . . x . . . .
    . . . . x . . x . . . x . . . x . . . x . . x . . . .
    . . . . x . . . x x x . x . . . x x x . . . x . . . .
    . . . . x . . . . . . . . x . . . . . . . . x . . . .
    . . . . x . . . . . . x x . x x . . . . . . x . . . .
    . . . . x x x x x x x . . . . . x x x x x x x . . . .
HERE
    
    ascii_output_1.gsub!(/^\s+/, ")
    panel = KnotworkPanel.new(2, 1)
    real_output = panel.to_aa.split("\n")
    ascii_output_1.split("\n").each_with_index do |line, i|
      assert_equal(line, real_output[i],
                   "line #{i} doesn't match")
    end
  end
  
end
   
panel = KnotworkPanel.new(8, 18)
panel.to_image()

Thanks to all the work we did building Tiles and Grids and ASCII art KnotworkPanels, we only need to add a few lines to allow KnotworkPanels to create a nice black and white PNG image file. Here's what we get:

10x20 panel

Cool, eh?