English
preview
Создание графической библиотеки для MetaTrader 5 по образцу Cairo (Часть 2): Точки, контуры и путь

Создание графической библиотеки для MetaTrader 5 по образцу Cairo (Часть 2): Точки, контуры и путь

MetaTrader 5 — Интеграция |
46 0
Sandro Begashvili
Sandro Begashvili

Содержание


Введение

В первой части мы описали принятое в Cairo разделение того, что нужно нарисовать, и того, куда выводится результат. Затем мы создали два вспомогательных файла: Color.mqh с типом для представления цвета и Surface.mqh с реализацией поверхности, которая связывает пиксельный буфер с одним графическим объектом. В завершение мы напрямую записывали пиксели во вложенных циклах и рисовали треугольник и круг с помощью отдельных функций.

Эта демонстрация выявила два ограничения:

  • границы получались ступенчатыми, поскольку каждый пиксель считался либо полностью внутри фигуры, либо полностью снаружи, без учета частичного покрытия;
  • подход не масштабируется, поскольку для каждой новой фигуры нужны отдельная функция рендеринга, собственные циклы, границы обработки и логика работы с ребрами.

В этой части мы устраним второе ограничение, введя единое представление геометрии фигур. В третьей части единая процедура заливки будет использовать это представление, и отдельный код рендеринга для каждого вида фигур больше не понадобится. В этой части мы напишем код файла Path.mqh. К концу этой части библиотека сможет описывать геометрию, но пока не сможет ее заливать.

Это разделение сделано намеренно. Заливку мы реализуем в третьей части, после того как определим геометрию, с которой она будет работать.

Вот что будет в библиотеке по итогам сегодняшней работы:

Color.mqh      Часть 1    тип цвета ARGB, uint 0xAARRGGBB
Surface.mqh    Часть 1    пиксельный буфер, связанный с одним графическим объектом
Path.mqh       Часть 2    точки, контуры, MoveTo / LineTo / Close <- сегодня


Что такое путь, а что им не является

Путь — это геометрическое описание одного или нескольких обводок. Путь не должен хранить сведения о толщине обводки, цвете или целевом буфере. Если объединить эти задачи с описанием геометрии, возможности рендеринга придется реализовывать отдельно в коде для каждого вида фигур.

Поэтому у пути в этой библиотеке нет:

  • цвета;
  • толщины;
  • правила заливки;
  • пикселей.

Это набор точек, сгруппированных в обводку. За рендеринг отвечает отдельный код, который работает с полученной геометрией. Это соответствует разделению, введенному в первой части: прямоугольники, звезды, круги и глифы можно представить в виде геометрических данных и обработать одним и тем же пайплайном рендеринга.

Есть и второе, более практическое преимущество, которое проявляется раньше: путь требует мало ресурсов и допускает повторное использование. Это описание, а не рисунок, поэтому его можно построить один раз, а затем многократно заливать — разными цветами, в разных позициях, с разными правилами заливки — без повторного вычисления геометрии. Информационная панель, которая отображает одинаковые обводки карточек в сорока строках, строит такую обводку только один раз.

На протяжении всей серии применима такая аналогия: путь — это партитура, а растеризатор — инструмент. В партитуре ничего не сказано о том, насколько громко играть, на каком инструменте и в каком помещении. Поэтому одну и ту же партитуру можно исполнить на фортепиано или силами оркестра, а один и тот же путь — залить одним цветом, залить градиентом или, на следующих этапах, обвести.


Точка и почему ее координаты должны иметь тип double

Вот полное определение типа:

struct SCairoPoint
{
    double x;
    double y;
};

Точка хранит обе координаты в виде чисел типа double. Альтернатива — хранить координаты как целые числа:

struct SCairoPoint    // NOT this
{
    int x;
    int y;
};

Целочисленные координаты занимали бы меньше памяти, и их обработка могла бы быть немного менее затратной. Поскольку положения экранных пикселей задаются целыми числами, поначалу может показаться, что целочисленных координат достаточно. CCanvas также использует целочисленные координаты, что ограничивает объем субпиксельной информации, доступной рендереру.

Чтобы понять почему, возьмем прямоугольник, левая граница которого находится на координате x = 10,35. Пиксель — это не точка, а маленький квадрат. Столбец пикселей с индексом 10 охватывает интервал от x = 10,0 до x = 11,0. Если фигура начинается на координате 10,35, то внутри нее находится часть столбца с индексом 10 от 10,35 до 11,0 — то есть 0,65 ширины столбца. Следовательно, правильный результат рендеринга таков:

column  9 :   0%   covered   ->  background
column 10 :  65%   covered   ->  65% of the way from background to shape color
column 11 : 100%   covered   ->  shape color
column 12 : 100%   covered   ->  shape color

В четвертой части мы используем эту же субпиксельную информацию для расчета покрытия и сглаживания границ.

Ступенчатость границ в первой части была следствием бинарного подхода к рендерингу и потери субпиксельной информации.

Рис. 1. Три способа хранения координаты одного и того же ребра — только тип double сохраняет дробную часть 0,35.

На первом фрагменте рисунка ребро проходит точно по границе пикселя, поэтому при хранении целочисленной координаты информация не теряется. На втором фрагменте исходная координата равна 10,35, но при сохранении в целочисленном формате дробная часть отбрасывается. В результате рендерер в обоих случаях получает одну и ту же сохраненную координату и не может различить эти случаи. На третьем фрагменте координата хранится как число типа double. Это позволяет сохранить информацию о частичном покрытии столбца с индексом 10 и отрисовать ребро в соответствии с геометрией.

Это влечет за собой два практических следствия:

  • Качество. Сглаживание полностью основано на этих дробных значениях покрытия. При целочисленном хранении сглаживать нечего, поскольку дробная часть, с которой можно было бы работать, не сохраняется.
  • Движение. При анимации виджет перемещается по панели на доли пикселя за кадр. При целочисленных координатах он может перемещаться только шагами в целый пиксель, что воспринимается как рывки. При координатах типа double виджет движется плавно, поскольку между смещениями на целый пиксель оттенки граничных пикселей меняются постепенно.

Даже если сложный значок содержит тысячи точек, его геометрические данные занимают мало памяти по сравнению с пиксельным буфером для отрисовки панели.

Работа с кривыми — еще одна причина сохранять координаты с плавающей точкой. При спрямлении кривой вершины полученной ломаной имеют дробные координаты (например, 12,3719… вместо 12). Если бы тип точки не позволял хранить такие значения, координаты кривых еще до рисования квантовались бы с шагом в один пиксель, и сглаживание не смогло бы компенсировать эту потерю точности.


Контуры — один путь, несколько обводок

Один путь может содержать несколько обводок. Каждая отдельная обводка называется контуром.

Контур представляет собой последовательность вершин и может быть незамкнутым или замкнутым. Замкнутый контур — это фигура с внутренней областью: прямоугольник, обводка глифа, отверстие. Незамкнутый контур — это линия, которая начинается в одной точке и заканчивается в другой: линия индикатора, кривая эквити. Оба варианта допустимы для пути. Путь хранит информацию о том, какие его контуры открыты, а какие замкнуты, и на последующих этапах библиотеки эти два случая обрабатываются по-разному.

Несколько контуров позволяют представлять ряд типовых структур:

  • буква O — внешняя обводка и отверстие внутри нее;
  • кольцо или кольцевая диаграмма — две окружности, одна внутри другой;
  • рамка — обводка и ее копия со смещением внутрь;
  • значок — очень часто около двенадцати отдельных частей;
  • составной индикатор — несколько несвязанных областей, которые нужно залить одним цветом за одну операцию.

Каждую такую структуру нужно представить одним путем, чтобы на последующих этапах при применении правил заливки правильно учитывалась взаимосвязь между ее контурами. Подробности мы рассмотрим в пятой части, но необходимую модель геометрии определим уже здесь.


Рис. 2. Буква A как один путь — два контура и два неявных замыкания.

На рисунке 2 в увеличенном масштабе показан пример D из демонстрационной программы. Два зеленых маркера обозначают первые вершины двух контуров. Каждый маркер соответствует вызову MoveTo(): один — для внешней обводки буквы, другой — для треугольного внутрибуквенного просвета в середине. Все остальные вершины — семь у внешней обводки и три у внутрибуквенного просвета — были добавлены с помощью LineTo().

Теперь обратите внимание на два красных отрезка. Ни один из них не был сохранен. Эти отрезки соединяют последнюю вершину каждого контура с его первой вершиной. Для этих отрезков в пути не хранится ни одной дополнительной вершины. В контуре сохраняется признак замкнутости обоих контуров. Ниже, в разделе о Close(), объясняется, почему этот признак важно сохранять.

Геометрия остается неизменной на последующих этапах. В третьей части мы зальем оба контура по исходному правилу заливки, а в пятой сможем интерпретировать ту же геометрию как фигуру с отверстием.


Хранение контуров — один плоский массив и индекс

Один из возможных вариантов — массив объектов контуров, каждый из которых содержит собственные точки. В этой реализации вместо этого используется плоское хранение:

private:
    SCairoPoint m_points[];       // vertices of every contour, back to back
    int         m_point_count;
    int         m_starts[];       // index where each contour begins
    bool        m_closed[];       // did the author call Close() on contour c?
    int         m_contour_count;

Один плоский массив хранит вершины всех контуров подряд, а второй массив, гораздо меньшего размера, — индексы начала каждого контура.

Третий массив содержит по одному значению bool для каждого контура. Он хранит информацию о том, был ли контур замкнут. Элементы этого массива соответствуют элементам m_starts: для контура c один массив хранит начальный индекс, а другой — флаг замкнутости. В разделе, посвященном Close(), объясняется, для чего нужен этот флаг.

Рис. 3. Один плоский массив вершин — индексы начала контуров хранятся в m_starts.

На рисунке 3 путь содержит три контура: с четырьмя, тремя и пятью вершинами — всего 12 вершин. Все они хранятся в m_points в одном непрерывном участке памяти. Массив m_starts содержит три числа — 0, 4 и 7. Каждое из них показано в столбце соответствующей вершины, поэтому связь видна без стрелок.

Правило чтения данных укладывается в одну строчку:

Контур c продолжается от m_starts[c] до m_starts[c + 1]
Последний контур продолжается до m_point_count

У этой структуры есть три практических преимущества:

Для добавления контура требуется только начальный индекс. Кроме того, при плоском хранении не нужен отдельный динамический массив точек для каждого контура. Плоское хранение позволяет растеризатору последовательно обходить все вершины, расположенные в непрерывном участке памяти. Это соответствует схеме доступа, необходимой при многократной обработке пути во время рендеринга.

Библиотеке в первую очередь требуются две операции: обход всего пути и обход отдельного контура. Единственное дополнительное вычисление — определение числа вершин контура по его начальному индексу и индексу начала следующего контура.

Примечание об увеличении массивов

В AddPoint() есть деталь, которую легко пропустить при беглом чтении:

if( ArraySize( m_points ) < i + 1 )
    if( ArrayResize( m_points, i + 1, 64 ) < i + 1 )
      {
       m_alloc_failed = true;
       return false;
      }

Третий аргумент функции ArrayResize() задает резерв памяти. Благодаря ему при последующем добавлении элементов можно использовать заранее выделенную память вместо немедленного изменения размера массива. Без резерва последовательное увеличение массива может приводить к многократному перевыделению памяти и копированию данных. Для небольших фигур эти затраты незначительны, но возрастают при спрямлении кривых с получением большого числа вершин.

ArrayResize() вызывается только тогда, когда текущей вместимости массива недостаточно; в противном случае AddPoint() просто сохраняет новую точку и увеличивает счетчик.

Возвращаемое значение проверяется, поскольку ArrayResize() может завершиться ошибкой. Если массив не удастся увеличить, запись в m_points[i] приведет к выходу за его границы. Такой сбой редок, но его последствия проявляются позже: другая функция обрабатывает путь, и панель отрисовывается некорректно. Поэтому точка не добавляется, а в пути сохраняется флаг неудачного выделения памяти.

Эти два значения легко перепутать, поэтому стоит один раз сформулировать правило. Значение m_point_count — это число записанных вершин. Значение ArraySize() — это вместимость массива. Обычно оно больше числа записанных вершин, поскольку резерв оставляет в конце место для новых данных. Код, который обходит путь, должен использовать PointCount(), а не ArraySize().


Создание файла Path.mqh

Ниже приведено полное содержимое файла Include/CairoG2D/Path.mqh в исходном порядке. Блоки комментариев сокращены, чтобы код и пояснительный текст не дублировали друг друга. Ничего не пропущено.

Защита от повторного включения и точка
#ifndef CAIRO5_PATH_MQH
#define CAIRO5_PATH_MQH

//+-------------------------------------------------------------------+
//| SCairoPoint - one vertex.                                         |
//+-------------------------------------------------------------------+
struct SCairoPoint
  {
   double            x;
   double            y;
  };

Имя макроса защиты от повторного включения соответствует соглашению, принятому в первой части. Эта защита предотвращает повторную обработку заголовочного файла. SCairoPoint — это простая структура данных с общедоступными полями координат и без методов.

Объявление класса
//+-------------------------------------------------------------------+
//| CCairoPath                                                        |
//+-------------------------------------------------------------------+
class CCairoPath
  {
private:
   //--- THE SIZE / CAPACITY INVARIANT.
   SCairoPoint       m_points[];       // vertices of every contour, back to back
   int               m_point_count;    // valid vertices, NOT ArraySize( m_points )
   int               m_starts[];       // index into m_points where each contour begins
   bool              m_closed[];       // did the author call Close() on contour c?
   int               m_contour_count;  // valid contours, NOT ArraySize( m_starts )
   double            m_cur_x;          // the "pen" position
   double            m_cur_y;
   bool              m_has_current;    // is there a pen position at all?
   bool              m_alloc_failed;   // sticky: some allocation was refused

   bool              AddPoint(const double x, const double y);

public:
                     CCairoPath();

   //-----------------------------------------------------------------
   //  LIFECYCLE
   //-----------------------------------------------------------------
   void              Clear();
   void              Release();
   bool              IsEmpty() const;

   //-----------------------------------------------------------------
   //  HEALTH
   //-----------------------------------------------------------------
   bool              IsValid()  const { return !m_alloc_failed; }
   bool              IsFinite() const;

   //--- capacity: ask for the room up front when the count is known
   bool              ReservePoints(const int capacity);
   bool              ReserveContours(const int capacity);

   //--- the primitive verbs
   void              MoveTo(const double x, const double y);
   void              LineTo(const double x, const double y);
   void              Close();

   //--- where is the pen right now, and is there one at all?
   double            CurrentX() const { return m_cur_x; }
   double            CurrentY() const { return m_cur_y; }
   bool              HasCurrentPoint() const { return m_has_current; }

   //--- shape helpers: pure path construction, no rasterising
   void              AddRect(const double x, const double y, const double w, const double h);
   void              AddTriangle(const double x0, const double y0,
                    const double x1, const double y1,
                    const double x2, const double y2);
   void              AddPolygon(const double &xs[], const double &ys[], const int n);
   void              AddPolyline(const double &xs[], const double &ys[], const int n);

   //-----------------------------------------------------------------
   //  INSPECTION
   //-----------------------------------------------------------------
   int               PointCount() const   { return m_point_count;   }
   int               ContourCount() const { return m_contour_count; }

   bool              GetPoint(const int i, SCairoPoint &pt) const;
   bool              GetContourInfo(const int c, int &start, int &length, bool &closed) const;
   bool              IsContourClosed(const int c) const;

   double            PointX(const int i) const { return m_points[i].x; }     // unchecked
   double            PointY(const int i) const { return m_points[i].y; }     // unchecked

   int               ContourStart(const int c) const;
   int               ContourLength(const int c) const;
  };

Здесь важны следующие проектные решения.

В m_point_count хранится число записанных вершин, которое учитывается отдельно от вместимости массива. Поскольку массивы с резервом могут содержать неиспользуемые элементы, ArraySize() не отражает число записанных точек. Метод AddPoint() закрытый, поэтому точки можно добавлять только через операции, сохраняющие структуру контуров. Он возвращает bool; причина объясняется ниже в описании MoveTo(). Флаг m_has_current показывает, задана ли вообще текущая позиция пера. Без этого флага пришлось бы проверять число контуров, но после замыкания контура такая проверка дала бы неверный ответ.

Флаг m_alloc_failed сохраняет установленное состояние. После отказа в выделении памяти флаг остается установленным до очистки пути, а IsValid() позволяет обнаружить ошибку. Благодаря этому код построения геометрии остается понятным: вспомогательные методы для построения фигур многократно вызывают LineTo() без проверок, а вызывающий код проверяет результат один раз в конце.

Класс также предоставляет два способа чтения вершины, и это различие сделано намеренно. Методы GetPoint() и GetContourInfo() проверяют индекс и возвращают false, если он выходит за пределы допустимого диапазона. Методы PointX() и PointY() ничего не проверяют. Для каждой строки сканирования растеризатор вызывает оба метода второй пары по одному разу для каждой вершины. Он сам вычисляет границы цикла, поэтому проверка лишь добавила бы затраты времени. В любом другом коде следует использовать первую пару.

Создание и сброс
//+------------------------------------------------------------------+
//| Constructor                                                       |
//+------------------------------------------------------------------+
CCairoPath::CCairoPath()
  {
   Clear();
  }

//+-------------------------------------------------------------------+
//| Reset to an empty path - but KEEP the memory.                     |
//+-------------------------------------------------------------------+
void CCairoPath::Clear()
  {
   m_point_count   = 0;
   m_contour_count = 0;
   m_cur_x         = 0.0;
   m_cur_y         = 0.0;
   m_has_current   = false;
   m_alloc_failed  = false;
  }

//+-------------------------------------------------------------------+
//| Clear, and hand the memory back to the terminal.                  |
//+-------------------------------------------------------------------+
void CCairoPath::Release()
  {
   Clear();
   ArrayResize(m_points, 0);
   ArrayResize(m_starts, 0);
   ArrayResize(m_closed, 0);
  }

//+-------------------------------------------------------------------+
//| True when nothing has been added yet.                             |
//+-------------------------------------------------------------------+
bool CCairoPath::IsEmpty() const
  {
   return (m_contour_count == 0);
  }

Конструктор вызывает Clear(), поэтому пустое состояние определяется в одном месте. Эту же операцию сброса можно повторно использовать при каждой очистке пути.

Метод Clear() обнуляет счетчики и не затрагивает массивы. Весь сброс сводится к обнулению счетчиков: вершины за пределами учитываемого числа недоступны, поэтому стирать данные не требуется. Память, уже выделенная пути, сохраняется.

Это важно при отрисовке панели. Информационная панель, которая перестраивает свою геометрию при каждом тике, очищает один и тот же путь тысячи раз в час. Если бы при очистке также освобождалась память массивов, при каждой перестройке ее пришлось бы освобождать и выделять заново в том же объеме, что и мгновение назад. Если сохранять память, затраты на ее выделение приходятся на первый кадр, а в последующих кадрах их уже нет.

Парный метод для Clear() — Release(). Он очищает путь и освобождает память. Используйте его, когда путь действительно больше не нужен — например, после разового импорта или при удалении кэшированной фигуры. Если неясно, какой из двух методов выбрать, используйте Clear().

Метод IsEmpty() проверяет число контуров, а не число точек.

AddPoint — само перо
//+-------------------------------------------------------------------+
//| Append a vertex and make it the current pen position.             |
//+-------------------------------------------------------------------+
bool CCairoPath::AddPoint(const double x, const double y)
  {
   const int i = m_point_count;

   if(ArraySize(m_points) < i + 1)
      if(ArrayResize(m_points, i + 1, 64) < i + 1)
        {
         m_alloc_failed = true;     // out of memory - refuse, do not write
         return false;
        }

   m_points[i].x = x;
   m_points[i].y = y;
   m_point_count++;

   m_cur_x       = x;
   m_cur_y       = y;
   m_has_current = true;

   return true;
  }

Два последних присваивания обновляют текущую точку.

Метод LineTo() использует текущую точку. В дальнейшем она также будет служить неявной начальной точкой для операций с кривыми.

Это соглашение также используется в таких форматах и API, как SVG, PostScript и Cairo. Таким образом, хранение и обновление текущей точки обеспечивает совместимость модели пути с будущими операциями над кривыми и внешними представлениями пути.

Здесь также устанавливается флаг m_has_current, поскольку текущая позиция пера появляется именно при сохранении вершины.

Функция возвращает bool, но лишь в одном месте вызова требуется учитывать результат. MoveTo() увеличивает счетчик контуров до сохранения первой вершины. Если сохранить вершину не удается, это увеличение нужно отменить. Во всех остальных местах вызова результат игнорируется, а в конце выполняется проверка через IsValid().

MoveTo — поднимаем перо
//+-------------------------------------------------------------------+
//| MoveTo - begin a NEW contour at (x, y).                           |
//+-------------------------------------------------------------------+
void CCairoPath::MoveTo(const double x, const double y)
  {
   const int c = m_contour_count;

   if(ArraySize(m_starts) < c + 1)
      if(ArrayResize(m_starts, c + 1, 16) < c + 1)
        {
         m_alloc_failed = true;
         return;
        }

   if(ArraySize(m_closed) < c + 1)
      if(ArrayResize(m_closed, c + 1, 16) < c + 1)
        {
         m_alloc_failed = true;
         return;
        }

   m_starts[c] = m_point_count;
   m_closed[c] = false;
   m_contour_count++;

   if(!AddPoint(x, y))
      m_contour_count--;      // <- the reason AddPoint returns bool:
//    never leave an empty contour behind
  }

MoveTo() начинает новый контур — как если бы мы подняли перо и перенесли его в новую позицию, не рисуя соединительный отрезок. Для начала контура достаточно записать текущий индекс точки и сохранить первую вершину. Это прямое следствие модели плоского хранения.

Порядок имеет значение: начальный индекс должен быть записан до того, как AddPoint() увеличит значение m_point_count. Если поменять операции местами, начало контура сместится на одну вершину.

Новый контур также помечается как незамкнутый. Контур остается незамкнутым до вызова Close(). Инициализация флага в этом месте гарантирует, что его значение не будет прочитано до записи.

Последние две строчки отменяют создание контура, если не удалось сохранить его первую вершину. Без них при отказе в выделении памяти в середине пути остался бы контур без вершин, и каждый цикл обхода контуров должен был бы учитывать такой случай. При таком подходе правило проще: любой существующий контур содержит хотя бы одну вершину.

LineTo — ведем перо
//+-------------------------------------------------------------------+
//| LineTo - straight segment from the pen to (x, y).                 |
//+-------------------------------------------------------------------+
void CCairoPath::LineTo(const double x, const double y)
  {
   if(!m_has_current)
      MoveTo(x, y);
   else
      AddPoint(x, y);
  }

Обычно LineTo() добавляет точку в текущий контур. Если контура нет, метод создает его в указанной позиции.

Вызов LineTo() для пустого пути неявно начинает новый контур. Благодаря этому API допускает отсутствие начального вызова MoveTo(). В этой библиотеке замкнутый контур считается завершенным: его начальные координаты сохраняются, но активной текущей точки для последующих вызовов LineTo() уже нет.

Проверяется флаг m_has_current, а не число контуров: это разные условия. После Close() в пути остаются контуры, но текущая позиция пера уже не задана. Поэтому следующий вызов LineTo() начинает новый контур, а не добавляет вершину в уже завершенный автором.

Close — сохраняем признак замыкания
//+-------------------------------------------------------------------+
//| Close the current contour.                                        |
//+-------------------------------------------------------------------+
void CCairoPath::Close()
  {
   if(m_contour_count == 0)
      return;

   const int c = m_contour_count - 1;
   m_closed[c] = true;             // record the intent - this is the whole point

//--- Cairo semantics: the pen returns to where this contour began.
//--- The contour is finished, so the next LineTo opens a new one.
   const int s = m_starts[c];
   if(s < m_point_count)
     {
      m_cur_x = m_points[s].x;
      m_cur_y = m_points[s].y;
     }
   m_has_current = false;
  }

Close() не сохраняет ни одной вершины. Это важно сформулировать точно: "не сохраняет вершины" и "ничего не делает" — не одно и то же.

Замыкающий отрезок нигде не сохраняется. В третьей части при преобразовании контуров в ребра последняя вершина каждого контура будет автоматически соединяться с первой, поскольку для заливки нужна замкнутая граница. Красные отрезки на рисунке 2 — это как раз эти ребра. Их нет в m_points, и хранить их там не нужно.

Close() сохраняет признак явного замыкания, который нельзя получить из правила заливки. Ломаная, которая заканчивается там же, где началась, еще не является замкнутым контуром. А треугольник, вершины которого расположены далеко друг от друга, является замкнутым контуром. Массив вершин не позволяет различить эти два случая, хотя от этого различия зависит дальнейшее поведение: будет ли при обводке в этой вершине построено соединение или два концевых элемента, что запишет модуль экспорта в SVG и какой результат даст проверка попадания. Восстанавливать замысел автора постфактум пришлось бы по координатам, а такие догадки иногда оказываются неверными.

Поэтому у контура сохраняется признак замыкания, из чего следуют две вещи. При заливке каждый контур обрабатывается как замкнутый, поскольку иного варианта нет. На последующих этапах обработки код может определить, какие контуры автор замкнул явно, по сохраненному признаку, а не по догадкам.

Как и в Cairo, сохраненным текущим координатам присваиваются координаты первой вершины контура. При этом перо считается поднятым: m_has_current принимает значение false, поэтому следующий вызов LineTo() начинает новый контур.

Кроме того, функция остается в API по двум причинам:

  1. Она явно выражает замысел в месте вызова. Завершающий вызов Close() в AddRect() показывает читателю, что обводка завершена, а следующий вызов MoveTo() начинает новую.
  2. Она соответствует Cairo. В Cairo есть cairo_close_path, поэтому код, перенесенный из Cairo или SVG, читается так же — без мысленного перевода и незаметного изменения поведения.

Это различие станет важным при реализации обводки: у незамкнутых путей нужно оформлять концы, а у замкнутых — соединять последний и первый отрезки. Именно флаг позволяет выбрать нужный вариант, а для его хранения требуется одно значение bool на контур.

вспомогательные методы для построения фигур

Первая вспомогательная функция строит прямоугольник:

//+-------------------------------------------------------------------+
//| Axis-aligned rectangle.                                           |
//+-------------------------------------------------------------------+
void CCairoPath::AddRect(const double x, const double y, const double w, const double h)
  {
   MoveTo(x,     y);
   LineTo(x + w, y);
   LineTo(x + w, y + h);
   LineTo(x,     y + h);
   Close();
  }

Вспомогательная функция лишь формирует вершины пути. На этапах рендеринга используется полученная геометрия; отдельная логика для прямоугольников не нужна.

Для треугольника применяется тот же подход, но с тремя точками:

//+-------------------------------------------------------------------+
//| Triangle from three arbitrary points.                             |
//+-------------------------------------------------------------------+
void CCairoPath::AddTriangle(const double x0, const double y0,
                             const double x1, const double y1,
                             const double x2, const double y2)
  {
   MoveTo(x0, y0);
   LineTo(x1, y1);
   LineTo(x2, y2);
   Close();
  }

А теперь общий случай:

//+-------------------------------------------------------------------+
//| Arbitrary CLOSED polygon from parallel x / y arrays.              |
//+-------------------------------------------------------------------+
void CCairoPath::AddPolygon(const double &xs[], const double &ys[], const int n)
  {
//--- a FILLED polygon needs area; use AddPolyline for open shapes
   if(n < 3)
      return;

   if(ArraySize(xs) < n || ArraySize(ys) < n)         // fewer points than promised
      return;

   ReservePoints(m_point_count + n);                  // the count is known: ask once

   MoveTo(xs[0], ys[0]);

   for(int i = 1; i < n; i++)
      LineTo(xs[i], ys[i]);

   Close();
  }

//+-------------------------------------------------------------------+
//| Arbitrary OPEN polyline from parallel x / y arrays.               |
//+-------------------------------------------------------------------+
void CCairoPath::AddPolyline(const double &xs[], const double &ys[], const int n)
  {
//--- one point is not a line
   if(n < 2)
      return;

   if(ArraySize(xs) < n || ArraySize(ys) < n)
      return;

   ReservePoints(m_point_count + n);

   MoveTo(xs[0], ys[0]);

   for(int i = 1; i < n; i++)
      LineTo(xs[i], ys[i]);

//--- deliberately no Close(): this contour stays open, and the path
//--- now remembers that instead of leaving it to be guessed
  }

Любая фигура, которую можно описать как последовательность вершин, может быть представлена с помощью AddPolygon() и обработана на последующих этапах рендеринга. Поддержку кругов мы добавим позже по той же модели: круг аппроксимируется последовательностью вершин, а растеризатор остается без изменений. Многоугольники с числом вершин меньше трех не могут ограничивать область, поэтому AddPolygon() их игнорирует. Благодаря этой проверке вырожденные контуры не попадают на этап заливки.

Эта защитная проверка выполняется во вспомогательной функции, а не в самом пути. Многоугольник — это фигура с внутренней областью, а две вершины внутренней области не образуют, поэтому этой вспомогательной функции нечего строить. Это не означает, что пути обязательно должны быть замкнутыми. AddPolyline() принимает те же два массива и сохраняет те же вершины без замыкания. Для этого метода достаточно двух вершин, поскольку один отрезок — уже допустимый путь. Линия индикатора, кривая эквити или граница канала — это именно такая фигура, а не многоугольник.

Эти две вспомогательные функции отличаются одной строчкой, и в ней заключается все различие между незамкнутым и замкнутым вариантами. Наличие обоих вариантов позволяет вызывающему коду указать, какой именно нужен, вместо того чтобы библиотека решала это за него.

Обе вспомогательные функции также проверяют переданные им массивы. Если бы n превышало размер массива, цикл вышел бы за границы данных вызывающего кода, причем ошибка возникла бы внутри библиотеки. Одного сравнения достаточно, чтобы предотвратить такую ошибку.

Затем обе вспомогательные функции вызывают ReservePoints(). Число вершин известно до сохранения первой вершины, поэтому нужный объем памяти можно запросить сразу, а не увеличивать постепенно. Этот же метод стоит вызывать напрямую всякий раз, когда число вершин известно заранее — например, для спрямленной кривой, круга из 96 отрезков или обводки глифа. Обратите внимание, что именно резервирует метод: он увеличивает вместимость массива, не добавляя ни одной вершины, поэтому значение PointCount() не изменяется.

Примечание о порядке вершин

Каждая из перечисленных выше вспомогательных функций обходит обводку в определенном направлении: AddRect() последовательно обходит левый верхний, правый верхний, правый нижний и левый нижний углы. В экранных координатах, где y увеличивается вниз, это обход по часовой стрелке.

Такой порядок — важное соглашение: на последующих этапах при применении правил заливки учитывается направление обхода контура. При ненулевом правиле заливки важно относительное направление обхода двух контуров: отверстие нужно обходить в направлении, противоположном обходу содержащей его обводки. Благодаря тому что каждая вспомогательная функция формирует вершины в определенном порядке, вызывающий код может управлять относительным направлением обхода, а не выяснять его постфактум.

Библиотека не меняет направление обхода. Метод AddPolygon() сохраняет заданный порядок вершин. В пути, построенном вручную, вершины также остаются в порядке добавления. Направление выбирает вызывающий код, а в пятой части мы объясним, как его использовать.

Чтение данных пути

Остальные методы предоставляют доступ к границам контуров и вершинам для анализа:

//+-------------------------------------------------------------------+
//| Index of the first vertex of contour c.                           |
//+-------------------------------------------------------------------+
int CCairoPath::ContourStart(const int c) const
  {
   if(c < 0 || c >= m_contour_count)
      return 0;

   return m_starts[c];
  }

//+-------------------------------------------------------------------+
//| Number of vertices in contour c.                                  |
//+-------------------------------------------------------------------+
int CCairoPath::ContourLength(const int c) const
  {
   if(c < 0 || c >= m_contour_count)
      return 0;

   const int start = m_starts[c];
   const int end   = (c + 1 < m_contour_count) ? m_starts[c + 1] : m_point_count;

   return end - start;
  }
//+-------------------------------------------------------------------+
//| Ask for room for `capacity` vertices in total.                    |
//+-------------------------------------------------------------------+
bool CCairoPath::ReservePoints(const int capacity)
  {
   if(capacity <= ArraySize(m_points))
      return true;

   if(ArrayResize(m_points, capacity) < capacity)
     {
      m_alloc_failed = true;
      return false;
     }

   return true;
  }

//+-------------------------------------------------------------------+
//| The same, for the two per-contour arrays.                         |
//+-------------------------------------------------------------------+
bool CCairoPath::ReserveContours(const int capacity)
  {
   if(capacity <= ArraySize(m_starts) && capacity <= ArraySize(m_closed))
      return true;

   if(ArrayResize(m_starts, capacity) < capacity)
     {
      m_alloc_failed = true;
      return false;
     }

   if(ArrayResize(m_closed, capacity) < capacity)
     {
      m_alloc_failed = true;
      return false;
     }

   return true;
  }

//+-------------------------------------------------------------------+
//| Did the author call Close() on this contour?                      |
//+-------------------------------------------------------------------+
bool CCairoPath::IsContourClosed(const int c) const
  {
   if(c < 0 || c >= m_contour_count)
      return false;

   return m_closed[c];
  }

//+-------------------------------------------------------------------+
//| Read one vertex, safely.                                          |
//+-------------------------------------------------------------------+
bool CCairoPath::GetPoint(const int i, SCairoPoint &pt) const
  {
   if(i < 0 || i >= m_point_count)
      return false;

   pt = m_points[i];
   return true;
  }

//+-------------------------------------------------------------------+
//| Everything about one contour, in a single checked call.           |
//+-------------------------------------------------------------------+
bool CCairoPath::GetContourInfo(const int c, int &start, int &length, bool &closed) const
  {
   if(c < 0 || c >= m_contour_count)
      return false;

   start  = ContourStart(c);
   length = ContourLength(c);
   closed = m_closed[c];

   return true;
  }

Метод ContourLength() определяет конец контура по индексу начала следующего контура, а для последнего — по значению m_point_count. Оба метода доступа возвращают 0 при недопустимых индексах, предотвращая обращение за границы массива. Эти методы доступа предназначены для демонстрационных примеров, тестов и отладки. При обработке всех вершин растеризатор может напрямую обращаться к внутреннему хранилищу.

На первый взгляд это лишь небольшое удобство. Это не так. В шестой части мы будем спрямлять кривые. Самый быстрый способ понять адаптивное разбиение — отобразить вершины, выбранные алгоритмом спрямления, и увидеть, как они сгущаются на сильных изгибах и редеют на почти прямых участках. Именно эти несколько методов доступа позволяют увидеть распределение вершин. Впервые мы используем их в демонстрационном примере этой статьи.

Метод GetContourInfo() за одну проверку допустимости индекса дает три сведения, необходимые для обхода пути: где начинается контур, сколько в нем вершин и замкнут ли он. GetPoint() — это аналог PointX() и PointY() с проверкой индекса. Проверка требует одного сравнения и исключает выход за границы массива. Это оправданный компромисс везде, кроме внутреннего цикла растеризатора.

К этой группе относится еще один метод, хотя он считывает координаты, а не структурные данные:

//+-------------------------------------------------------------------+
//| Is every stored coordinate a real number?                         |
//+-------------------------------------------------------------------+
bool CCairoPath::IsFinite() const
  {
   for(int i = 0; i < m_point_count; i++)
      if(!MathIsValidNumber(m_points[i].x) ||
         !MathIsValidNumber(m_points[i].y))
         return false;

   return true;
  }

#endif // CAIRO5_PATH_MQH

Координаты в торговом терминале вычисляются, а не вводятся вручную. Их получают из буферов индикаторов, результатов деления и входных данных. В любом из этих случаев возможно значение NaN или бесконечность. Путь не проверяется при каждом вызове: иначе в наиболее часто выполняемый цикл растеризатора пришлось бы добавить проверку ошибки, за которую отвечает вызывающий код. Поэтому вводится правило: координаты должны быть конечными. Метод IsFinite() проверяет готовый путь за один линейный проход; его можно вызвать, если надежность исходных чисел вызывает сомнения.

Его стоит использовать. Обнаруженное здесь значение NaN — это ошибка, место которой известно. Если тот же NaN дойдет до растеризатора, панель останется пустой без сообщения об ошибке.


Что уже реализовано в первой части

В демонстрационном примере повторно используются файлы, представленные в первой части. В таблице приведены компоненты, необходимые для этой части:

Файл Часть, в которой создан файл Что из файла используется в этой части
Include/CairoG2D/Color.mqh Часть 1 CairoRgb, CairoArgb, соглашение о представлении цвета в uint в формате 0xAARRGGBB
Include/CairoG2D/Surface.mqh Часть 1 CCairoSurface: Create, Clear, m_px, Flush, Destroy
Include/CairoG2D/DemoTheme.mqh Часть 1 Светлая палитра, DemoUseLightChart, DemoLabel

Файл Path.mqh добавляется без изменений в существующих слоях цвета и поверхности. Это показывает, что слои остаются независимыми.

Демонстрационный пример — геометрия, которую мы пока не можем заливать

Это программа для просмотра каркасного представления сохраненной геометрии и ее проверки до реализации растеризатора.

Палитра и почему выбраны именно эти четыре цвета
//+-------------------------------------------------------------------+
//| THE WIREFRAME PALETTE.                                            |
//+-------------------------------------------------------------------+
#define C_GRID    CairoRgb( 231, 235, 239 )   // the faint measuring grid
#define C_EDGE    DEMO_BLUE                   // a segment the path stored
#define C_CLOSE   DEMO_RED                    // the closing segment of a closed contour
#define C_VERTEX  DEMO_AMBER                  // every vertex
#define C_START   DEMO_GREEN                  // the first vertex of a contour
Четыре цвета взяты из общей светлой темы, чтобы сохранить единое визуальное оформление всей серии.

Локально задается только цвет сетки. Сетка должна быть светлее всех цветов общей палитры, поскольку служит фоном для геометрии и не должна отвлекать от нее. На рисунке, где показаны обводки, сетка, привлекающая к себе внимание, лишь мешает.

Запись пикселя и построение отрезка

Функция SetPixel() не изменилась с первой части. Ее код повторяется здесь только потому, что ее используют вспомогательные функции рисования:

//+-------------------------------------------------------------------+
//| Write one pixel (unchanged from Part 1).                          |
//+-------------------------------------------------------------------+
void SetPixel(const int x, const int y, const uint argb)
  {
   if(x < 0 || y < 0 || x >= g_surface.Width() || y >= g_surface.Height())
      return;

   g_surface.m_px[ y * g_surface.Width() + x ] = argb;
  }

А вот функция построения отрезка:

//+-------------------------------------------------------------------+
//| DEMO CODE, NOT LIBRARY CODE.                                      |
//+-------------------------------------------------------------------+
void PlotSegment(const double x0, const double y0,
                 const double x1, const double y1, const uint argb)
  {
   const double dx = x1 - x0;
   const double dy = y1 - y0;

//--- step once per pixel along the longer axis
   const int steps = (int)MathMax(MathAbs(dx), MathAbs(dy)) + 1;

   for(int i = 0; i <= steps; i++)
     {
      const double t = (double)i / steps;

      SetPixel((int)MathRound(x0 + dx * t),
               (int)MathRound(y0 + dy * t), argb);
     }
  }

Этим вычислениям стоит посвятить один абзац: единственное нетривиальное решение здесь — выбор числа шагов. Если выполнять шаги вдоль оси с большей протяженностью отрезка, последовательные пиксели будут отстоять друг от друга не более чем на один пиксель, и линия получится непрерывной, а не пунктирной. При фиксированном числе шагов в длинной линии появились бы разрывы, а при построении короткой один и тот же пиксель записывался бы несколько раз.

Функция PlotMarker() еще проще: она рисует небольшой залитый квадрат, чтобы обозначить вершину:

//+-------------------------------------------------------------------+
//| DEMO CODE. A small filled square, to mark a vertex.               |
//+-------------------------------------------------------------------+
void PlotMarker(const double cx, const double cy, const int r, const uint argb)
  {
   for(int y = -r; y <= r; y++)
      for(int x = -r; x <= r; x++)
         SetPixel((int)MathRound(cx) + x,
                  (int)MathRound(cy) + y, argb);
  }
Главная функция этой статьи
//+-------------------------------------------------------------------+
//| THE HEART OF THIS DEMO.                                           |
//+-------------------------------------------------------------------+
void PlotPath(CCairoPath &path)
  {
//--- two questions worth asking once, before drawing anything.
//--- A path that failed to allocate is short of vertices; a path
//--- carrying a NaN plots as a shape that silently is not there.
   if(!path.IsValid())
     {
      Print("PlotPath: incomplete path - an allocation was refused");
      return;
     }

   if(!path.IsFinite())
     {
      Print("PlotPath: the path holds a coordinate that is not a number");
      return;
     }

   int  start  = 0;
   int  count  = 0;
   bool closed = false;

   SCairoPoint a, b;

   for(int c = 0; c < path.ContourCount(); c++)
     {
      //--- where does this contour start, how long is it, was it closed
      if(!path.GetContourInfo(c, start, count, closed))
         continue;

      if(count < 2)
         continue;

      //--- the stored segments
      for(int i = 0; i < count - 1; i++)
         if(path.GetPoint(start + i, a) && path.GetPoint(start + i + 1, b))

            PlotSegment(a.x, a.y, b.x, b.y, C_EDGE);

      //--- the closing segment, drawn only because Close() was called
      if(closed)
         if(path.GetPoint(start + count - 1, a) && path.GetPoint(start, b))
            PlotSegment(a.x, a.y, b.x, b.y, C_CLOSE);

      //--- the vertices
      for(int i = 1; i < count; i++)
         if(path.GetPoint(start + i, a))
            PlotMarker(a.x, a.y, 2, C_VERTEX);

      //--- and where this contour began
      if(path.GetPoint(start, a))
         PlotMarker(a.x, a.y, 3, C_START);
     }
  }

//+-------------------------------------------------------------------+
//| A faint background grid, so positions can be judged by eye.       |
//+-------------------------------------------------------------------+
void PlotGrid(const int step)
  {
   for(int x = 0; x < g_surface.Width(); x += step)
      for(int y = 0; y < g_surface.Height(); y++)
         SetPixel(x, y, C_GRID);

   for(int y = 0; y < g_surface.Height(); y += step)
      for(int x = 0; x < g_surface.Width(); x++)
         SetPixel(x, y, C_GRID);
  }
Функция PlotPath() работает только с контурами и вершинами. В этом проявляется главное преимущество представления геометрии в виде пути: код обработки не зависит от конкретных типов фигур.

Проверка count < 2 позволяет пропустить контур с одной вершиной, из которой нельзя построить отрезок.

Красный замыкающий отрезок рисуется, если у контура установлен признак замкнутости. Функции не передают отдельно, какие фигуры незамкнуты: она считывает флаг и действует в соответствии с его значением. В этом и заключается смысл хранения флага. Без него в демонстрационном примере потребовалось бы отдельное ветвление.

Две проверки в начале требуют мало ресурсов, и только здесь еще можно сообщить о некорректном пути. При рисовании неполного пути в фигуре будут отсутствовать вершины, но сообщения об этом не будет. Если путь содержит NaN, на экране вообще ничего не появится — тоже без объяснения причины.

Любой индекс в этой функции можно было бы передать в PointX() и PointY(): все индексы находятся в допустимом диапазоне. Тем не менее функция использует методы доступа с проверкой индекса: так и следует поступать в коде, который получает путь, построенный в другом месте. Растеризатор в третьей части будет использовать быструю пару методов. Он может обойтись без проверки, поскольку сам вычисляет границы цикла. При разработке инструментов для работы с путем берите за образец эту функцию.

Семь вариантов

В каждом варианте строится путь, отображается его геометрия и выводятся сведения о его содержимом. Вариант A — это минимально возможный пример:

//+-------------------------------------------------------------------+
//| A. THE SIMPLEST PATH                                              |
//+-------------------------------------------------------------------+
void CaseRect(const double ox, const double oy)
  {
   CCairoPath p;
   p.AddRect(ox, oy, 170, 110);

   PlotPath(p);

   PrintFormat("A. rectangle       : %d contour(s), %d vertices",
               p.ContourCount(), p.PointCount());

//--- what Close() left behind: the pen is back at the first vertex,
//--- and it is lifted, so the next LineTo would open a new contour
   PrintFormat("     after Close(): pen at %.1f, %.1f  pen down = %s",
               p.CurrentX(), p.CurrentY(),
               p.HasCurrentPoint() ? "true" : "false");
  }

У прямоугольника четыре вершины. Три отрезка хранятся явно, а четвертый, замыкающий, подразумевается.

Во второй строке вывода показано состояние после вызова Close(): перо вернулось к первой вершине и считается поднятым. Cледующий вызов LineTo() будет выполняться в этом состоянии и поэтому начнет новый контур.

В варианте B вычисляются десять вершин и передаются в путь как список без указания типа фигуры:

//+-------------------------------------------------------------------+
//| B. AN ARBITRARY POLYGON                                           |
//+-------------------------------------------------------------------+
void CaseStar(const double ox, const double oy)
  {
   double xs[10], ys[10];

   for(int i = 0; i < 10; i++)
     {
      const double r = (i % 2 == 0) ? 68.0 : 28.0;
      const double a = -M_PI * 0.5 + M_PI * i / 5.0;

      xs[i] = ox + 85 + r * MathCos(a);
      ys[i] = oy + 75 + r * MathSin(a);
     }

   CCairoPath p;
   p.AddPolygon(xs, ys, 10);

   PlotPath(p);

   PrintFormat("B. star            : %d contour(s), %d vertices",
               p.ContourCount(), p.PointCount());
  }

Звезда строится из десяти вычисленных вершин. В путь передаются только эти координаты; способ их вычисления для него не важен.

Вариант C демонстрирует, как один путь может содержать несколько контуров:

//+-------------------------------------------------------------------+
//| C. TWO CONTOURS IN ONE PATH                                       |
//+-------------------------------------------------------------------+
void CaseTwoContours(const double ox, const double oy)
  {
   CCairoPath p;

//--- two contours, eight vertices, and we know it before we start
   p.ReserveContours(2);
   p.ReservePoints(8);

   p.AddRect(ox,      oy,      190, 130);
   p.AddRect(ox + 22, oy + 22, 146,  86);

   PlotPath(p);

   PrintFormat("C. frame           : %d contour(s), %d vertices"
               "  <- one path, two outlines",
               p.ContourCount(), p.PointCount());

   for(int c = 0; c < p.ContourCount(); c++)
      PrintFormat("     contour %d: starts at index %d, %d vertices, closed = %s",
                  c, p.ContourStart(c), p.ContourLength(c),
                  p.IsContourClosed(c) ? "true" : "false");
  }

Два вызова AddRect() для одного объекта пути. Поскольку каждая вспомогательная функция начинается с вызова MoveTo(), второй прямоугольник не продолжает первый, а образует новый контур. Теперь путь содержит две обводки и восемь вершин. Дополнительный цикл выводит индекс начала, число вершин и флаг замыкания каждого контура. Так во время выполнения становится наглядной модель хранения с рисунка 3.

Два вызова резервирования памяти в начале не обязательны. Два прямоугольника — это восемь вершин. Их число известно до добавления первой вершины, поэтому в этом варианте нужный объем памяти запрашивается сразу. Для столь небольшого пути экономия ничтожна. Важна сама привычка: те же две строчки перед добавлением спрямленной кривой позволяют избежать нескольких перераспределений памяти.

Вариант D — это глиф с рисунка 2, построенный вручную так, чтобы два вызова MoveTo() были явно видны в исходном коде:

//+-------------------------------------------------------------------+
//| D. A MULTI-PART GLYPH                                             |
//+-------------------------------------------------------------------+
void CaseGlyph(const double ox, const double oy)
  {
   CCairoPath p;

//--- outer outline
   p.MoveTo(ox +  70, oy +   8);
   p.LineTo(ox + 128, oy + 140);
   p.LineTo(ox + 100, oy + 140);
   p.LineTo(ox +  88, oy + 110);
   p.LineTo(ox +  52, oy + 110);
   p.LineTo(ox +  40, oy + 140);
   p.LineTo(ox +  12, oy + 140);
   p.Close();

//--- the counter - the hole in the middle of the A
   p.MoveTo(ox +  60, oy +  86);
   p.LineTo(ox +  80, oy +  86);
   p.LineTo(ox +  70, oy +  50);
   p.Close();

   PlotPath(p);

   PrintFormat("D. glyph 'A'       : %d contour(s), %d vertices",
               p.ContourCount(), p.PointCount());
  }

Глиф шрифта — это и есть набор контуров, часть которых образует отверстия, и ничего больше. В TrueType к этому добавляются кривые, которые мы реализуем в шестой части, и правило определения контуров, образующих отверстия, которое рассмотрим в пятой части. Модель хранения не меняется.

Вариант E демонстрирует, что дробные части координат действительно сохраняются:

//+-------------------------------------------------------------------+
//| E. SUB-PIXEL COORDINATES ARE REAL                                 |
//+-------------------------------------------------------------------+
void CaseSubPixel(const double ox, const double oy)
  {
   Print("E. sub-pixel offsets stored in the path:");

   CCairoPath p;

   for(int i = 0; i < 6; i++)
     {
      const double x = ox + i * 40 + i / 6.0;

      p.Clear();                      // reset the geometry, keep the memory
      p.AddRect(x, oy, 26, 90);

      PlotPath(p);

      //--- index 0 is the vertex AddRect just wrote, so the fast
      //--- unchecked accessor is safe here by construction
      PrintFormat("     rect %d: x = %.4f  (plotted at pixel column %d)",
                  i, p.PointX(0), (int)MathRound(p.PointX(0)));
     }

   p.Release();                        // done with it - hand the memory back
  }

Шесть прямоугольников показаны с последовательно увеличивающимися смещениями на доли пикселя. В записанных координатах эти смещения сохраняются, а демонстрационная функция рисования округляет их до целых номеров столбцов пикселей. Это различие показывает, зачем в четвертой части вводится растеризация на основе покрытия.

Этот вариант также показывает, как следует повторно использовать путь. Один и тот же объект пути заполняется шесть раз. Clear() очищает путь, не освобождая память, поэтому память выделяется при построении первого прямоугольника, а для остальных пяти используется повторно. Советник, который перерисовывает изображение на каждом тике, должен быть написан именно так. Вызов Release() в конце корректен только потому, что работа с этим путем действительно завершена.

Вариант F завершает набор примеров и показывает путь, который не предполагается заливать:

//+-------------------------------------------------------------------+
//| F. AN OPEN CONTOUR                                                |
//+-------------------------------------------------------------------+
void CaseOpenPolyline(const double ox, const double oy)
  {
   CCairoPath wave;
   wave.ReservePoints(23);            // we know the count in advance

   wave.MoveTo(ox, oy);

   for(int i = 1; i <= 22; i++)
      wave.LineTo(ox + i * 11, oy + MathSin(i * 0.55) * 52);

//--- no Close() here, deliberately

   PlotPath(wave);

   PrintFormat("F. open polyline   : %d contour(s), %d vertices, closed = %s",
               wave.ContourCount(), wave.PointCount(),
               wave.IsContourClosed(0) ? "true" : "false");
  }

Путь также может представлять незамкнутую ломаную. Close() просто не вызывается, и это отражено в состоянии пути: красный отрезок не появляется, хотя в демонстрационном коде нет отдельного указания его пропустить. Сравните с вариантом A, где четыре вершины и один вызов Close() дают пятую сторону, которая не хранится в пути. Любой ценовой ряд, отображаемый линией, представляет собой такую ломаную.

В варианте G незамкнутая и замкнутая фигуры показаны рядом:

//+-------------------------------------------------------------------+
//| G. THE SAME VERTICES, TWO WAYS                                    |
//+-------------------------------------------------------------------+
void CasePolylineVsPolygon(const double ox, const double oy)
  {
   double xs[5], ys[5];

   for(int i = 0; i < 5; i++)
     {
      xs[i] = ox + i * 55;
      ys[i] = oy + ((i % 2 == 0) ? -34.0 : 0.0);
     }

   CCairoPath p;
   p.ReserveContours(2);
   p.ReservePoints(10);

//--- open: the vertices, and nothing else
   p.AddPolyline(xs, ys, 5);

//--- the same five vertices, shifted down and closed this time
   for(int i = 0; i < 5; i++)
      ys[i] += 90.0;

   p.AddPolygon(xs, ys, 5);

   PlotPath(p);

   int  start, count;
   bool closed;

   PrintFormat("G. polyline+polygon: %d contour(s), %d vertices",
               p.ContourCount(), p.PointCount());

   for(int c = 0; c < p.ContourCount(); c++)
      if(p.GetContourInfo(c, start, count, closed))
         PrintFormat("     contour %d: %d vertices from index %d, closed = %s  <- %s",
                     c, count, start, closed ? "true" : "false",
                     closed ? "AddPolygon" : "AddPolyline");
  }

Одни и те же пять вершин передаются обеим вспомогательным функциям. Верхний зигзаг построен методом AddPolyline() и не содержит красного отрезка. Нижний построен методом AddPolygon() и замыкается отрезком снизу. Они находятся в одном пути, а разница между ними заключается в одном вызове внутри библиотеки.

Одно изображение наглядно показывает различие между незамкнутым и замкнутым вариантами. По координатам невозможно определить, какой из них замкнут, а какой нет. Эта информация хранится в пути, потому что ее явно задал вызывающий код.

Формирование кадра
//+-------------------------------------------------------------------+
//| Compose the frame.                                                |
//|                                                                   |
//| Every call below writes into memory. Exactly one line - the final |
//| Flush - touches the terminal.                                     |
//+-------------------------------------------------------------------+
void Render()
  {
   g_surface.Clear(DEMO_BG);
   PlotGrid(20);

   Print("=== Part 2: paths built, nothing filled ===");

   CaseRect(40,  46);
   CaseStar(260,  46);
   CaseTwoContours(470,  40);
   CaseGlyph(700,  30);
   CaseSubPixel(40, 246);
   CaseOpenPolyline(320, 430);
   CasePolylineVsPolygon(600, 420);

   g_surface.Flush();
  }

В демонстрационном примере используется схема рендеринга из первой части: операции рисования изменяют пиксельный буфер, а один вызов Flush() обновляет графический объект.

Обработчик событий OnInit() переключает график на цветовую схему Black On White, создает поверхность, выполняет рендеринг и добавляет подписи:

//+-------------------------------------------------------------------+
//| Expert initialization function                                    |
//+-------------------------------------------------------------------+
int OnInit()
  {
   if(InpSetLightChart)
      DemoUseLightChart();

   if(!g_surface.Create("cairo_demo02", UI_X, UI_Y, UI_W, UI_H))
     {
      Print("Cairo5: failed to create the surface");
      return INIT_FAILED;
     }

   Render();

   if(InpShowCaptions)
      Captions();

   ChartRedraw();

   return INIT_SUCCEEDED;
  }

Для подписей используются объекты OBJ_LABEL, поскольку в библиотеке пока нет рендерера текста. Они служат только пояснениями, и их можно отключить.

Что показывает демонстрационный пример

Рис. 4. Demo02_PathViewer на графике с цветовой схемой Black On White — семь вариантов

На рисунке 4 программа просмотра путей целиком показана на графике MetaTrader. Цвета маркеров позволяют различать сохраненные отрезки, замыкающие отрезки, вершины и начала контуров. Красный отрезок появляется только у замкнутых контуров, поэтому незамкнутые варианты сразу видны.

Рис. 5. Увеличенный вариант C — два зеленых маркера, то есть два вызова MoveTo() в одном пути

На рисунке 5 в увеличении показан пример с несколькими контурами: один путь содержит два контура и восемь вершин.

В журнале показано, как контуры хранятся в пути:

=== Part 2: paths built, nothing filled ===
A. rectangle       : 1 contour(s), 4 vertices
     after Close(): pen at 40.0, 46.0  pen down = false
B. star            : 1 contour(s), 10 vertices
C. frame           : 2 contour(s), 8 vertices  <- one path, two outlines
     contour 0: starts at index 0, 4 vertices, closed = true
     contour 1: starts at index 4, 4 vertices, closed = true
D. glyph 'A'       : 2 contour(s), 10 vertices
E. sub-pixel offsets stored in the path:
     rect 0: x = 40.0000  (plotted at pixel column 40)
     rect 1: x = 80.1667  (plotted at pixel column 80)
     rect 2: x = 120.3333  (plotted at pixel column 120)
     rect 3: x = 160.5000  (plotted at pixel column 161)
     rect 4: x = 200.6667  (plotted at pixel column 201)
     rect 5: x = 240.8333  (plotted at pixel column 241)
F. open polyline   : 1 contour(s), 23 vertices, closed = false
G. polyline+polygon: 2 contour(s), 10 vertices
     contour 0: 5 vertices from index 0, closed = false  <- AddPolyline
     contour 1: 5 vertices from index 5, closed = true  <- AddPolygon

Три фрагмента вывода показывают основные особенности хранения данных и сохранения точности:

Две строки со сведениями о контурах варианта C показывают модель с рисунка 3 во время выполнения: контур 0 начинается с индекса 0 и содержит четыре вершины, контур 1 начинается с индекса 4 и тоже содержит четыре вершины. Один массив m_points, один массив m_starts и граница между двумя обводками, заданная одним целым числом.

Округление выполняется только в демонстрационной функции рисования; сам путь сохраняет исходные дробные координаты.

Две строки со сведениями о контурах варианта G описывают одни и те же пять вершин дважды. Число вершин совпадает, индексы начала следуют один за другим, а единственное различающееся поле — флаг замыкания. Именно это поле будут читать остальные компоненты библиотеки, чтобы определить, нужно ли замкнуть фигуру ребром или оставить два свободных конца.


Структура проекта

В приложенном архиве содержится полный исходный код этой части. Распакуйте его в каталог MQL5/, сохранив структуру папок, и скомпилируйте Demo02_PathViewer.mq5.

MQL5/
├── Include/
│   └── CairoG2D/
│       ├── Color.mqh          // the ARGB color type
│       ├── Surface.mqh        // pixel buffer bound to one chart object
│       ├── Path.mqh           // points, contours, the path
│       └── DemoTheme.mqh      // the demos' shared palette - NOT library code
└── Experts/
    └── CairoG2D/
        └── Article 02/
            └── Demo02_PathViewer.mq5  // the demo - builds paths, plots what they say

Начиная с этой части заголовочные файлы библиотеки хранятся в MQL5/Include/CairoG2D/, а демонстрационные примеры — в MQL5/Experts/CairoG2D/. Эта структура остается неизменной в последующих частях.

#  Файл Каталог Содержимое
1 Color.mqh MQL5/Include/CairoG2D Тип цвета ARGB, функции формирования цвета, интерполяция
2 Surface.mqh MQL5/Include/CairoG2D CCairoSurface — вывод пиксельного буфера на график в виде растрового изображения
3 Path.mqh MQL5/Include/CairoG2D SCairoPoint, CCairoPath, контуры
4 DemoTheme.mqh MQL5/Include/CairoG2D Палитра, настройка графика, подписи — не входят в состав библиотеки
5 Demo02_PathViewer.mq5 MQL5/Experts/CairoG2D/Article 02/ Демонстрационный пример — строит пути и отображает их геометрию
6 Cairo Style Library - Part 02.zip   Архив со всеми приложенными файлами и структурой каталогов относительно корневой папки терминала


Заключение и планы на третью часть

Во второй части мы добавили слой геометрии, необходимый рендереру:

  • координаты типа double для сохранения субпиксельной геометрии;
  • контуры для представления нескольких обводок внутри одного пути;
  • плоскую модель хранения с индексами начала контуров;
  • MoveTo(), LineTo() и Close() для построения путей из прямых отрезков;
  • флаг замыкания для каждого контура, благодаря которому незамкнутая ломаная и замкнутая фигура различаются в хранимых данных, а не только на экране;
  • вспомогательные функции, преобразующие фигуры более высокого уровня в единое представление в виде пути.

Главный результат — теперь разные фигуры представлены в едином формате пути. Поэтому код обработки может работать с геометрией без отдельной логики для каждого типа фигуры.

В третьей части на основе этого представления геометрии мы реализуем растеризацию. Мы введем понятие ребра — отрезка, подготовленного для обработки строк сканирования: его конечные точки упорядочены по координате y, а наклон вычислен один раз. Затем напишем функцию BuildEdges(), которая преобразует контуры в ребра и при этом автоматически замыкает каждый контур. В результате будут отрисованы и замыкающие отрезки, которые в пути не хранятся. Первый растеризатор будет преобразовывать ребра пути в залитые области с помощью строк сканирования.

Растеризатор будет заливать любую геометрию, представленную моделью пути, используя один и тот же пайплайн обработки для всех фигур. В первой версии края пока останутся несглаженными; сглаживание на основе покрытия появится в четвертой части.

Именно в третьей части пути впервые превратятся в залитые фигуры.


Проект поддерживается в Algo Forge. Для каждой части серии подготовлен отдельный релиз с кодом, зафиксированным именно в том виде, который разбирается в этой статье. Поэтому скачивайте релиз, соответствующий той статье, которую читаете.

Часть 2 находится здесь: https://forge.mql5.io/SandroBegashvil/CairoG2D/releases/tag/part-02

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/23993

Прикрепленные файлы |
Path.mqh (34.56 KB)
Алгоритм опыления цветов — Flower Pollination Algorithm (FPA) Алгоритм опыления цветов — Flower Pollination Algorithm (FPA)
Рассматривается реализация Flower Pollination Algorithm (FPA) в MQL5 с разбором операторов глобального и локального опыления и влияния ключевых параметров популяции и шага Леви, который при авторских настройках ищет почти наугад: находим причину и поднимаем результат с 26% до 49%. Трейдер получает готовый класс FPA с рабочими настройками для оптимизации советников и понимание, для каких задач он подходит.
Автоматизация графических паттернов в MQL5 (Часть 2): Паттерны "двойная вершина" и "двойное дно" Автоматизация графических паттернов в MQL5 (Часть 2): Паттерны "двойная вершина" и "двойное дно"
Мы создадим надежный алгоритм обнаружения паттернов "двойная вершина" и "двойное дно" на языке MQL5. Сначала он подтвердит тренд на H4, а затем проверит шесть условий (равенство точек, положение линии шеи, порядок следования, ширину, высоту и допуски на основе среднего истинного диапазона, ATR). Пробой линии шеи определяется на таймфрейме графика, а машина состояний с тремя состояниями гарантирует, что по каждому паттерну сделка совершается только один раз. Проекция высоты паттерна позволяет определить четкий целевой уровень выхода.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Как обнаруживать и нормализовать объекты графика в MQL5 (часть 1): Создание движка обнаружения объектов графика Как обнаруживать и нормализовать объекты графика в MQL5 (часть 1): Создание движка обнаружения объектов графика
В этой статье рассматривается интерпретационный разрыв между визуальными объектами графика и алгоритмическим исполнением. Вы создадите системный детектор, который проходит по всем объектам графика, определяет аналитические типы и нормализует их геометрические данные (временные и ценовые координаты) в структурированный массив SChartObjectInfo. В реализации используются низкоуровневые функции MQL5, конвейер "фильтрация — извлечение — сохранение" и тестовый советник (EA), управляемый таймером, что в итоге дает переиспользуемый фреймворк для торговых входных данных на основе правил.