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)