ドロー・プログラムを作る

tkmove3.rb に手を入れているうちにドロー・プログラムを作ってみたくなりました。Ruby/Tk で本格的なドロー・プログラムを作るのは速度的に無理だそうですが、絵を描くのは他のソフトでやることにして、プログラム作りを楽しんでみましょう。

ドロー・プログラムらしく見えるように tkmove3.rb と tkdraw.rb を改良したのが、tkmove4.rbtkdraw4.rb です。tkdraw のバージョン番号がいきなり4に上がっていますが。内容はたいして変わりありません。起動方法は ruby tkdraw4.rb です。

ファイル名: tkmove4.rb(ドロークラスライブラリー)

ファイル名: tkdraw4.rb(ドロープログラム本体)

カーソルの形状を変える

ドロー・プログラムらしく見えるにはカーソルに変化をつけたいところです。Ruby/Tk ではカーソルの形状を変えることができます。TkRoot クラスの cursor メソッドを使うと X11 標準のカーソルのうちから好きなものを指定できます。使い方は TkRoot.new.cursor( 'hand2' ) のようにカーソル名を引数にして cursor メソッドを使います。また、cursor = TkRoot.new.cursor のように引数を省略すると現在のカーソルの名前を得ることができます。カーソル名にどのようなものがあるかは次のサンプルプログラムで確認できます。

ファイル名: cursors.rb

require 'tk'
$cursors =  %w(arrow based_arrow_down based_arrow_up boat bogosity
 bottom_left_corner bottom_right_corner bottom_side bottom_tee
 box_spiral center_ptr circle clock coffee_mug cross cross_reverse
 crosshair diamond_cross dot dotbox double_arrow draft_large
 draft_small draped_box exchange fleur gobbler gumby hand1
 hand2 heart icon iron_cross left_ptr left_side left_tee leftbutton
 ll_angle lr_angle man middlebutton mouse pencil pirate plus
 question_arrow right_ptr right_side right_tee rightbutton rtl_logo
 sailboat sb_down_arrow sb_h_double_arrow sb_left_arrow
 sb_right_arrow sb_up_arrow sb_v_double_arrow shuttle sizing
 spider spraycan star target tcross top_left_arrow top_left_corner
 top_right_corner top_side top_tee trek ul_angle umbrella ur_angle
 watch X_cursor xterm)

$root = TkRoot.new
for i in 1..16
  TkFrame.new{|f|
    for j in 1..5
      cur_name = $cursors.shift
      next if cur_name == nil
      eval "
        TkButton.new(f, 'text'=>'#{cur_name}', 'command'=>proc{
          $root.cursor '#{cur_name}'
        }).pack('side'=>'left') "
    end
  }.pack('fill'=>'x')
end

Tk.mainloop

アイテムの鏡面像を作る

アイテムの鏡面像を作るにはまずX座標の最大値と最小値の中点を求めます。その値を m とすると、点(x, y)の変換後の座標は(2m-x, y)になります。なぜなら m から x までの距離は x - m で対応する点はこれを m から引いた値 m - (x - m) = 2m - x となるからです。次のスクリプトを起動したら Polygon ボタンを押して多角形を作ってください。(最後にダブルクリックすると描画モードから脱出します)。それから mirror ボタンをクリックすると手のアイコンになりますから、それで作成した多角形をクリックして見てください。鏡面像になったら、もう一度クリックすると元の図形に戻ります。

ファイル名: mirror.rb

require 'tkmove4.rb'
include TestBed

def get_item_coords( item )
  coord = item.coords
  coord_array = []
  x = coord.shift
  y = coord.shift
  while x != nil
    coord_array.push( [x.to_f, y.to_f] )
    x = coord.shift
    y = coord.shift
  end
  coord_array
end
def min_max( coord_array, index )
  temp = coord_array.collect{|c| c[index]}
  [ temp.min, temp.max ]
end
def mirror( item, index )
  coords_array = get_item_coords( item )
  min, max = min_max( coords_array, index)
  axis = ( min + max )
  temp = coords_array.collect{|c| c[index] = axis - c[index]; c }
  eval "item.coords( #{temp.join(',')} )" 
end
def mirror_at_cursor( canvas, index )
  item = canvas.find_withtag('current').shift
  mirror( item, index )
end

c = TkCanvas.new.pack
testbed(c)
TkButton.new(nil, 'text'=>'mirror', 'command'=>proc{
  TkRoot.new.cursor('hand2')
  c.bind('1', proc{})
  c.bind('B1-Motion', proc{})
  c.itembind('item', '1', proc{ mirror_at_cursor( c, 0 ) })
  }).pack
Tk.mainloop

簡単に各メソッドの説明をします。get_item_coords はアイテムオブジェクトを引数に取り、そのアイテムの位置情報を2次元配列にして戻します。min_max メソッドはアイテムの位置情報の2次元配列とインデックスを引数に取り、インデックスが 0 ならX座標の、1 ならY座標の最大値と最小値を戻します。mirror メソッドはアイテムオブジェクトとインデックスを引数に取り、インデックスが 0 ならY軸に対称に、1 なら X 軸に対称にアイテムを鏡面像にします。mirror_at_cursor メソッドはクリックされたアイテムを鏡面像にします。

このスクリプトではグループ化されたアイテムについてはうまく作動しません。グループ化されたアイテムについても動くようにしたのが次の mirror2.rb です。起動したら 2, 3 個のアイテムを作成して Area ボタンを押した後グループ化したいアイテムを領域で囲んでから、Grp ボタンでグループ化してください。それから mirror ボタンをクリックするとカーソルが手の形に変わりますから、グループ化したアイテムをクリックしてみてください。

ファイル名: mirror2.rb

共通のメソッドをモジュールにまとめる

上のスクリプトの get_item_coords など使い回しができそうなメソッドはモジュールにまとめておくと便利です。ドロープログラムで共通に使えそうなメソッドをまとめた DrawCommon モジュールは次のようになります。

ファイル名: draw_common.rb

module DrawCommon
  def get_item_coords( item )
    coord = item.coords
    coord_array = []
    x = coord.shift
    y = coord.shift
    while x != nil
      coord_array.push( [x.to_f, y.to_f] )
      x = coord.shift
      y = coord.shift
    end
    coord_array
  end
  def min_max( coord_array, index )
    temp = coord_array.collect{|c| c[index]}
    [ temp.min, temp.max ]
  end
  def center( coord_array )
    x_min, x_max = min_max( coord_array, 0 )
    y_min, y_max = min_max( coord_array, 1 )
    x = ( x_min + x_max ) / 2.0
    y = ( y_min + y_max ) / 2.0
    [x, y]
  end
end

モジュールのメソッドの使い方は require でファイルを読み込んだ後、include 文でモジュールのメソッドを導入します。次のテストスクリプトでは DrawCommon モジュールのメソッドを利用します。ruby testcommon.rb で起動したら先ずアイテムをキャンバス上に作成し、それから center ボタンをクリックした後でアイテムをクリックしてください。中心部に黄色い丸が表示され、コンソールには中心の座標が表示されます。

ファイル名: testcommon.rb

require 'tkmove4.rb'
require 'draw_common.rb'
include TestBed
include DrawCommon

c = TkCanvas.new.pack
move = TkMove.new( c )
testbed( c )
TkButton.new(nil, 'text'=>'center', 'command'=>proc{
  move.unbind
  c.itembind('item', '1', proc{
    item = c.find_withtag('current').shift
    coord_array = get_item_coords( item )
    cx, cy = center( coord_array )
    puts [cx, cy].inspect
    cnt = TkcOval.new(c, cx-3, cy-3, cx+3, cy+3, 'fill'=>'yellow')
    cnt.addtag('item')
    cnt.addtag( cnt.id )
  })
}).pack
Tk.mainloop

アイテムを線形変換する

ドロープログラムで使われる拡大縮小や回転等の操作は全て線形写像として統一的に扱うことができます。変換前の点の座標ベクトルを[x, y]、線形写像の行列を A = [a, b, c, d](本来なら[[a,b],[c,d]]と二次元配列で表現するべきところですが、プログラムの都合で一次元配列にしています)とすると変換後の座標 [u, v] は u = a*x + b*y, v = c*x + d*y となります。アイテムの線形変換をすることができるように draw_common.rb に次のメソッドを追加します。

module DrawCommon
  def set_coords( item, coord_array )
    eval "item.coords( #{ coord_array.flatten.join(',')} )"
  end
  def axis_shift( coord_array, cntr )
    x0, y0 = cntr
    coord_array.collect{|c| [ c[0]-x0, c[1]-y0 ] }
  end
  def axis_shift_reverse( coord_array, cntr )
    x0, y0 = cntr
    coord_array.collect{|c| [ c[0]+x0, c[1]+y0 ] }
  end
  def linear_map( coord_array, matrix )
    cntr = center( coord_array )
    temp = axis_shift( coord_array, cntr )
    temp = temp.collect{|c|
      x = c[0]*matrix[0] + c[1]*matrix[1]
      y = c[0]*matrix[2] + c[1]*matrix[3]
      [x, y]
    }
    axis_shift_reverse( temp, cntr )
  end
end

set_coords メソッドは item アイテムの位置情報を coord_array 二次元配列で置き換えます。axis_shift メソッドは coord_arrey の座標を中心点 cntr が原点になるように座標変換します。axis_sift_reverse メソッドは axis_shift メソッドで変換した座標を元の位置に戻します。アイテムの座標の線形写像をおこなう linear_map メソッドはアイテムの座標 coord_arry に matrix 行列で線形変換をかけます。

次の test_draw_common.rb スクリプトを動かしてみてください。まずキャンバスに適当なアイテムを作成します。次に enlarge ボタンをクリックした後アイテムをクリックするとアイテムの大きさが拡大されます。また、rotate ボタンをクリックした後アイテムをクリックすると、アイテムが 90 度回転します。

ファイル名: test_draw_common.rb

require 'tkmove4.rb'
include TestBed
require 'draw_common.rb'
include DrawCommon

c = TkCanvas.new.pack
testbed( c )
move = TkMove.new( c )

f = TkFrame.new.pack
TkButton.new(f, 'text'=>'enlarge', 'command'=>proc{
  move.unbind
  c.itembind('item', '1', proc{
    item = c.find_withtag('current').shift
    coord = get_item_coords( item )
    temp = linear_map( coord, [1.2,0,0,1.2] )
    set_coords( item, temp )
  })
}).pack('side'=>'left')
TkButton.new(f, 'text'=>'rotate', 'command'=>proc{
  move.unbind
  c.itembind('item', '1', proc{
    item = c.find_withtag('current').shift
    coord = get_item_coords( item )
    temp = linear_map( coord, [0, -1, 1, 0] )
    set_coords( item, temp )
  })
}).pack('side'=>'left')
Tk.mainloop

上のスクリプトではグループ化されたアイテムは操作できないので改良したのが次の group_enlarge.rb です。グループ化されたアイテムも拡大、回転、鏡面像にすることができます。最初に作った鏡面像も線形変換なので統一的に扱うことができます。また回転角度を90度にしたのは、矩形や楕円アイテムを多角形アイテムと同じように扱うためです。自由な角度で回転させることもできますが、矩形や楕円のアイテムには適用できません。group_enlarge.rb を作動させるために draw_common.rbに次のメソッドを追加しました。線形変換の中心点の座標を cntr 引数に渡すことによって、グループ全体の座標変換をその中心点を基準に行うことができます。

module DrawCommon
  def linear_map_with_center( coord_array, matrix, cntr )
    temp = axis_shift( coord_array, cntr )
    temp = temp.collect{|c|
      x = c[0]*matrix[0] + c[1]*matrix[1]
      y = c[0]*matrix[2] + c[1]*matrix[3]
      [x, y]
    }
    axis_shift_reverse( temp, cntr )
  end
end

group_enlarge.rb のソースです。

ファイル名: group_enlarge.rb

require 'tkmove4.rb'
include TestBed
require 'draw_common.rb'
include DrawCommon

def transform_at_cursor( c, matrix )
  item = c.find_withtag('current').shift
  tags = item.gettags
  a_tag = tags.pop
  while ( a_tag == 'current' or a_tag == 'selected' )
    a_tag = tags.pop
  end
  if a_tag.kind_of? Numeric
    coord = get_item_coords( item )
    temp = linear_map( coord, matrix )
    set_coords( item, temp )
  else
    coord = get_item_coords( item )
    cntr = center( coord )
    items = c.find_withtag( a_tag )
    items.each{|itm|
      coord = get_item_coords( itm )
      temp = linear_map_with_center( coord, matrix, cntr )
      set_coords( itm, temp )
    }
  end
end

c = TkCanvas.new.pack
testbed( c )
move = TkMove.new( c )

f = TkFrame.new.pack
TkButton.new(f, 'text'=>'enlarge', 'command'=>proc{
  move.unbind
  c.itembind('item', '1', proc{
    transform_at_cursor( c, [1.2, 0, 0, 1.2] )
  })
}).pack('side'=>'left')
TkButton.new(f, 'text'=>'rotate', 'command'=>proc{
  move.unbind
  c.itembind('item', '1', proc{
    transform_at_cursor( c, [0, -1, 1, 0] )
  })
}).pack('side'=>'left')
TkButton.new(f, 'text'=>'mirror', 'command'=>proc{
  move.unbind
  c.itembind('item', '1', proc{
    transform_at_cursor( c, [-1, 0, 0, 1] )
  })
}).pack('side'=>'left')
Tk.mainloop

まとめ

ドロープログラムも役者が大体そろったようなのでもう一度まとめてみましょう。クラスは泣いて3度作り変えるそうですが、この程度なら泣く程のこともありません。最終改訂版の draw_common2.rb は次のようになります。アイテムの線形写像をおこなうメソッドも draw_common2.rb に取り込みました。また、enlarge, rotete_90, rotate_degree, mirror などのメソッドは線形写像のための行列を作るメソッドです。

ファイル名: draw_common2.rb

module DrawCommon
  def get_item_coords( item )
    coord = item.coords
    coord_array = []
    x = coord.shift
    y = coord.shift
    while x != nil
      coord_array.push( [x.to_f, y.to_f] )
      x = coord.shift
      y = coord.shift
    end
    coord_array
  end

  def set_coords( item, coord_array )
    eval "item.coords( #{ coord_array.flatten.join(',')} )"
  end

  def min_max( coord_array, index )
    temp = coord_array.collect{|c| c[index]}
    [ temp.min, temp.max ]
  end

  def center( coord_array )
    x_min, x_max = min_max( coord_array, 0 )
    y_min, y_max = min_max( coord_array, 1 )
    x = ( x_min + x_max ) / 2.0
    y = ( y_min + y_max ) / 2.0
    [x, y]
  end

  def axis_shift( coord_array, center )
    x0, y0 = center
    coord_array.collect{|c| [ c[0]-x0, c[1]-y0 ] }
  end

  def axis_shift_reverse( coord_array, center )
    x0, y0 = center
    coord_array.collect{|c| [ c[0]+x0, c[1]+y0 ] }
  end

  def linear_map( coord_array, matrix )
    center = center( coord_array )
    temp = axis_shift( coord_array, center )
    temp = temp.collect{|c|
      x = c[0]*matrix[0] + c[1]*matrix[1]
      y = c[0]*matrix[2] + c[1]*matrix[3]
      [x, y]
    }
    axis_shift_reverse( temp, center )
  end

  def linear_map_with_center( coord_array, matrix, center )
    temp = axis_shift( coord_array, center )
    temp = temp.collect{|c|
      x = c[0]*matrix[0] + c[1]*matrix[1]
      y = c[0]*matrix[2] + c[1]*matrix[3]
      [x, y]
    }
    axis_shift_reverse( temp, center )
  end

  def mirror
    [-1, 0, 0, 1]
  end

  def enlarge( ratio )
    [ ratio, 0, 0, ratio ]
  end

  def rotate_90
    [ 0, 1, -1, 0]
  end

  def rotate_degree( degree )
    radian = Math::PI * degree / 180.0
    cos = Math.cos( radian )
    sin = Math.sin( radian )
    [cos, sin, -sin, cos]
  end

  def transform( c, item, matrix )
    tags = item.gettags
    a_tag = tags.pop
    while ( a_tag == 'current' or a_tag == 'selected' )
      a_tag = tags.pop
    end
    if a_tag.kind_of? Numeric
      coord = get_item_coords( item )
      temp = linear_map( coord, matrix )
      set_coords( item, temp )
    else
      coord = get_item_coords( item )
      center = center( coord )
      items = c.find_withtag( a_tag )
      items.each{|itm|
        coord = get_item_coords( itm )
        temp = linear_map_with_center( coord, matrix, center )
        set_coords( itm, temp )
      }
    end
  end

  def transform_at_cursor( c, matrix )
    item = c.find_withtag('current').shift
    transform( c, item, matrix )
  end
end

draw_common2.rb 用のテストプログラムは group_enlarge2.rb です。30 degree ボタンを押してからアイテムをクリックすると、直線や折れ線、多角形などのアイテムを 30 度ずつ回転することができます。ただし、矩形や楕円アイテムに対しては 30 degree はうまく働きません。これは Tk の仕様なので仕方がありません。

ファイル名: group_enlarge2.rb

require 'tkmove4.rb'
include TestBed
require 'draw_common2.rb'
include DrawCommon

c = TkCanvas.new.pack
testbed( c )
move = TkMove.new( c )

f = TkFrame.new.pack
TkButton.new(f, 'text'=>'enlarge', 'command'=>proc{
  move.unbind
  c.itembind('item', '1', proc{
    transform_at_cursor( c, enlarge(1.2) )
  })
}).pack('side'=>'left')
TkButton.new(f, 'text'=>'rotate', 'command'=>proc{
  move.unbind
  c.itembind('item', '1', proc{
    transform_at_cursor( c, rotate_90 )
  })
}).pack('side'=>'left')
TkButton.new(f, 'text'=>'30 degree', 'command'=>proc{
  move.unbind
  c.itembind('item', '1', proc{
    transform_at_cursor( c, rotate_degree( 30 ) )
  })
}).pack('side'=>'left')
TkButton.new(f, 'text'=>'mirror', 'command'=>proc{
  move.unbind
  c.itembind('item', '1', proc{
    transform_at_cursor( c, mirror )
  })
}).pack('side'=>'left')
Tk.mainloop

Ruby/Tk にも大分慣れて、色々な楽しいプログラムが作れそうな気がしてきました。(2002/06/06)

tkdraw4.rbの改良

今までの改良をまとめたのが次のスクリプトです。下の3本のスクリプトをダウンロードしてから ruby tkdraw5.rb で起動してください。draw_common2.rb ではモジュールとしてまとめていたメソッドを、tkdrawcom.rb ではクラスライブラリーにしてあります。上の記事のように機能を少しずつ追加して行くときはメソッドをモジュールにまとめておくと便利ですが、メソッドの再利用をするときはクラスライブラリーにする方が便利なようです。tkdraw5.rb の各コマンドの使いかたは説明が要らないと思いますが、Size メニューの Rotate コマンドの使いかたがちょっと戸惑うかも知れません。コマンドを選択した後アイテムをクリックしてそのまま右にマウスをドラッグすると時計方向に回転します。左にドラッグすると反時計方向に回転します。

ファイル名: tkmove4.rb

ファイル名: tkdrawcom.rb

ファイル名: tkdraw5.rb

動作例

タングラム

おまけです。パズルのタングラムを作ってみました。右ボタンのドラッグでアイテムを移動できます。中ボタンでアイテムをクリックしてから左右にドラッグすると回転できます。右クリックでアイテムが裏返しになります。

tkdrawcomm.rb ではマウスの左ボタンに割り当ててあるメソッドを中ボタンに割り当てるために特異メソッドを利用しています。特異メソッドを使うとそのインスタンスについてだけ、メソッドを変更することができます。クラスライブラリーのメソッドを利用する側のプログラムで変更できるので、こういう応用プログラムを作るときはとても便利です。特異メソッドについてはRuby トレーニングでも触れています。

あんちもんさんのホームページにはたくさんのタングラムの問題があります。

ファイル名: tangram.rb

require 'tkmove4'
require 'tkdrawcom.rb'

def color(c)
  color = Tk.chooseColor
  break if color == ''
  items = c.find_withtag('item')
  items.each{|itm| itm.fill( color )}
end

def tangram( c )
  x1, x2, x3, x4, x5 = 50, 100, 150, 200, 250
  y1, y2, y3, y4, y5 = 50, 100, 150, 200, 250
  t1 = TkcPolygon.new(c, x1, y1, x3, y1, x1, y3, 'width'=>2, 'outline'=>'black', 'fill'=>'orange')
  t1.addtag('item'); t1.addtag( t1.id )
  t2 = TkcPolygon.new(c, x3, y1, x2, y2, x3, y3, x4, y2, 'width'=>2, 'outline'=>'black', 'fill'=>'orange')
  t2.addtag('item'); t2.addtag( t2.id )
  t3 = TkcPolygon.new(c, x3, y1, x5, y1, x4, y2, 'width'=>2, 'outline'=>'black', 'fill'=>'orange')
  t3.addtag('item'); t3.addtag( t3.id )
  t5 = TkcPolygon.new(c, x1, y3, x1, y5, x2, y4, x2, y2, 'width'=>2, 'outline'=>'black', 'fill'=>'orange')
  t5.addtag('item'); t5.addtag( t5.id )
  t6 = TkcPolygon.new(c, x2, y2, x2, y4, x3, y3, 'width'=>2, 'outline'=>'black', 'fill'=>'orange')
  t6.addtag('item'); t6.addtag( t6.id )
  t8 = TkcPolygon.new(c, x5, y1, x3, y3, x5, y5, 'width'=>2, 'outline'=>'black', 'fill'=>'orange')
  t8.addtag('item'); t8.addtag( t8.id )
  t9 = TkcPolygon.new(c, x1, y5, x3, y3, x5, y5, 'width'=>2, 'outline'=>'black', 'fill'=>'orange')
  t9.addtag('item'); t9.addtag( t9.id )
end

c = TkCanvas.new(nil, 'width'=>600, 'height'=>400, 'bg'=>'white')

m = TkMove.new( c )
g = TkdGroup.new( c )
cl = TkdColor.new( c )
rot = TkdRotate.new( c )
mirr = TkdMirror.new( c )

menu_spec = [
  [ ['File',0],
    ['Color', proc{ color(c) }],
    ['Exit', proc{ exit }]
  ]
]
mb = TkMenubar.new(nil, menu_spec, 'tearoff'=>false).pack('fill'=>'x', 'side'=>'top')

def rot.bind
  @canvas.itembind('item', '2', proc{|x, y| button_down(x, y)}, "%x %y")
  @canvas.itembind('item', 'B2-Motion', proc{|x, y| button_motion(x, y)}, "%x %y")
end

def mirr.bind
  @canvas.itembind('item', '3', proc{
    transform_at_cursor( mirror )
  })
end

m.bind
rot.bind
mirr.bind
TkRoot.new.title('Tangram')
TkRoot.new.cursor('hand2')
tangram(c)

c.pack
Tk.mainloop

このページはスクリプトを作りながら書いて行ったのでファイル名にやたらと番号がついてしまいました。そこで、ここで紹介した TkCanvas のインターフェース・クラスを一本にまとめた tkmove.rb とそのテスト用サンプルスクリプト tkdraw.rb を tar ball にしました。ここをクリックしてダウンロードしてください。(2002/06/14)