2019年12月14日 星期六

用 Qt Graphics 做一個顯示座標的工具 - 細節調整

其實這篇才是我寫文的主因,前面兩篇其實都是前言(欸),總之就是我埋頭自幹 AZ 大大吃一頓飯就寫得出來的工具,結果過程中寫得亂七八糟,有時候該出現的東西就是不出現測到頭很痛,或是測一測就進無窮迴圈後來發現是自己蠢,發了 v0.1 之後被靠北顯示怎麼這麼醜,想改又很難搜不到解法QQ。
當然最後還是搜到了,但就希望可以多寫一篇文,讓這些解法更容易被搜到,如果運氣很好真的幫到人也算功德一件,如果你真的被幫到的話,麻煩幫我這篇文章留下一個 like 然後順手按下旁邊的訂閱和小鈴鐺(醒醒這裡是 blogger 不是 youtube。

畫上座標線:

這算是一個附加功能,一般的顯示工具只要設定 Scene 的 background brush,設定一個黑色的 brush,就能畫出黑色背景了。
但如果我們要更精細的話,就要去實作 scene 或是 view 的 drawBackground 函式:
void drawBackground(QPainter *painter, const QRectF &rect)

下面是我實作的程式碼節錄,有幾個可以注意的地方:
  1. Qt 的座標系統左上角是 0,0,往右往下是 x 遞增跟 y 遞增,所以用 top 的值會比 bottom 小。
  2. 這個函式參數是一個 QPainter 跟一個 QRectF,painter 好理解就是目前作畫的對象,在這個 painter 上面作畫就會畫在背景上;QRectF 一般會認為就是現在顯示背景的矩形,不過不對,它是「現在要更新背景的範圍的矩形」。
    例如我現在顯示的 Scene 大小是 -50,-50 ~ 50, 50,但我實作滑鼠可以在 scene 上面拉一個選取的小框框,在我拉框框的時候,Qt 會判定只有這個小框框裡面的背景是需要重畫的,QRectF 就會設定到這個小框框上;其實某種程度來看這樣也對,而且更節省重畫的資源。
  3. 承上點,所以有抓到現在 scene 的大小要怎麼辦?這也是我為什麼選擇是實作 View 的 drawBackground 而不是 Scene 的,因為我可以透過 mapToScene(viewport()->rect()).boundingRect() 取得這個 View 現在顯示 Scene 的大小。
後來就是一些數學計算,在對應的座標點上呼叫 drawPoint 了,下面的 code 我有略為刪節過了,要用的話不要全抄。
void
MyViewer::drawBackground(QPainter *painter, const QRectF &rect) {
  qreal left         = rect.left();
  qreal right      = rect.right();
  qreal top         = rect.top();
  qreal bottom = rect.bottom();

  QRectF sceneRect = mapToScene(viewport()->rect()).boundingRect();
  qreal size = qMax(sceneRect.width(), sceneRect.height());
  qreal step = qPow(10, qFloor(log10(size/4)));

  qreal snap_l = qFloor(left / step) * step;
  qreal snap_r = qFloor(right / step) * step;
  qreal snap_b = qFloor(bottom / step) * step;
  qreal snap_t = qFloor(top / step) * step; 

  // print coordinate point
  for (qreal x = snap_l; x <= snap_r; x += step) {
    for (qreal y = snap_t; y <= snap_b; y += step) {
      painter->drawPoint(x, y);
    }
  }

  // print coordinate line
  painter->drawLine(qFloor(left), 0, qCeil(right), 0);
  painter->drawLine(0, qCeil(bottom), 0, qFloor(top));
  QGraphicsView::drawBackground(painter, rect);
}

不動物件:

第一個是所謂的不動物件,也就是 QGraphicsItem 透過設定了 ItemIgnoresTransformations flag,這樣這個 item 就不會受到 view 視角變化的影響。

使用情境也很單純,像是在畫面上打個 marker 或是寫上文字,如果視窗縮小就看不到就奇怪了,所以這個 marker 就要設定這個 flag,放大縮小都會顯示一樣。
改變也就是呼叫一下
setFlag(QGraphicsItem::ItemIgnoresTransformations, true);
就可以了。

要注意的是在設定了這個 flag 之後,在這個 item 裡面的位移似乎會失去效果(還是行為會變很怪,我有點忘了),一般要在一個位置例如 100, 100 畫一個正方形,我們可以用 QGraphicsRectItem,在 100, 100 的地方畫正方形;如果是 ignore transformation 的物件,我是變成在 0,0 的位置畫一個正方形,然後把物件的位置用 setPos 設定在 100, 100。
這部分當初真的弄超久,後來覺得這樣不行,把放在 dropbox 裡面的 <c++ GUI Programming With Qt 4> 拿出來翻翻,沒想到在第八章 Qt graphics 章節就講了要怎麼寫類似的東西,還有範例 code ,果然寫程式還是要多看書而不是瞎攪和,弄了好一陣子的東西其實書上的範例都寫了。

填充物:

上一篇文的最後一張圖,應該很明顯可以看到,我用 brush 填進去的東西,非常的…不均勻,一格一格的非常醜,實際運作的 code 也是,只要放大縮小填充物的就會變得不連續。
這是因為 QBrush 在填東西的時候,用固定密度在填充,不會隨著螢幕的放大縮小改變填充物的密度,要修成也只需要一行,在 paint 函式裡面加上這個:
QBrush m_brush;
m_brush.setTransform(QTransform(painter->worldTransform().inverted()));
painter->setBrush(m_brush)
把現在場景的變形反轉補償回去就可以了;這個解答出自 Stack Overflow

隨放大縮小調整長度:

同樣的是另一張圖的箭頭,在放大縮小的時候,箭頭的部分會跟著放大縮小,這是我們不想要的,因為縮太小的時候箭頭會看不到,這時候就要用到我們上篇提到的 QStyleOptionGraphicsItem,在 paint 函式裡面,可以用這個東西從 painter 的 transform 裡取得 level of detail (LOD):
qreal qScale = option->levelOfDetailFromTransform(painter->worldTransform());
qreal len = 10 / qScale;
qScale 就是目前放大值,可以用它調整我們要畫的長度 len;這個解答出自 Heresy's Space

下面列一下參考書目:
  • C++ GUI Programming With Qt 4
  • Game Programming using Qt 5 Beginner's Guide: Create amazing games with Qt 5, C++, and Qt Quick

2019年12月11日 星期三

用 Qt Graphics 做一個顯示座標的工具 - 客製化元件

上一篇我們介紹了 Scene, View, Item 的關係,這篇就來客製化一下,畢竟 Qt 的元件沒客製化功能都非常受限,預設行為幾乎什麼都沒有,這時候就是好好重溫 C++ 最美妙功能––繼承的時候了。
首先是 Scene 跟 View,都用繼承的方式建一個自己的 class,才能在裡面實作各種信號跟插槽,設計上 Scene 是 View 的 data member,View 上面接到什麼東西直接 pass 給 Scene,實作就不一一介紹,下面是一個簡單的修改列表,因為是內部用的工具就沒辦法把程式碼貼上來給大家笑了

View實作事件:

  • keyPressEvent:客製化按鍵盤的行為。
  • wheelEvent:連接到放大縮小的函式。


View實作插槽:

  • clearScene:清空畫面
  • addItem:接收讀進來的物件,往下直接呼叫 Scene 的 addItem。
  • zoomToAll 跟 zoomRect:放大縮小。


Scene 實作事件:

  • mousePressEvent/mouseMoveEvent/mouseReleaseEvent:定義滑鼠行為。


Scene 實作信號:

  • rectSelected:滑鼠事件會發出這個信號,通知 View 跟 MainWindow。
  • mouseClick:同樣用來通知 MainWindow 使用者點在哪裡。


當然我們也要客製化自己的 QGraphicsItem,當然如果要顯示的東西沒太多特別要求,只靠 Qt 提供的那些 QGraphicsXXXItem 也是 OK 的。
我記得在實作時候也有考慮過是不是繼承 QAbstractGraphicsShapeItem 就好,後來好像因為什麼原因,還是繼承 QGraphicsItem。

照著 Qt 的說明文件,要實作自己的 QGraphicsItem,重點在打造兩個函式:
QRectF boundingRect() const override
這個函式要回傳一個 QRectF,標示這個 Item 的大概位置,讓 Scene 能用它作分類跟檢索,如果設錯的話就有可能變成 item 明明在某個位置視窗卻打死不顯示它,因為 Scene 在檢索的時候就不認為這個 Item 有需要顯示。
void paint(
  QPainter *painter,
  const QStyleOptionGraphicsItem *option,
  QWidget *widget) override
Paint 就是操作 Painter 裡面的函式盡情的作畫,不過我自己是省得麻煩,都是創一個 QPainterPath 然後呼叫 Painter 的 drawPath,這樣比較簡單。
舉例來說我自己實作的一個物件是在視窗上面標上一個箭頭然後顯示文字,大略來說程式碼就是這樣:
QPainterPath path;
path.moveTo(0, 0);
path.lineTo(0, 8);
path.lineTo(5.656, 5.656);
path.closeSubpath();
path.lineTo(7.65, 18.48); // len 20, tilt 22.5
path.setFillRule(Qt::WindingFill);
QFont serif("Helvetica", 12);
path.addText(QPoint(0, 0), serif, m_text);
paint 裡再用 drawPath 把這條 path 畫出來就可以了。

Paint 的另外兩個參數 QStyleOptionGraphicsItem 和 QWidget,前者帶著顯示上要用的參數,在下篇做一些細部設定的時候會用到;後者則指向目前繪圖中的物件,通常可以不用管它,我也還不確定什麼時候會用到它。
這次實作全面採用 C++ 的新關鍵字 override,個人認為真的好用,像是上面的 boundingRect 如果沒寫 const 的話其實是在實作不同的函式,加了 override 編譯器就會跳錯誤,比較不會犯這種不容易注意到的錯。

實作 QGraphicsItem 以我們的應用這樣就夠了,如果還需要更精細的管理,可以再實作函式:
QPainterPath shape() const
這個函式用來檢查碰撞、滑鼠有沒有點在物件上等等,不實作預設就是以 boundingRect 代替。

自己幹完 QGraphicsItem 之後,整個程式也有了個樣子了,繼承自 QGraphicsItem 的物件可以直接用 addItem 塞進 Scene 裡面,下面截兩張運作起來的樣子,分別是顯示箭頭跟一個三角形的多邊形:



還有很多細部設定沒做所以看起來會有一點粗糙,下一篇預定就是要講這些細部的東西。

2019年12月5日 星期四

用 Qt Graphics 做一個顯示座標的工具

故事是這樣的,平常小弟在公司處理的東西多半是一些 polygon, line 之類的資料,除錯的時候總不能看著 gdb 印出來的 x, y 座標 debug,所以公司同仁有自幹一套 debug 的工具幫忙把這些資料畫出來;不過呢…這套工具好像在記憶體模型那邊有點問題,資料量大的時候會變超慢;畫圖是直接 call xlib,每次放大縮小都要重畫所有物件,對記憶體的負擔又更嚴重。只要 polygon 數量突破幾萬個的時候,一次的 refresh 就會花上好幾秒。

前陣子手上暫時沒其他緊急的事情,乾脆就用 Qt Graphics 重寫一個,不準在留言問我為什麼不用 nodejs 寫,MaDer 公司的工作站就沒有 nodejs。
完工後自己試了一陣發現幾十萬個物件的時候放大縮小都超流暢,不愧是 Qt Graphics,雖然程式行數比我預期的多了些,但架構比本來的東西清楚很多。
其實過程中一直參考強者我同學 AZ 大大的 QCamber,覺得 AZ大大實在太過神猛狂強溫,5-6 年前就寫得出這麼複雜的 project,我一直覺得我寫 code 的時候的整體感很不夠,都是在單一 class 裡面塗塗抹抹,小地方會動可是大架構沒辦法在一開始就訂好,後續要修改的成本就非常高。 Anyway 總之它現有個樣子了,我覺得中間碰過一些實作的問題值得記錄一下,預計可能寫個三篇左右吧。

----

首先圖形顯示的部分,使用的是 Qt 的 graphics framework,可以用來繪製大量的 2D 物件,支援選取、縮放等等,我們這裡只是要顯示而已,也不用搞得這麼複雜。
Graphics framework 裡的三個基本元件就是:
  • QGraphicsScene:場景,可以把它想成一塊巨大的畫布,可以在上面自由放上各種 item,Scene 會幫你管理物件的顯示和更新,個人經驗 Scene 負擔到 10 萬個元件左右還很流暢,上到百萬個的時候就會有點頓了(又或者是我把所有 item 都放在 scene 裡面的關係)。
  • QGraphicsItem:物件,可以想像成畫素描的時候放的那些石膏,在一個場景上擺上東西,Qt 有提供基本的幾種物件:橢圓 QGraphicsEllipseItem、路徑 QGraphicsPathItem、多邊形 QGraphicsPolygonItem、矩形 QGraphicsRectItem跟文字 QGraphicsSimpleTextItem。
  • QGraphicsView:View 是唯一可以在 Qt 的 MainWindow 畫面上出現的物件,可以把 View 想成一台相機,場景 Scene 是不動的,相機從各種角度自由取景,並把取到的景顯示出來,如果取景的尺寸比畫面還要大,跟其他的物件一樣, View 能自動出現捲軸,也可以接收畫面上的滑鼠、鍵盤事件。
只用 Qt 原生的 QGraphicsScene, QGraphicsView, QGraphicsItem 只能組出最基本的顯示工具,變化量非常少,以下就示範一個最基本的設定:
#include <QApplication>
#include <QGraphicsView>
#include <QGraphicsScene>
#include <QGraphicsRectItem>

int main(int argc, char *argv[]) {
  QApplication app(argc, argv);
  QGraphicsScene *scene = new QGraphicsScene;
  scene->setSceneRect(0, 0, 400, 400);
  scene->addItem(new QGraphicsRectItem(50, 50, 150, 100));

  QGraphicsView *view = new QGraphicsView;
  view->setScene(scene);
  view->show();

  return app.exec();
}
編譯執行就可以看到這個畫面:

反正這個設計只是一開始建來試驗用的,看一下顯示的效果,很快下一篇就會被我們拆掉了。