Преобразования рисовальщика
Преобразования рисовальщика
В используемой по умолчанию координатной системе рисовальщика QPainter точка (0, 0) находится в левом верхнем углу устройства рисования; значение координат x увеличивается при перемещении вправо, а значение координат у увеличивается при перемещении вниз. Каждый пиксель занимает область 1 ? 1 в координатной системе, применяемой по умолчанию.
Необходимо помнить об одной важной особенности: центр пикселя имеет «полупиксельные» координаты. Например, пиксель в верхнем левом углу занимает область между точками (0, 0) и (1, 1), а его центр находится в точке (0.5, 0.5). Если мы просим QPainter нарисовать пиксель, например, в точке (100, 100), его координаты будут смещены на величину +0.5 по обоим направлениям, и в результате нарисованный пиксель будет иметь центр в точке (100.5, 100.5).
На первый взгляд эта особенность представляет лишь теоретический интерес, однако она имеет важные практические последствия. Во-первых, смещение +0.5 действует только при отключении сглаживания линий (режим по умолчанию); если режим сглаживания линий включен и мы пытаемся нарисовать пиксель черного цвета в точке (100, 100), QPainter фактически выведет на экран четыре светло-серых пикселя в точках (99.5, 99.5), (99.5, 100.5), (100.5, 99.5) и (100.5, 100.5), чтобы создалось впечатление расположения пикселя точно в точке соприкосновения всех этих четырех пикселей. Если этот эффект нежелателен, его можно избежать, указывая полупиксельные координаты, например (100.5, 100.5).
При начертании таких фигур, как линии, прямоугольники и эллипсы, действуют аналогичные правила. На рис 8.7 показано, как изменяется результат вызова drawRect(2, 2, 6, 5) в зависимости от ширины пера, когда сглаживание линий отключено. В частности, важно отметить, что прямоугольник 6 ? 5, вычерчиваемый пером с шириной 1, фактически занимает область размером 7 ? 6. Это не делалось прежними инструментальными средствами, в том числе в ранних версиях Qt, но такой подход существенен для получения действительно масштабируемой, независимой от разрешающей способности векторной графики.
Рис. 8.7. Вычерчивание прямоугольника 6 ? 5 при отсутствии сглаживания линий.
Теперь, когда мы ознакомились с используемой по умолчанию координатной системой, мы можем внимательно рассмотреть возможные ее изменения при использовании рисовальщиком QPainter области отображения, окна и универсальной матрицы преобразования. (В данном контексте термин «окно» не является обозначением окна виджета верхнего уровня, а термин «область отображения» никак нe связан с областью отображения QScrollArea.)
Термины «область отображения» и «окно» сильно связаны друг с другом. Область отображения является произвольным прямоугольником, заданным в физических координатах. Окно определяет такой же прямоугольник, но в логических координатах. При рисовании мы задаем координаты точек в логической системе координат, и эти координаты с помощью линейного алгебраического преобразования переводятся в физическую систему координат на основе текущих настроек связи «окно—область отображения».
По умолчанию область отображения и окно устанавливаются на прямоугольную область устройства рисования. Например, если этим устройством является виджет размером 320 ? 200, область отображения и окно представляют собой одинаковый прямоугольник 320 ? 200, верхний левый угол которого располагается в позиции (0, 0). В данном случае системы логических и физических координат совпадают.
Механизм «окно—область отображения» удобно применять для создания программного кода, который не будет зависеть от размера или разрешающей способности устройства рисования. Например, если мы хотим обеспечить логические координаты в диапазоне от (—50, —50) до (+50, +50) с (0, 0) в середине, мы можем задать окно следующим образом:
painter.setWindow(-50, -50, 100, 100):
Пара аргументов (—50, —50) задает начальную точку, а пара аргументов (100, 100) задает ширину и высоту. Это означает, что точка с логическими координатами (—50, —50) теперь соответствует точке с физическими координатами (0, 0), а точка с логическими координатами (+50, +50) соответствует точке с физическими координатами (320, 200). В этом примере мы не изменяли область отображения.
Рис. 8.8. Преобразование логических координат в физические координаты.
Теперь очередь дошла до универсальной матрицы преобразования. Эта матрица используется как дополнение к преобразованию «окно—область отображения». Она позволяет нам перемещать начало координат, изменять масштаб, поворачивать и обрезать графические элементы. Например, если бы нам понадобилось отобразить текст под углом 45°, мы бы использовали такой программный код:
QMatrix matrix;
matrix.rotate(45.0);
painter.setMatrix(matrix);
painter.drawText(rect, Qt::AlignCenter, tr("Revenue"));
Логические координаты, которые мы передаем функции drawText(), преобразуются при помощи универсальной матрицы и затем переводятся в физические координаты, используя связь «окно—область отображения».
Если мы задаем несколько преобразований, они осуществляются в порядке поступления. Например, если мы хотим использовать точку (10, 20) в качестве точки поворота, мы можем перенести начало координат окна, выполнить поворот и затем сделать обратный перенос начала координат окна, устанавливая его в прежнее положение.
QMatrix matrix;
matrix.translate(-10.0, -20.0);
matrix.rotate(45.0);
matrix.translate(+10.0, +20.0);
painter.setMatrix(matrix);
painter.drawText(rect, Qt::AlignCenter, tr("Revenue"));
Более удобно осуществлять преобразования путем применения соответствующих функций класса QPainter — translate(), scale(), rotate() и shear():
painter.translate(-10.0, -20.0);
painter.rotate(45.0);
painter.translate(+10.0, +20.0);
painter.drawText(rect, Qt::AlignCenter, tr("Revenue"));
Но если мы хотим регулярно делать одни и те же преобразования, то они будут выполняться более эффективно при их хранении в объекте QMatrix и затем применении универсальной матрицы преобразования для рисовальщика всякий раз, когда требуется выполнить преобразование.
Рис. 8.9. Виджет OvenTimer.
Для иллюстрации преобразования рисовальщика мы рассмотрим программный код виджета OvenTimer (таймер духовки), показанного на рис. 8.9. Виджет OvenTimer имитирует кухонные таймеры, которые широко использовались до того, как в духовках стали применяться встроенные часы. Пользователь может повернуть ручку и установить требуемую длительность. Диск переключателя автоматически поворачивается против часовой стрелки, пока не достигнет отметки 0, в результате чего OvenTimer генерирует сигнал timeout().
01 class OvenTimer : public QWidget
02 {
03 Q_OBJECT
04 public:
05 OvenTimer(QWidget *parent = 0);
06 void setDuration(int secs);
07 int duration() const;
08 void draw(QPainter *painter);
09 signals:
10 void timeout();
11 protected:
12 void paintEvent(QPaintEvent *event);
13 void mousePressEvent(QMouseEvent *event);
14 private:
15 QDateTime finishTime;
16 QTimer *updateTimer;
17 QTimer *finishTimer;
18 }
Класс OvenTimer наследует QWidget и переопределяет две виртуальные функции: paintEvent() и mousePressEvent().
const double DegreesPerMinute = 7.0;
const double DegreesPerSecond = DegreesPerMinute / 60;
const int MaxMinutes = 45;
const int MaxSeconds = MaxMinutes * 60;
const int UpdateInterval = 1;
Мы начнем с нескольких констант, управляющих внешним видом и режимом работы таймера духовки.
01 OvenTimer::OvenTimer(QWidget *parent)
02 : QWidget(parent)
03 {
04 finishTime = QDateTime::currentDateTime();
05 updateTimer = new QTimer(this);
06 connect(updateTimer, SIGNAL(timeouf()),
07 this, SLOT(update()));
08 finishTimer = new QTimer(this);
09 finishTimer->setSingleShot(true);
10 connect(finishTimer, SIGNAL(timeout()),
11 this, SIGNAL(timeout()));
12 connect(finishTimer, SIGNAL(timeout()),
13 updateTimer, SLOT(stop()));
14 }
В конструкторе мы создаем два объекта QTimer: updateTimer используется для обновления внешнего вида виджета через каждую секунду, a finishTimer генерирует сигнал виджета timeout() при достижении отметки 0. Объект finishTimer должен генерировать только один сигнал тайм-аута, поэтому мы вызываем setSingleShot(true); по умолчанию таймеры запускаются повторно, пока они не будут остановлены или не будут уничтожены. Последний вызов connect() является оптимизационным и обеспечивает прекращение обновления виджета каждую секунду, когда таймер неактивен.
01 void OvenTimer::setDuration(int secs)
02 {
03 if (secs > MaxSeconds) {
04 secs = MaxSeconds;
05 } else if (secs <= 0) {
06 secs = 0;
07 }
08 finishTime = QDateTime::currentDateTime().addSecs(secs);
09 if (secs > 0) {
10 updateTimer->start(UpdateInterval * 1000);
11 finishTimer->start(secs * 1000);
12 } else {
13 updateTimer->stop();
14 finishTimer->stop();
15 }
16 update();
17 }
Функция setDuration() выставляет таймер духовки, задавая требуемое количество секунд. Время окончания мы рассчитываем путем добавления продолжительности его работы к текущему времени, полученному функцией QDateTime::currentDateTime(), и сохраняем его в закрытой переменной finishTime. B конце мы вызываем update() для перерисовки виджета с новой продолжительностью работы.
Переменная finishTime имеет тип QDateTime. Поскольку она содержит дату и время, мы избегаем ошибки из-за смены суток, когда текущее время оказывается до полуночи, а время окончания — после полуночи.
01 int OvenTimer::duration() const
02 {
03 int secs = QDateTime::currentDateTime().
04 secsTo(finishTime);
05 if (secs < 0)
06 secs = 0;
07 return secs;
08 }
Функция duration() возвращает количество секунд, оставшееся до завершения работы таймера. Если таймер неактивен, мы возвращаем 0.
01 void OvenTimer::mousePressEvent(QMouseEvent *event)
02 {
03 QPointF point = event->pos() - rect().center();
04 double theta = atan2(-point.x(), -point.y()) * 180 / 3.14159265359;
05 setDuration(duration() + int(theta / DegreesPerSecond));
06 update();
07 }
Если пользователь щелкает по этому виджету, мы находим ближайшую метку, используя тонкую, но эффективную математическую формулу, а результат идет на установку новой продолжительности таймера. Затем мы генерируем событие перерисовки. Метка, по которой щелкнул пользователь, теперь будет располагаться сверху поворотного диска и будет поворачиваться против часовой стрелки до тех пор, пока не будет достигнуто значение 0.
01 void OvenTimer::paintEvent(QPaintEvent * /* event */)
02 {
03 QPainter painter(this);
04 painter.setRenderHint(QPainter::Antialiasing, true);
05 int side = qMin(width(), height());
06 painter.setViewport((width() - side) / 2, (height() - side) / 2,
07 side, side);
08 painter.setWindow(-50, -50, 100, 100);
09 draw(&painter);
10 }
B paintEvent() мы устанавливаем область отображения на максимальный квадрат, который можно разместить внутри виджета, и мы устанавливаем окно на прямоугольник (—50, —50, 100, 100), то есть на прямоугольник с размерами 100 ? 100, который покрывает пространство от точки (—50, —50) до точки (+50, +50). Шаблонная функция qMin возвращает наименьшее из двух значений аргументов. Затем мы вызываем функцию draw() для фактического вывода рисунка на экран.
Рис. 8.10. Вид виджета OvenTimer при трех различных размерах.
Если область отображения не была бы квадратом, таймер духовки принял бы форму эллипса, когда форма виджета перестанет быть квадратной после изменения его размеров. Чтобы избежать такой деформации, мы должны устанавливать область отображения и окно на прямоугольник с одинаковым соотношением сторон.
Теперь давайте рассмотрим программный код рисования:
01 void OvenTimer::draw(QPainter *painter)
02 {
03 static const int triangle[3][2] = {
04 { -2, -49 }, { +2, -49 }, { 0, -47 }
05 };
10 QPen thickPen(palette().foreground(), 1.5);
11 QPen thinPen(palette().foreground(), 0.5);
12 QColor niceBlue(150, 150, 200);
13 painter->setPen(thinPen);
14 painter->setBrush(palette().foreground());
15 painter->drawPolygon(QPolygon(3, &triangle[0][0]));
Мы начинаем с отображения маленького треугольника в позиции 0 в верхней части виджета. Этот треугольник задается в программе тремя фиксированными координатами, и мы используем функцию drawPolygon() для его воспроизведения.
Одно из удобств применения механизма «окно—область отображения» заключается в том, что мы можем при программировании в командах рисования жестко задавать координаты точек и тем не менее добиваться необходимого изменения размеров.
16 QConicalGradient coneGradient(0, 0, -90.0);
17 coneGradient.setColorAt(0.0, Qt::darkGray);
18 coneGradient.setColorAt(0.2, niceBlue);
19 coneGradient.setColorAt(0.5, Qt::white);
20 coneGradient.setColorAt(1.0, Qt::darkGray);
21 painter->setBrush(coneGradient);
22 painter->drawEllipse(-46, -46, 92, 92);
Мы рисуем внешнюю окружность и заполняем ее, используя конический градиент. Центр градиента находится в точке (0, 0), а его угол равен —90°.
23 QRadialGradient haloGradient(0, 0, 20, 0, 0);
24 haloGradient.setColorAt(0.0, Qt::lightGray);
25 haloGradient.setColorAt(0.8, Qt::darkGray);
26 haloGradient.setColorAt(0.9, Qt::white);
27 haloGradient.setColorAt(1.0, Qt::black);
28 painter->setPen(Qt::NoPen);
29 painter->setBrush(haloGradient);
30 painter->drawEllipse(-20, -20, 40, 40);
Мы заполняем внутреннюю окружность, используя радиальный градиент. Центр и фокус градиента располагаются в точке (0, 0). Радиус градиента равен 20.
31 QLinearGradient knobGradient(-7, -25, 7, -25);
32 knobGradient.setColorAt(0.0, Qt::black);
33 knobGradient.setColorAt(0.2, niceBlue);
34 knobGradient.setColorAt(0.3, Qt::lightGray);
35 knobGradient.setColorAt(0.8, Qt::white);
36 knobGradient.setColorAt(1.0, Qt::black);
37 painter->rotate(duration() * DegreesPerSecond);
38 painter->setBrush(knobGradient);
39 painter->setPen(thinPen);
40 painter->drawRoundRect(-7, -25, 14, 50, 150, 50);
41 for (int i = 0; i <= MaxMinutes; ++i) {
42 if (i % 5 == 0) {
43 painter->setPen(thickPen);
44 painter->drawLine(0, -41, 0, -44);
45 painter->drawText(-15, -41, 30, 25,
46 Qt::AlignHCenter | Qt::AlignTop,
47 QString::number(i));
48 } else {
49 painter->setPen(thinPen);
50 painter->drawLine(0, -42, 0, -44);
51 }
52 painter->rotate(-DegreesPerMinute);
53 }
54 }
Мы вызываем функцию rotate() для поворота системы координат рисовальщика. В старой системе координат нулевая отметка находилась сверху; теперь нулевая отметка перемещается для установки соответствующего времени, которое остается до срабатывания таймера. После каждого поворота мы снова рисуем ручку таймера, поскольку его ориентация зависит от угла поворота.
В цикле for мы рисуем минутные отметки по внешней окружности и отображаем количество минут через каждые 5 минутных меток. Текст размещается в невидимом прямоугольнике под минутной отметкой. В конце каждой итерации цикла мы поворачиваем рисовальщик по часовой стрелке на 7°, что соответствует одной минуте. При рисовании минутной отметки следующий раз она будет отображаться в другом месте окружности, хотя мы передаем одни и те же координаты функциям drawLine() и drawText().
В этом программном коде в цикле for имеется незаметная погрешность, которая быстро стала бы очевидной, если бы мы выполнили больше итераций. При каждом вызове rotate() мы фактически умножаем текущую универсальную матрицу преобразования на матрицу поворота, получая новую универсальную матрицу преобразования. Ошибка округления чисел с плавающей точкой еще больше увеличивает неточность универсальной матрицы преобразования. Ниже показан один из возможных способов решения этой проблемы путем перезаписи программного кода с использованием save() и restore() для сохранения и восстановления первоначальной матрицы преобразования на каждом шаге итерации:
41 for (int i = 0; i <= MaxMinutes; ++i) {
42 painter->save();
43 painter->rotate(-i * DegreesPerMinute);
44 if (i % 5 == 0) {
45 painter->setPen(thickPen);
46 painter->drawLine(0, -41, 0, -44);
47 painter->drawText(-15, -41, 30, 25,
48 Qt::AlignHCenter | Qt::AlignTop,
49 QString::number(i));
50 } else {
51 painter->setPen(thinPen);
52 painter->drawLine(0, -42, 0, -44);
53 }
54 painter->restore();
55 }
При другом способе реализации таймера духовки нам нужно было бы самим рассчитывать координаты (x, y), используя функции sin() и cos() для определения их позиции на окружности. Но тогда нам все же пришлось бы выполнять перенос и поворот системы координат для отображения текста под некоторым углом.