[C#] GUI 中的 .RotateTransform() 方法無旋轉中心參數

在用 C# 練習時鐘問題,要將圖片旋轉時發現 Graphics.RotateTransform() 方法竟然只有一個參數就是角度(另一個多載也沒有),如上圖上方是 C#,下方是 Java,促使我研究了一下,其實不難。首先先瞭解一下電腦中的坐標系是向右為 x 軸正向,向下是 y 軸正向,如下圖:

有摸 GUI 的人應該很熟悉了,其實四個象限存在,只是視窗可視的部份是 x,y 皆正,上圖我用黃色區域表示第一象限。如果要把一張圖片(這裡我用台灣翠青旗)畫出來,要求旗子的左上角定位在 (20, 30) P 這一點,如下圖:

這非常簡單,C# 的話用 DrawImage() 方法:

namespace RotateDemo
{
  public partial class Form1 : Form
  {
    Image flag;
    public Form1()
    {
      try
      {
        flag = Image.FromFile("flag.png");
      }
      catch(Exception)
      {
      }
    }
    private void Form1_Paint(object sender, PaintEventArgs e)
    {
      Graphics gfx = e.Graphics;
      gfx.DrawImage(flag, 20, 30);    // 重點在這行
    }
  }
}

接下來我都省略其他部份著重在 gfx 的動作,如果要將形狀為矩形的旗子正中央定位在 (20, 30),則 DrawImage 坐標的引數減去矩形寬與高的一半即可:

  
  gfx.DrawImage(flag, 20-38/2, 30-26/2);   // 假定圖片(旗子)大小是寬38,高26,不精準的示意尺寸
  

以上兩個可以利用 Graphics 的 .TranslateTransform() 平移變換方法。想像一下 Graphics 有一張自己的畫布,和螢幕上可視的視窗不一樣,最開始原點 O' 和視窗上的原點 O 重合,兩軸稱作 u 軸和 v 軸,分別與視窗上的 x 軸和 y 軸重合,如下:

Graphics 的畫布我用紅色來表示,若跑一行如下的指令:

  
  gfx.TranslateTransfrom(20, 30);     // 將畫布的原點平移 (20,30)
  

這時就好像做如下圖的動作:

畫布仍然是無限延伸,只是我聚焦在原點 O' 附近,如上圖,畫布的兩軸 u 軸和 v 軸移出來了,這時畫布原點 O'(u,v) = (0, 0) ,但對原來視窗的坐標系來說是 P(20, 30),如果這時候在畫布的 O'(0, 0) 畫圖,就會是畫在視窗的 P(20, 30),程式碼如下:

  
  gfx.TranslateTransform(20, 30);
  gfx.DrawImage(flag, 0, 0);        // 注意是畫在(0,0)
  

結果如下圖:

但又為何要多此一舉?我一行就可以畫為什麼要拆成兩行?會有這個考慮的原因是 C# 的圖片旋轉是預設對畫布 O' 為旋轉中心,導致大部份的旋轉不符合期待,尤其對自己中心旋轉的自體旋轉,若跑下面程式:

  
  gfx.RotateTransform(30);       // 對原點O'旋轉30度
  gfx.DrawImage(flag, 20, 30);   // 沿u軸20單位、沿v軸30單位,畫上flag圖案
  

因為沒有指定中心(也不能指定)所以對 O' 旋轉,這時再畫上旗子,並不是一般期待的矩形自身旋轉,結果會是這樣:

很多時候我們期待的是將矩形中心定位在某處,而且對自己中心旋轉,例如下圖是圖形的中央定位在 (20, 30),且對自己旋轉 30 度。

如何修正?

修正不難,大概下面的步驟:

  1. 平移畫布使畫布原點為視窗上要讓圖片對他旋轉的點
  2. 旋轉畫布
  3. 將畫布反方向平移圖片矩形寬與高的一半
  4. 畫圖片

1. 用例子來操作一次,先執行:

  
  gfx.TranslateTransfrom(20, 30);     // 將畫布的原點平移 (20, 30)
  

此時畫布的原點 O' 即視窗上的 P(20, 30),是等下圖片希望繞著旋轉的中心點:

2. 接著是旋轉30度,程式碼為:

  
  gfx.RotateTransform(30);       // 對原點O'旋轉30度
  

因為前面步驟有將畫布的原點改變,所以畫布的旋轉恰對著視窗的 (20, 30)旋轉 30 度:

3. 接著是反方向平移,這個動作的目的是讓畫布原點退回去能夠在下一步畫圖時,使得圖片的旋轉中心是前面的 P(20, 30),所以利用寬高的一半 38/2 = 19 和 26/2 = 13,程式碼為:

  
  gfx.TranslateTransfrom(-19, -13);    // 將畫布的原點平移(-38/2, -26/2)
  

要注意的是這個平移是沿著畫布的 u 軸和 v 軸,從原來視窗坐標系來看其實是斜的(因為前面旋轉了30度),沿著 u 軸平移 -19 單位,沿著 v 軸平移 -13 單位,結果如下:

4. 最後把圖片畫上去即可,要注意畫在畫布的 (0, 0) 即可,這也是上一步驟的用意

  
  gfx.DrawImage(flag, 0, 0);
  

結果如下,這樣就符合本來的期待了:矩形對自己中心旋轉 30 度,並將中心定位在視窗的 (20, 30),畫出圖片:

上面四個步驟程式碼如下,可考慮寫成一個方法反覆使用:

  
  gfx.TranslateTransfrom(20, 30);
  gfx.RotateTransform(30);
  gfx.TranslateTransfrom(-19, -13);
  gfx.DrawImage(flag, 0, 0);
  // gfx.ResetTransform();  可清除以上平移和旋轉的變換,使得畫布回到最初位置,以利後續作畫
  

如果不是圖片矩形的中央來作為旋轉中心呢?也是操作上面步驟,只是數字要調整一下,下面是我用 C# 做的時鐘,鐘底是 google 取到的圖片,指針靠小畫家 mspaint 和 paint.net 去背:

以分針為例,分鐘的圖片寬為266,高為37,指針圈圈的中心點也就是旋轉中心是(18, 18);而時鐘鐘面的圖片寬為780,高為780,正中央為(390, 390),如上述資料搭配上面的操作,則分針繪製的程式碼為:


  gfx.TranslateTransfrom(390, 390);   // 將畫布平移到原點為時鐘鐘面正中央
  gfx.RotateTransform(thetaMin);      // 分鐘數換算得到的旋轉角度
  gfx.TranslateTransfrom(-18, -18);   // 平移逆向量,使得下一步驟繪圖時能夠將指針圈圈置於時鐘中心
  gfx.DrawImage(imgMin, 0, 0);
  

留言