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

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

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

Содержание


Введение

После четвертой части движок уже хорошо справляется с отрисовкой. Рендерер уже умеет многое: выполняет заливку произвольных контуров, сглаживает их края и корректно выполняет альфа-композицию — и все это через единый метод Fill(). Но он не справляется, когда интерфейсу нужна по-настоящему пустая область внутри фигуры. Это не просто косметический недостаток — он мешает правильно отображать распространенные элементы интерфейса: рамки, кольца и кольцевые шкалы, внутренние просветы букв вроде "A" или "O", кнопки с контуром и дорожки полос прогресса. На практике это хуже, чем просто отсутствие нужной возможности: в вызывающем коде приходится задавать поведение рендерера через порядок вершин или прибегать к обходным приемам с непрозрачной заливкой, которая закрашивает все под ней.

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

Вот что уже есть в библиотеке к началу этой части и что мы в нее добавим.

Color.mqh      Часть 1    тип цвета ARGB
               Часть 4    + CairoBlendOver
Config.mqh     Часть 4    CairoAaSamples — настройка качества
Surface.mqh    Часть 1    пиксельный буфер, связанный с одним графическим объектом
Path.mqh       Часть 2    точки, контуры, перо
               Часть 3    + BuildEdges
               Часть 5    + AddThickLine                             <- сегодня
Raster.mqh     Часть 3    построчная заливка
               Часть 4    покрытие и композиция
               Часть 5    + правило заливки                          <- сегодня

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


Два способа ответить на один вопрос

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

Растеризатор проводит через фигуру горизонтальную линию сканирования, собирает все точки ее пересечения с ребрами, сортирует их слева направо, а затем проходит по ним, накапливая сумму. Каждое пересечение добавляет к этой сумме +1 или -1 в зависимости от того, было ли соответствующее ребро изначально направлено вниз или вверх. Эта сумма и есть число обхода: оно показывает, сколько раз и в каком направлении контур обходит рассматриваемую точку.

В третьей части мы задавали об этом числе один вопрос:

if(winding != 0)

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

Правило ненулевого числа обхода

CAIRO_NONZERO: точка находится внутри фигуры, если число обхода не равно нулю. Направление имеет значение — в этом и состоит суть правила.
Два контура, заданные в одном направлении, объединяются. В области перекрытия один контур дает вклад +1, а другой — еще +1. Число обхода здесь равно 2, то есть не нулю, поэтому эта область считается внутренней. Получается сплошная объединенная фигура без шва между частями.
Контур, заданный в противоположном направлении, вырезает отверстие. В области его перекрытия с первым контуром один дает вклад +1, а другой — -1. Число обхода равно 0, поэтому область перекрытия не закрашивается.

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

Правило "чет-нечет"

CAIRO_EVENODD: точка находится внутри фигуры, если границу пересекли нечетное число раз. Направление не имеет значения.

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

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

Рис. 1. Одни и те же три контура, залитые по каждому из правил, с обозначенными числами обхода в областях.

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

Посмотрите на числа. Три круга с одинаковым направлением обхода дают число обхода 1 в областях, покрытых одним кругом, 2 — в областях перекрытия двух кругов и 3 — в центре, где перекрываются все три. На левой панели закрашивается все, где число обхода не равно 0, то есть все эти области. Поэтому три круга превращаются в единое пятно. На правой панели закрашиваются области с нечетным числом обхода — 1 и 3, а области с четным не закрашиваются. В результате области перекрытия двух кругов вырезаются и возникает интерференционная картина.

Та же геометрия. Тот же вызов. Отличается только значение перечисления.

Ни одно из этих правил не лучше другого. Важно не воспринимать правило "чет-нечет" просто как "то, которое делает отверстия". Они отвечают на разные вопросы. К концу статьи мы рассмотрим случай, когда правило ненулевого числа обхода не просто предпочтительно, а обязательно, тогда как правило "чет-нечет" испортило бы рисунок. Именно по этой причине Cairo, SVG и PostScript предоставляют выбор между обоими правилами.


Изменение в растеризаторе

Вот полный код реализации, разбитый на три части.

Во-первых, перечисление. Его нужно разместить в начале файла Raster.mqh, перед классом растеризатора:

//+------------------------------------------------------------------+
//| ENUM_CAIRO_FILLRULE - what the winding number MEANS.             |
//+------------------------------------------------------------------+
enum ENUM_CAIRO_FILLRULE
  {
   CAIRO_NONZERO = 0,      // inside when winding != 0 - the default
   CAIRO_EVENODD = 1       // inside when the crossing count is odd
  };

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

Во-вторых, параметр. У Fill появляется один параметр со значением по умолчанию:

   void              Fill(CCairoPath &path, const uint argb, uint &buffer[],
                          const int width, const int height,
                          const ENUM_CAIRO_FILLRULE rule = CAIRO_NONZERO);

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

Выше приведена сигнатура функции самого растеризатора. В примерах ниже она вызывается через однострочную функцию-обертку, определенную в демонстрационной программе: Fill( path, color, rule = CAIRO_NONZERO ), которая передает растеризатору буфер, его размеры и выбранное правило без изменений.

В-третьих, сама проверка. В ходе сканирующего прохода один оператор if заменяется тернарным оператором и оператором if:

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

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

Это возможно только потому, что во всем движке за определение внутренней области отвечает ровно одна функция. В библиотеке с функциями FillRect(), FillTriangle() и FillPolygon() сейчас эту возможность пришлось бы добавлять в трех местах, а через несколько частей — уже в пяти.

         //--- sweep with the winding number, as before - but a span
         //--- is now ACCUMULATED rather than painted, and what the
         //--- winding number MEANS is now the caller's choice
         int winding = 0;

         for(int k = 0; k < n - 1; k++)
           {
            winding += m_cross_dir[k];

            //--- the fill rule, and the only line in this file
            //--- that Part 5 actually adds. (winding & 1) is a
            //--- valid parity test even though winding is a SIGNED
            //--- sum: every crossing adds +1 or -1, and both of
            //--- those flip the low bit, so the parity of the sum
            //--- equals the parity of the count.
            const bool inside = (rule == CAIRO_NONZERO)
                                ? (winding != 0)
                                : ((winding & 1) != 0);

            if(inside)
               AddSpan(m_cross_x[k], m_cross_x[k + 1],
                       weight, ix0, ix1);
           }
        }

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


Почему проверка четности работает для суммы со знаком

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

( winding & 1 ) != 0

Нам нужна четность числа пересечений. А у нас есть число обхода — сумма со знаком: каждое пересечение добавляло к ней +1 или -1. Проверка младшего бита суммы со знаком, словно это число пересечений, похожа на прием, который работает в тестовом примере, но дает сбой в реальной программе.

Но этот прием совершенно корректен, и объяснение короткое. Каждое пересечение изменяет сумму на +1 или -1, и оба этих изменения инвертируют младший бит. Прибавление единицы инвертирует его; вычитание единицы — тоже. Поэтому после k пересечений, независимо от сочетания направлений ребер, младший бит суммы показывает четность k.

Разберем случаи:

crossings  +1, -1, +1   ->  sum = +1   odd sum,  3 crossings, odd count
crossings  -1, -1       ->  sum = -2   even sum, 2 crossings, even count
crossings  +1, +1, -1   ->  sum = +1   odd sum,  3 crossings, odd count

Осталось проверить только одно: работает ли оператор & языка MQL5 с отрицательными числами так, как нам нужно. Да: целые числа представлены в дополнительном коде, поэтому у -1 все биты установлены в 1 и выражение -1 & 1 дает 1. У -2 младший бит равен 0, поэтому выражение -2 & 1 дает 0. Никаких неожиданных особенностей языка здесь нет.

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


Наконец-то буква "A"

В демонстрации из третьей части буква "A" получилась полностью закрашенной. Оба контура — внешний контур и треугольный просвет в центре — были заданы в одном направлении. Поэтому по правилу ненулевого числа обхода обе области считались внутренними: число обхода было равно 1 в основной части буквы и 2 — в просвете.

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

//--- the counter, deliberately traced the other way round
p.MoveTo( ox + 58, oy + 46 );
p.LineTo( ox + 68, oy + 80 );
p.LineTo( ox + 48, oy + 80 );
p.Close();

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

Правило "чет-нечет" полностью устраняет это требование:

CCairoPath a;
AddGlyphA( a, x, y );              // both contours wound naturally
Fill( a, fill_clr, CAIRO_EVENODD );

Оба правила используют один и тот же список пересечений, поэтому оба зависят от проверки принадлежности полуоткрытому интервалу [ytop, ybot) из третьей части. Именно эта проверка не позволяет дважды учесть вершину, общую для двух ребер. Двойной учет одинаково искажает и четность, и число обхода.

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

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


Рамки — неправильный и правильный способы построения

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

Распространенный подход
//--- fill a big rectangle in the border color
CCairoPath outer;
outer.AddRect( 20, 20, 170, 70 );
Fill( outer, border_color );

//--- then a smaller one in the BACKGROUND color on top
CCairoPath inner;
inner.AddRect( 26, 26, 158, 58 );
Fill( inner, background_color );

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

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

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

Второй недостаток — две сглаженные границы накладываются друг на друга со смешиванием цветов. Эта проблема появилась в четвертой части. Такие недостатки становятся заметны лишь тогда, когда рендерер уже достаточно хорош, чтобы оценивать качество его работы. Сглаженная граница внутреннего прямоугольника накладывается со смешиванием на внутреннюю область внешнего прямоугольника. Поэтому внутренняя граница рамки получается в результате двух смешиваний, а внешняя — одного. Рамка получается слегка асимметричной — более четкой снаружи и более размытой изнутри. Если рамка очень тонкая, на экране с высоким DPI этого как раз достаточно, чтобы панель выглядела чуть дешевле, хотя никто не сможет объяснить почему.

Правильный способ
CCairoPath ring;
ring.AddRect( 20, 20, 170, 70 );      // outer outline
ring.AddRect( 26, 26, 158, 58 );      // inset copy
Fill( ring, border_color, CAIRO_EVENODD );

Один путь. Два контура. Одна заливка.

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

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

Рис. 2. Две заливки цветом фона в сравнении с одним кольцом, залитым по правилу "чет-нечет".

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

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

Отступ внутрь равен ширине рамки. Тончайшая линия и полоса шириной 16 пикселей рисуются теми же двумя строчками кода — меняется лишь одно число. На панели D демонстрационной программы показаны пять вариантов толщины. И двумя контурами дело не ограничивается: четыре вложенных прямоугольника, залитые по правилу "чет-нечет", дают концентрические полосы. При числе пересечений 1 мы внутри, при 2 — снаружи, при 3 — снова внутри, и так далее, сколько бы контуров мы ни добавляли.


Обводки, построенные с помощью заливки

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

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

Вот полный код функции AddThickLine — единственного дополнения к Path.mqh в этой части:

//+------------------------------------------------------------------+
//| AddThickLine - one segment of a stroke, as a closed quad.        |
//+------------------------------------------------------------------+
void CCairoPath::AddThickLine(const double x0, const double y0,
                              const double x1, const double y1,
                              const double thickness)
  {
   const double dx  = x1 - x0;
   const double dy  = y1 - y0;
   const double len = MathSqrt(dx * dx + dy * dy);

//--- a zero-length segment has no direction, so it has no normal
//--- and no quad. Returning is correct; dividing is not.
   if(len < 1e-9)
      return;

//--- the quad is four vertices in one new contour
   ReservePoints(m_point_count + 4);

//--- unit normal, scaled to half the thickness
   const double nx = -dy / len * (thickness * 0.5);
   const double ny =  dx / len * (thickness * 0.5);

   MoveTo(x0 + nx, y0 + ny);
   LineTo(x0 - nx, y0 - ny);
   LineTo(x1 - nx, y1 - ny);
   LineTo(x1 + nx, y1 + ny);
   Close();
  }

В этом коде есть четыре момента, на которых стоит остановиться подробнее.

Значение len вычисляется один раз и проверяется. У отрезка нулевой длины нет направления, а значит, нет и нормали. Без защитной проверки каждая следующая строчка приводила бы к делению на ноль. Выход без сообщения об ошибке здесь правилен: отрезки нулевой длины возникают естественным образом — в ломаной с повторяющейся точкой, в кадре анимации, где значение не изменилось, или в пути, построенном по поступающим данным, в которых случайно встретился повтор. Вместо проверки == 0.0 используется порог 1e-9, потому что опасность представляет не точный ноль — MathSqrt(0) возвращает именно ноль, — а настолько малая длина, что при делении на нее из координат, различающихся лишь из-за погрешности округления, получается нормаль неправдоподобно большой длины.

(-dy, dx) — это нормаль. Это вся математика, которая используется в функции. В обычной математической системе координат поворот вектора на девяносто градусов против часовой стрелки переводит (dx, dy) в (-dy, dx). Это легко проверить на одном примере: направленный вправо вектор (1, 0) превращается в перпендикулярный ему вектор (0, 1). На экране, где y увеличивается вниз, полученный вектор направлен вниз, поэтому видимый поворот происходит по часовой стрелке. Вычисления в обоих случаях одинаковы. Никакие другие сведения о направлении не сохраняются — именно это нам и нужно.

Деление на len и умножение на thickness * 0.5 выполняются в одном выражении. Деление на len делает нормаль единичной независимо от длины отрезка. Без этого при одном и том же значении аргумента thickness длинный отрезок давал бы широкий четырехугольник, а короткий — узкий. Затем нормаль умножается на половину толщины: четырехугольник располагается по обе стороны от осевой линии, поэтому на каждую сторону приходится половина толщины.

Четыре вершины добавляются в неизменном направлении обхода. Это может показаться чисто косметической деталью, но это не так. Последовательность p0 + n, p0 - n, p1 - n, p1 + n задает одно и то же направление обхода прямоугольника для любого отрезка, куда бы он ни был направлен. Следующий раздел целиком посвящен тому, что происходит, когда это условие нарушается.

Рис. 3. Один отрезок в виде четырехугольника и то, что происходит на стыке двух отрезков.

Слева показан результат одного вызова AddThickLine(). Штриховая линия — отрезок, заданный в вызывающем коде; n — единичная нормаль, умноженная на половину толщины; четыре пронумерованные точки — вершины в порядке их добавления. Направление обхода всегда одно и то же, куда бы ни был направлен отрезок.

Справа показан стык двух четырехугольников, залитый с реальным применением правила ненулевого числа обхода. На верхнем изгибе оба четырехугольника заданы в одном направлении. В области перекрытия число обхода равно 2, поэтому изгиб закрашен сплошь. На нижнем изгибе второй четырехугольник задан в обратном направлении. В области перекрытия сумма равна +1 - 1 = 0, поэтому в углу образуется отверстие. Это отверстие вычисляется, а не дорисовывается.

Главное — один путь, одна заливка

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

//+------------------------------------------------------------------+
//| E. STROKES BUILT FROM FILLS                                      |
//+------------------------------------------------------------------+
void PanelStroke(const double ox, const double oy)
  {
   const int    n = 34;
   const double thickness = 7.0;

   double vx[], vy[];

   if(ArrayResize(vx, n) < n)
      return;
   if(ArrayResize(vy, n) < n)
      return;

   for(int i = 0; i < n; i++)
     {
      vx[i] = ox + i * 12.0;
      vy[i] = oy + 55 + MathSin(i * 0.42) * 40
              + MathCos(i * 0.17) * 10;
     }

//--- ONE path for the whole polyline. The total is known, so the
//--- whole thing is one allocation instead of sixty-five.
   CCairoPath stroke;
   stroke.ReserveContours((n - 1) + (n - 2));
   stroke.ReservePoints((n - 1) * 4 + (n - 2) * 12);

   for(int i = 0; i < n - 1; i++)
      stroke.AddThickLine(vx[i], vy[i], vx[i + 1], vy[i + 1],
                          thickness);

//--- a small regular polygon at each interior joint, rounding it
   for(int i = 1; i < n - 1; i++)
      AddNGon(stroke, vx[i], vy[i], thickness * 0.5, 12);

   Fill(stroke, CairoWithOpacity(C_GREEN, 0.75), CAIRO_NONZERO);

   PrintFormat("E. stroke: %d segments + %d joints = %d contours, "
               "%d vertices, ONE fill, valid = %s",
               n - 1, n - 2, stroke.ContourCount(),
               stroke.PointCount(),
               stroke.IsValid() ? "true" : "false");
  }

Эти два вызова Reserve показывают, чем полезны вспомогательные методы из второй части. Число контуров и вершин известно заранее: 65 контуров и 516 вершин — 33 четырехугольника по четыре вершины и 32 многоугольника для стыков по 12 вершин. Поэтому память для всего пути выделяется за один раз вместо десятков небольших выделений. Эти вызовы не обязательны: без них путь все равно будет корректно увеличиваться. Но если нужное число уже указано строчкой выше, незачем заставлять массив выяснять его ценой повторных выделений памяти.

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

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

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


Когда возникают ошибки — смешанное направление обхода

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

Очевидный способ развернуть отрезок — поменять местами его конечные точки:

//--- this does NOT do what it looks like it does
bad.AddThickLine( x1, y1, x0, y0, thickness );

Но направление обхода от этого не меняется. Перестановка конечных точек меняет знак вектора (dx, dy), а значит, и нормали. Вершины добавляются в порядке p1 - n, p1 + n, p0 + n, p0 - n — это тот же циклический порядок, что и раньше, только с началом в другой вершине. Два разворота взаимно компенсируются, и получается тот же самый четырехугольник с тем же направлением обхода. Это полезное напоминание: обратное направление обхода определяется порядком добавления вершин, а не порядком аргументов.

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

//+------------------------------------------------------------------+
//| F. THE FAILURE CASE                                              |
//+------------------------------------------------------------------+
void AddThickLineReversed(CCairoPath &p, const double x0, const double y0,
                          const double x1, const double y1,
                          const double thickness)
  {
   const double dx  = x1 - x0;
   const double dy  = y1 - y0;
   const double len = MathSqrt(dx * dx + dy * dy);

   if(len < 1e-9)
      return;

   const double nx = -dy / len * (thickness * 0.5);
   const double ny =  dx / len * (thickness * 0.5);

//--- corners 4, 3, 2, 1 instead of 1, 2, 3, 4
   p.MoveTo(x1 + nx, y1 + ny);
   p.LineTo(x1 - nx, y1 - ny);
   p.LineTo(x0 - nx, y0 - ny);
   p.LineTo(x0 + nx, y0 + ny);
   p.Close();
  }

Эта вспомогательная функция не входит в библиотеку, и в Path.mqh мы ее не добавляем. Она нужна в демонстрации только для одного — намеренно воспроизвести ошибку. На панели F она применяется к каждому второму отрезку той же ломаной, которая правильно нарисована на панели E.

Теперь в области перекрытия на каждом стыке один четырехугольник дает вклад +1, а другой — -1. Число обхода здесь равно 0. По правилу ненулевого числа обхода эта область считается внешней, поэтому на каждом стыке образуется отверстие. В нижней половине рисунка 3 этот механизм показан на двух отрезках. На панели F он повторяется 32 раза подряд — именно так выглядит результат, когда вы допускаете такую ошибку случайно.

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


Демонстрационный пример

Demo05_FillRules.mq5 рисует шесть панелей в одном объекте OBJ_BITMAP_LABEL. Все фигуры на экране рисуются одним и тем же вызовом Fill(). На разных панелях меняется лишь передаваемая геометрия, а на трех из них — еще и значение перечисления.

  • A — три перекрывающихся круга в одном пути, нарисованные дважды. Одинаковая геометрия, одинаковый вызов — отличается только значение перечисления. Это рисунок 1, нарисованный самим движком, а не скриптом подготовки иллюстрации.
  • B — буква "A" в трех вариантах: полностью закрашенная по правилу ненулевого числа обхода, правильно нарисованная по тому же правилу с вручную измененным направлением контура просвета и правильно нарисованная по правилу "чет-нечет" без изменения направлений.
  • C — имитированная рамка и настоящее кольцо. Оба варианта нарисованы дважды: поверх непрозрачной карточки, где они неотличимы, и поверх шахматного фона, где разница видна.
  • D — кольца с толщиной рамки 1, 2, 4, 8 и 16 пикселей, а также четыре вложенных прямоугольника, показывающие, что полосы чередуются при любой глубине вложенности.
  • E — ломаная из 34 точек. Ее обводка построена из 33 четырехугольников и 32 многоугольников на стыках — в одном пути, одной заливкой, с непрозрачностью 75%. В строчке журнала выводятся число контуров, число вершин и результат IsValid(), поэтому неудачное выделение памяти не останется незамеченным.
  • F — та же ломаная с чередующимся направлением обхода и ошибками отрисовки.

Рис. 4. Demo05_FillRules на графике с цветовой схемой Black On White — шесть панелей, один вызов.

Во всем показанном есть одна деталь, на которую стоит обратить внимание: AddNGon из демонстрационного примера не входит в библиотеку. Это локальная вспомогательная функция, которая вычисляет вершины правильного многоугольника. Такие многоугольники заменяют круги, поддержки которых в библиотеке пока нет. Каждый "круг" на панелях A, D и E — это многоугольник с 72 или 96 сторонами. Растеризатор не видит разницы, и вы при таком размере тоже ее не заметите. Именно об этом шла речь в третьей части, когда мы обсуждали круги. В одной из следующих частей мы наконец применим этот подход на практике: библиотека будет вычислять эти вершины за вас.

Код рисования шахматного фона на панели C тоже относится к демонстрационной программе, а не к библиотеке. Этот фон изображает содержимое, которое вызывающий код мог нарисовать раньше. Так сразу видно, если заливка закрашивает уже нарисованные элементы. Стоит посмотреть, как это устроено: рисуются несколько сотен квадратов, а для всех них используется ровно один объект CCairoPath. Clear() очищает путь после рисования каждого квадрата, сохраняя уже выделенную память, а Release() освобождает ее, когда панель нарисована. Именно для этого во второй части и была добавлена эта пара методов управления жизненным циклом.

Рис. 5. Панели E и F в увеличении — на одной обводка без швов, на другой она вся в отверстиях.

Сравнение на рисунке 5 стоит рассмотреть внимательно. Слева стыки невидимы — не потому, что их сгладили, а потому, что их действительно нет: растеризатор залил одну область, а не 33 перекрывающиеся. Заодно проверьте непрозрачность: она одинакова по всей длине обводки, в том числе в углах. Это подтверждает, что для каждого пикселя смешивание выполнялось только один раз. Справа — та же геометрия, но направление обхода каждого второго контура изменено на обратное. Поэтому на каждом стыке число обхода становится равным нулю.


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

Распакуйте архив в MQL5/, сохранив структуру папок, и скомпилируйте Demo05_FillRules.mq5. Редактировать пути не требуется.

MQL5/
├── Include/
│   └── CairoG2D/
│       ├── Color.mqh          // the ARGB color type and blending
│       ├── Config.mqh         // CairoAaSamples - the quality dial
│       ├── Surface.mqh        // pixel buffer bound to one chart object
│       ├── Path.mqh           // the path and the edges, now + AddThickLine
│       ├── Raster.mqh         // the coverage fill, now + the fill rule
│       └── DemoTheme.mqh      // the demos' shared palette - NOT library code
└── Experts/
    └── CairoG2D/
        └── Article 05/
            └── Demo05_FillRules.mq5   // six panels, one Fill()
#  Файл Каталог Содержимое
1 Color.mqh MQL5/Include/CairoG2D Тип цвета ARGB и CairoBlendOver
2 Config.mqh MQL5/Include/CairoG2D CairoAaSamples и строка версии
3 Surface.mqh MQL5/Include/CairoG2D CCairoSurface — вывод пиксельного буфера на график в виде растрового изображения
4 Path.mqh MQL5/Include/CairoG2D Путь, ребра, а теперь и AddThickLine
5 Raster.mqh MQL5/Include/CairoG2D CCairoRasterizer — покрытие, композиция, правило заливки
6 DemoTheme.mqh MQL5/Include/CairoG2D Палитра, настройка графика, подписи — не входят в состав библиотеки
7 Demo05_FillRules.mq5 MQL5/Experts/CairoG2D/Article 05 Шесть панелей, включая пример ошибки отрисовки из-за направления обхода
8 Cairo-Style Library - Part 05.zip   Архив со всеми приложенными файлами и структурой каталогов относительно корневой папки терминала

В этой части изменились два файла, и в обоих были только добавления. В Path.mqh в конце блока вспомогательных функций для построения фигур добавлена AddThickLine. Весь код выше остался без изменений. В Raster.mqh добавлены ENUM_CAIRO_FILLRULE, параметр Fill() со значением по умолчанию и тернарное выражение в ходе сканирующего прохода. Color.mqh, Config.mqh и Surface.mqh вообще не изменялись, а приложенная папка include/ содержит ровно то, что было написано в серии к этому моменту: пять файлов и ничего из последующих частей.


Какие задачи остаются нерешенными и что будет дальше

Изменение намеренно небольшое и сугубо практическое: добавлены перечисление ENUM_CAIRO_FILLRULE и параметр правила заливки в Fill(). По умолчанию используется прежнее правило ненулевого числа обхода, поэтому существующие вызовы продолжают работать. Одно это перечисление открывает несколько полезных возможностей:

  • CAIRO_EVENODD позволяет получать отверстия за счет самого построения фигуры: поместите внешний контур и его уменьшенную копию с отступом внутрь в один путь — внутренняя область вообще не закрашивается. Содержимое под ней остается нетронутым, а обе границы сглаживаются ровно один раз. Так можно правильно отображать просветы букв и настоящие рамки, не угадывая цвет фона.
  • CAIRO_NONZERO позволяет строить обводки с помощью заливки: создайте четырехугольник для каждого отрезка (а также небольшие многоугольники на стыках), поместите их в один путь и залейте один раз, чтобы избежать швов и повторного альфа-смешивания. Правило ненулевого числа обхода обеспечивает объединение без швов, но требует соблюдения единого соглашения о направлении обхода контуров.
  • Реализация эффективна и требует лишь локальных изменений: одно перечисление, один параметр, одно тернарное выражение во внутреннем цикле растеризатора. По-прежнему нужно следить за направлением обхода контуров: если оно чередуется, на стыках образуются отверстия. Поэтому для вспомогательных функций построения фигур нужно документировать направление обхода и соблюдать его в реализации.

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


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

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

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

Прикрепленные файлы |
Path.mqh (45.5 KB)
Raster.mqh (18.33 KB)
Построение системы обнаружения дивергенций (Часть IV): Создание переиспользуемого модуля обнаружения дивергенций для MQL5 Построение системы обнаружения дивергенций (Часть IV): Создание переиспользуемого модуля обнаружения дивергенций для MQL5
В статье логика обнаружения дивергенций, разработанная в этой серии, выносится в подключаемый файл DivergenceEngine.mqh, который можно повторно использовать в индикаторах и советниках на языке MQL5. В статье подробно описаны архитектура на основе структур, выбор осциллятора (MPO4 или RSI), работа с опорными точками и состоянием, а также минимальный API для доступа к данным. Советник на основе Parabolic SAR демонстрирует подключение модуля: коэффициент ускорения индикатора изменяется в зависимости от обнаруженной дивергенции. Этот пример дает понятную схему, которую можно повторно использовать без дублирования кода.
Интеграция ИИ в три концепции Smart Money (SMC): Ордер-блок (OB), слом структуры (BOS) и гэп справедливой стоимости (FVG) Интеграция ИИ в три концепции Smart Money (SMC): Ордер-блок (OB), слом структуры (BOS) и гэп справедливой стоимости (FVG)
В этом руководстве описано, как интегрировать обученную модель XGBoost (ONNX) в SMC-советник для оценки торговых сетапов перед их исполнением. Пайплайн на Python размечает исторические события по инструменту XAUUSD и формирует представление из 12 признаков, согласованное с советником. В результате получается воспроизводимый метод обучения, экспорта и встраивания модели, позволяющий советнику программно фильтровать сигналы ордер-блоков (OB), гэпов справедливой стоимости (FVG) и слома структуры (BOS).
Создание индикатора Market Structure Sentinel на языке MQL5 Создание индикатора Market Structure Sentinel на языке MQL5
В статье описывается создание индикатора Market Structure Sentinel на языке MQL5, который в режиме реального времени выявляет и визуализирует события в рамках концепций «умных денег» (SMC), включая слом структуры (BOS) и смену характера (CHOCH). В ней объясняются обнаружение свингов, структурная валидация и классификация тренда, а также добавляется компактная мини-панель для отслеживания бычьего или медвежьего тренда либо флэта, что позволяет быстрее интерпретировать ситуацию на графике.
За рамками REST и ZeroMQ: Создание моста Protocol Buffers между MetaTrader 5 и Python с выходом на gRPC для инференса в реальном времени За рамками REST и ZeroMQ: Создание моста Protocol Buffers между MetaTrader 5 и Python с выходом на gRPC для инференса в реальном времени
В этой статье мы определим контракт Protocol Buffers для обмена данными между MetaTrader 5 и Python и реализуем на языке MQL5 клиент Protobuf-over-TCP, передающий сообщения с префиксом длины: MQL5 нативно не поддерживает реальный gRPC. Небольшая прослойка на Python передает эти фреймы в полноценный сервер grpc.aio. Пока через нее выполняются только унарные RPC-вызовы, но на бэкенде уже работают и потоковые вызовы. Вы получите строго типизированные сообщения с контролем соответствия схеме, явные сообщения об ошибках, повторные попытки с задержкой между ними и кэш для тестера стратегий, позволяющий воспроизводить результаты тестирования на истории там, где сокеты недоступны.