tkmove3.rb に手を入れているうちにドロー・プログラムを作ってみたくなりました。Ruby/Tk で本格的なドロー・プログラムを作るのは速度的に無理だそうですが、絵を描くのは他のソフトでやることにして、プログラム作りを楽しんでみましょう。
ドロー・プログラムらしく見えるように tkmove3.rb と tkdraw.rb を改良したのが、tkmove4.rb と tkdraw4.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)
今までの改良をまとめたのが次のスクリプトです。下の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)