English
preview
Торговые инструменты MQL5 (Часть 24): Улучшение восприятия глубины с помощью 3D-кривых, режима панорамирования и навигации через виджет ViewCube

Торговые инструменты MQL5 (Часть 24): Улучшение восприятия глубины с помощью 3D-кривых, режима панорамирования и навигации через виджет ViewCube

MetaTrader 5Торговые системы |
31 1
Allan Munene Mutiiria
Allan Munene Mutiiria

Введение

У вас есть средство просмотра трёхмерного биномиального распределения с вращением и масштабированием, но без кривой с корректной передачей глубины, без возможности смещать целевую точку обзора и без быстрого сброса ориентации. Поэтому анализ формы функции массы вероятности требует постоянного ручного вращения, области, расположенные вне центра сцены, остаются труднодоступными для просмотра, а возвращение к стандартному виду означает перетаскивание вслепую. Эта статья предназначена для разработчиков MetaQuotes Language 5 (MQL5) и алгоритмических трейдеров, стремящихся расширить интерактивные трехмерные статистические инструменты с интуитивно понятными элементами управления навигацией для более глубокого вероятностного анализа.

В своей предыдущей статье (Часть 23) мы интегрировали Direct3D в инструмент просмотра биномиального распределения в MQL5, позволяющий переключать режимы 2D/3D и управлять камерой для поворота, масштабирования и автоподбора положения камеры. В Части 24 мы улучшим инструмент посредством добавления сегментированной трехмерной кривой для улучшения восприятия глубины функции массы вероятности. Также интегрируем режим панорамирования для смещения целевой точки камеры и реализуем интерактивный куб обзора с зонами наведения курсора и анимационными переходами камеры для обеспечения быстрой смены ориентации. В статье рассмотрим следующие темы:

  1. Понимание 3D-кривых, режима панорамирования и структуры виджета ViewCube
  2. Реализация средствами MQL5
  3. Тестирование на истории
  4. Заключение

В итоге у вас будет продвинутый инструмент на MQL5 с улучшенной 3D-навигацией для анализа распределения, готовый к настройке. Перейдём к реализации!


Понимание 3D-кривых, режима панорамирования и структуры виджета ViewCube

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

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

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

3D CURVE, PAN MODE AND VIEWCUBE FRAMEWORK


Реализация средствами MQL5

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

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

//+------------------------------------------------------------------+
//|    Canvas Graphing PART 3.2 - Statistical Distributions (3D).mq5 |
//|                           Copyright 2026, Allan Munene Mutiiria. |
//|                                   https://t.me/Forex_Algo_Trader |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, Allan Munene Mutiiria."
#property link      "https://t.me/Forex_Algo_Trader"
#property version   "1.00"
#property strict

input group "=== VIEW CUBE SETTINGS ==="
input bool             showViewCubeBackground = false;       // Show View Cube Background Panel

//--- Add new constants for pan icon and view cube dimensions

const int PAN_ICON_SIZE     = 24;   // Size of the pan mode toggle icon
const int PAN_ICON_MARGIN   = 6;    // Margin around the pan icon
const int VCUBE_SIZE        = 70;   // Pixel size of the view cube widget
const int VCUBE_MARGIN      = 6;    // Margin around the view cube widget

//--- Add new member variables in the "DistributionVisualizer" class for hovering states, curve segments, panning, animation, and view cube.

//+------------------------------------------------------------------+
//| Distribution visualization window class                          |
//+------------------------------------------------------------------+
class DistributionVisualizer
  {
protected:

   bool              m_isHoveringPanIcon;     // True when mouse is over the pan mode icon
   bool              m_isHoveringViewCube;    // True when mouse is over the view cube widget
   int               m_lastMouseX;            // Last recorded mouse X coordinate
   int               m_lastMouseY;            // Last recorded mouse Y coordinate
   int               m_previousMouseButtonState; // Mouse button state on previous event

   ViewModeType      m_currentViewMode;       // Active view mode (2D or 3D)
   bool              m_are3DObjectsCreated;   // True once 3D scene objects have been built

   CDXBox            m_histogramBars[];       // Array of 3D boxes representing histogram bars
   CDXBox            m_groundPlane;           // 3D box used as the ground reference plane
   CDXBox            m_axisX;                 // 3D box representing the X axis
   CDXBox            m_axisY;                 // 3D box representing the Y axis
   CDXBox            m_axisZ;                 // 3D box representing the Z axis
   CDXBox            m_curveSegments[];       // Array of 3D boxes representing PMF curve tube segments
   int               m_curveSegmentCount;     // Number of active curve segment boxes

   double            m_cameraDistance;        // Distance from camera to scene target
   double            m_cameraAngleX;          // Camera elevation angle (radians)
   double            m_cameraAngleY;          // Camera azimuth angle (radians)
   int               m_mouse3DStartX;         // Mouse X when 3D orbit drag began
   int               m_mouse3DStartY;         // Mouse Y when 3D orbit drag began
   bool              m_isRotating3D;          // True while user is orbiting the 3D scene
   bool              m_panMode;               // True when pan mode is active (vs orbit mode)
   bool              m_isPanning;             // True while a pan drag is in progress
   int               m_panStartX;             // Mouse X when pan drag began
   int               m_panStartY;             // Mouse Y when pan drag began
   DXVector3         m_viewTarget;            // World-space point the camera looks at

   double            m_sampleData[];                  // Raw binomial sample values
   double            m_histogramIntervals[];           // Histogram bin centre positions
   double            m_histogramFrequencies[];         // Scaled frequency per histogram bin
   double            m_theoreticalXValues[];           // X values for the theoretical PMF curve
   double            m_theoreticalYValues[];           // Y values for the theoretical PMF curve
   double            m_minDataValue;                   // Minimum value in data range
   double            m_maxDataValue;                   // Maximum value in data range
   double            m_maxFrequency;                   // Peak raw frequency across all bins
   double            m_maxTheoreticalValue;            // Peak theoretical PMF value
   bool              m_isDataLoaded;                   // True once distribution data is ready

   double            m_sampleMean;                     // Computed sample mean
   double            m_sampleStandardDeviation;        // Computed sample standard deviation
   double            m_sampleSkewness;                 // Computed sample skewness
   double            m_sampleKurtosis;                 // Computed sample excess kurtosis
   double            m_percentile25;                   // 25th percentile (Q1)
   double            m_percentile50;                   // 50th percentile (median)
   double            m_percentile75;                   // 75th percentile (Q3)
   double            m_confidenceInterval95Lower;      // Lower bound of 95% confidence interval
   double            m_confidenceInterval95Upper;      // Upper bound of 95% confidence interval
   double            m_confidenceInterval99Lower;      // Lower bound of 99% confidence interval
   double            m_confidenceInterval99Upper;      // Upper bound of 99% confidence interval

   double            m_targetAngleX;          // Target camera elevation angle for animation
   double            m_targetAngleY;          // Target camera azimuth angle for animation
   bool              m_isAnimating;           // True while a camera snap animation is running
   int               m_animSteps;             // Total number of animation interpolation steps
   int               m_animStep;              // Current animation step index
   double            m_animStartAngleX;       // Camera elevation angle at animation start
   double            m_animStartAngleY;       // Camera azimuth angle at animation start
   string            m_vcubeHoverZone;        // Name of the view cube zone under the cursor
   int               m_vcubeCenterX;          // Screen X of the view cube widget centre
   int               m_vcubeCenterY;          // Screen Y of the view cube widget centre

public:
   //+------------------------------------------------------------------+
   //| Initialise all member variables to safe defaults                 |
   //+------------------------------------------------------------------+
   DistributionVisualizer(void)
     {
      //--- Reset canvas hover flag
      m_isHoveringCanvas = false;
      //--- Reset header hover flag
      m_isHoveringHeader = false;
      //--- Reset resize zone hover flag
      m_isHoveringResizeZone = false;
      //--- Reset switch icon hover flag
      m_isHoveringSwitchIcon = false;
      //--- Reset pan icon hover flag
      m_isHoveringPanIcon = false;
      //--- Reset view cube hover flag
      m_isHoveringViewCube = false;
      //--- Reset last mouse X
      m_lastMouseX = 0;
      //--- Reset last mouse Y
      m_lastMouseY = 0;
      //--- Reset previous mouse button state
      m_previousMouseButtonState = 0;

      //--- Set view mode from input
      m_currentViewMode = viewMode;
      //--- Mark 3D objects as not yet created
      m_are3DObjectsCreated = false;

      //--- Set camera distance from input
      m_cameraDistance = initialCameraDistance;
      //--- Set camera elevation angle from input
      m_cameraAngleX = initialCameraAngleX;
      //--- Set camera azimuth angle from input
      m_cameraAngleY = initialCameraAngleY;
      //--- Reset 3D orbit drag origin X
      m_mouse3DStartX = -1;
      //--- Reset 3D orbit drag origin Y
      m_mouse3DStartY = -1;
      //--- Mark 3D orbit as inactive
      m_isRotating3D = false;
      //--- Start in orbit mode (not pan)
      m_panMode = false;
      //--- Mark pan drag as inactive
      m_isPanning = false;
      //--- Reset pan drag origin X
      m_panStartX = 0;
      //--- Reset pan drag origin Y
      m_panStartY = 0;
      //--- Initialise view target at scene origin
      m_viewTarget = DXVector3(0.0f, 0.0f, 0.0f);

      //--- Reset data range minimum
      m_minDataValue = 0.0;
      //--- Reset data range maximum
      m_maxDataValue = 0.0;
      //--- Reset peak histogram frequency
      m_maxFrequency = 0.0;
      //--- Reset peak theoretical PMF value
      m_maxTheoreticalValue = 0.0;
      //--- Mark data as not yet loaded
      m_isDataLoaded = false;

      //--- Reset sample mean
      m_sampleMean = 0.0;
      //--- Reset standard deviation
      m_sampleStandardDeviation = 0.0;
      //--- Reset skewness
      m_sampleSkewness = 0.0;
      //--- Reset kurtosis
      m_sampleKurtosis = 0.0;
      //--- Reset 25th percentile
      m_percentile25 = 0.0;
      //--- Reset median
      m_percentile50 = 0.0;
      //--- Reset 75th percentile
      m_percentile75 = 0.0;
      //--- Reset 95% CI lower bound
      m_confidenceInterval95Lower = 0.0;
      //--- Reset 95% CI upper bound
      m_confidenceInterval95Upper = 0.0;
      //--- Reset 99% CI lower bound
      m_confidenceInterval99Lower = 0.0;
      //--- Reset 99% CI upper bound
      m_confidenceInterval99Upper = 0.0;

      //--- Reset animation target elevation
      m_targetAngleX = 0.0;
      //--- Reset animation target azimuth
      m_targetAngleY = 0.0;
      //--- Mark animation as inactive
      m_isAnimating = false;
      //--- Set default animation step count
      m_animSteps = 20;
      //--- Reset current animation step
      m_animStep = 0;
      //--- Reset animation start elevation
      m_animStartAngleX = 0.0;
      //--- Reset animation start azimuth
      m_animStartAngleY = 0.0;
      //--- Clear view cube hover zone
      m_vcubeHoverZone = "";
      //--- Reset view cube centre X
      m_vcubeCenterX = 0;
      //--- Reset view cube centre Y
      m_vcubeCenterY = 0;
      //--- Reset curve segment count
      m_curveSegmentCount = 0;
     }

   //--- Update destructor to shutdown new curve segments array

   //+------------------------------------------------------------------+
   //| Release all 3D scene objects on destruction                      |
   //+------------------------------------------------------------------+
   ~DistributionVisualizer(void)
     {
      //--- Get total number of histogram bar boxes
      int count = ArraySize(m_histogramBars);
      //--- Release DirectX resources for every histogram bar
      for(int i = 0; i < count; i++)
         m_histogramBars[i].Shutdown();
      //--- Release DirectX resources for every curve tube segment
      for(int i = 0; i < m_curveSegmentCount; i++)
         m_curveSegments[i].Shutdown();
      //--- Release ground plane DirectX resources
      m_groundPlane.Shutdown();
      //--- Release X axis DirectX resources
      m_axisX.Shutdown();
      //--- Release Y axis DirectX resources
      m_axisY.Shutdown();
      //--- Release Z axis DirectX resources
      m_axisZ.Shutdown();
     }
   }

Сначала добавим новую группу input "=== VIEW CUBE SETTINGS ===" с параметром "showViewCubeBackground" по умолчанию, равным false, что позволит нам переключать фон для наложения view cube. Следующим шагом определим константы для значка панорамирования и куба обзора: "PAN_ICON_SIZE" и "PAN_ICON_MARGIN" для размеров переключения панорамирования, "VCUBE_SIZE" и "VCUBE_MARGIN" для размещения куба обзора. В защищенном разделе класса "DistributionVisualizer" введем члены для расширенного взаимодействия: "m_isHoveringPanIcon" и "m_isHoveringViewCube" для обнаружения наведения курсора, массив "m_curveSegments" и "m_curveSegmentCount" для управления 3D-кривыми, логические значения "m_panMode" и "m_isPanning" с параметрами "m_panStartX" и "m_panStartY" для состояния панорамирования, "m_viewTarget" в формате DXVector3 для фокусировки камеры, "m_targetAngleX" и "m_targetAngleY" для целей анимации, "m_isAnimating" с параметрами "m_animSteps", "m_animStep", "m_animStartAngleX" и "m_animStartAngleY" для плавных переходов, строку "m_vcubeHoverZone" для обнаруженных областей куба, а также "m_vcubeCenterX" и "m_vcubeCenterY" для позиционирования куба.

В конструкторе инициализируем следующие параметры: установим "m_isHoveringPanIcon" и "m_isHoveringViewCube" в false, "m_curveSegmentCount" в 0, "m_panMode" и "m_isPanning" в false, "m_panStartX" и "m_panStartY" в 0, "m_viewTarget" в вектор начала координат, углы цели в 0.0, "m_isAnimating" в false, "m_animSteps" в 20, "m_animStep" в 0, углы начала в 0.0, "m_vcubeHoverZone" в пустую строку, а центры куба в 0. Обновим деструктор, чтобы завершить работу нового массива "m_curveSegments", перебирая "m_curveSegmentCount" и вызывая метод "Shutdown" для каждого элемента, а также выполняя существующие операции очистки для столбцов, плоскости и осей. После этого обновим инициализацию трехмерного контекста, используя новую переменную-член следующим образом. Для большей ясности мы выделили конкретное изменение.

Обновление инициализации 3D-контекста для динамической целевой точки камеры

Единственное изменение в этой части заключается в замене жестко заданного вектора начала координат, передаваемого в "ViewTargetSet", на переменную-член "m_viewTarget", так что операции панорамирования, обновляющие "m_viewTarget", будут немедленно отображаться при изменении положения камеры.

//+------------------------------------------------------------------+
//| Set up projection, lighting and initial camera for 3D rendering  |
//+------------------------------------------------------------------+
bool initialize3DContext()
  {
   //--- Set perspective projection matrix with 30-degree FOV
   m_mainCanvas.ProjectionMatrixSet((float)(DX_PI / 6.0),
                                    (float)m_currentWidth / (float)m_currentHeight,
                                    0.1f, 1000.0f);
   //--- Point the camera at the current view target
   m_mainCanvas.ViewTargetSet(m_viewTarget);
   //--- Define world up direction as positive Y
   m_mainCanvas.ViewUpDirectionSet(DXVector3(0.0f, 1.0f, 0.0f));
   //--- Set directional light colour to near-white
   m_mainCanvas.LightColorSet(DXColor(1.0f, 1.0f, 1.0f, 0.9f));
   //--- Set ambient light colour for soft fill
   m_mainCanvas.AmbientColorSet(DXColor(0.6f, 0.6f, 0.6f, 0.5f));

   //--- Auto-fit camera if enabled and data is already available
   if(autoFitCamera && m_isDataLoaded)
      autoFitCameraPosition();
   //--- Recompute and apply the camera transform
   updateCameraPosition();

   Print("SUCCESS: 3D context initialized");
   return true;
  }

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

Создание трехмерных сегментов кривой

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

//+------------------------------------------------------------------+
//| Allocate one DXBox tube segment per PMF curve interval           |
//+------------------------------------------------------------------+
bool create3DCurveSegments()
  {
   //--- Get the total number of PMF sample points
   int numPoints = ArraySize(m_theoreticalXValues);
   Print("DEBUG: create3DCurveSegments called, numPoints=", numPoints);
   //--- Need at least two points to form a segment
   if(numPoints < 2) return false;

   //--- Shutdown any previously created segments before rebuilding
   for(int i = 0; i < m_curveSegmentCount; i++)
      m_curveSegments[i].Shutdown();

   //--- One segment per adjacent pair of PMF points
   m_curveSegmentCount = numPoints - 1;
   ArrayResize(m_curveSegments, m_curveSegmentCount);

   //--- Decompose PMF curve colour into RGB byte components
   uchar cr = (uchar)((theoreticalCurveColor)       & 0xFF);
   uchar cg = (uchar)((theoreticalCurveColor >> 8)  & 0xFF);
   uchar cb = (uchar)((theoreticalCurveColor >> 16) & 0xFF);

   //--- Create and configure each tube segment box
   for(int i = 0; i < m_curveSegmentCount; i++)
     {
      //--- Create unit box; actual transform applied in update step
      if(!m_curveSegments[i].Create(m_mainCanvas.DXDispatcher(), m_mainCanvas.InputScene(),
                                    DXVector3(0.0f, -0.5f, -0.5f),
                                    DXVector3(1.0f,  0.5f,  0.5f)))
        {
         Print("ERROR: Failed to create curve segment ", i);
         return false;
        }
      //--- Set the segment diffuse colour from the curve colour input
      m_curveSegments[i].DiffuseColorSet(DXColor(cr / 255.0f, cg / 255.0f, cb / 255.0f, 1.0f));
      //--- Add a specular highlight to the tube surface
      m_curveSegments[i].SpecularColorSet(DXColor(0.3f, 0.3f, 0.3f, 0.5f));
      //--- Set specular shininess exponent
      m_curveSegments[i].SpecularPowerSet(32.0f);
      //--- Add a faint self-emission for visibility in shadowed areas
      m_curveSegments[i].EmissionColorSet(DXColor(cr / 255.0f * 0.25f,
                                                   cg / 255.0f * 0.25f,
                                                   cb / 255.0f * 0.25f, 1.0f));
      //--- Register the segment with the 3D scene
      m_mainCanvas.ObjectAdd(GetPointer(m_curveSegments[i]));
     }

   Print("DEBUG: Created ", m_curveSegmentCount, " curve segments successfully");
   return true;
  }

В этой части определим функцию "create3DCurveSegments" для генерации ряда трехмерных примитивов типа box, образующих трубчатое представление теоретической кривой функции массы вероятности. В этом контексте трубчатый подход означает представление кривой в виде последовательности соединенных параллелепипедных сегментов, вместе напоминающих непрерывную трубку. Это придает кривой глубину и плавную визуальную непрерывность в трехмерной сцене. Трубчатый подход — это самый простой и наглядный подход, который мы выбрали для данной демонстрации. Вы можете использовать любой подход на своё усмотрение. Сначала получим количество теоретических точек с помощью параметра ArraySize в переменной "m_theoreticalXValues" и выведем отладочное сообщение. Если точек меньше двух, возвращаем false, так как сегменты создать невозможно. Затем в цикле закроем все существующие сегменты в массиве "m_curveSegments" с помощью функции "Shutdown", установим "m_curveSegmentCount" равным количеству точек минус один и изменим размер массива с помощью функции ArrayResize

Следующим шагом извлечем компоненты RGB из "theoreticalCurveColor" с помощью битовых операций. В цикле для каждого сегмента вызовем метод "Create" для объекта box с диспетчером DX, входной сценой и векторными размерами для формы, визуально напоминающей цилиндрический сегмент. Обработаем сбой выводом сообщения об ошибке и вернем значение false. Установим существенные свойства: диффузный цвет с помощью "DiffuseColorSet" с использованием нормализованного RGB, зеркальный цвет с помощью "SpecularColorSet" для блеска, мощность с помощью "SpecularPowerSet" на уровне 32.0f и излучение с легким оттенком для свечения с помощью "EmissionColorSet". Добавляем каждый сегмент на объект Canvas с помощью функции "ObjectAdd", передающей указатель. Выведем сообщение об успешном завершении отладки и вернем значение true.

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

Обновление преобразований сегментов кривой для отслеживания точек данных

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

//+------------------------------------------------------------------+
//| Reposition and orient every PMF curve tube segment to match data |
//+------------------------------------------------------------------+
void update3DCurveSegments()
  {
   //--- Abort if data or segments are not ready
   if(!m_isDataLoaded || m_curveSegmentCount == 0) return;

   //--- Compute data ranges for world-space mapping
   double rangeX = m_maxDataValue - m_minDataValue;
   double rangeY = m_maxTheoreticalValue;
   if(rangeX == 0) rangeX = 1;
   if(rangeY == 0) rangeY = 1;

   //--- Match the total scene width used for the histogram bars
   float totalWidth  = 30.0f;
   float offsetX     = -totalWidth / 2.0f;
   float barSpacing  = totalWidth / (float)histogramCells;
   float bw          = barSpacing * 0.8f;
   //--- Tube radius controls the 3D thickness of each curve segment
   float tubeRadius  = 0.2f;
   //--- Place the curve in front of the histogram bars along Z
   float zPos        = bw / 2.0f + 0.5f;

   //--- Limit debug logging to the first call only
   static bool firstDebug = true;

   //--- Build and apply a custom transform matrix for each segment
   for(int i = 0; i < m_curveSegmentCount; i++)
     {
      //--- Map PMF X and Y values to 3D world space coordinates
      float x1 = offsetX + (float)((m_theoreticalXValues[i]     - m_minDataValue) / rangeX * totalWidth);
      float y1 = (float)(m_theoreticalYValues[i]     / rangeY * 15.0);
      float x2 = offsetX + (float)((m_theoreticalXValues[i + 1] - m_minDataValue) / rangeX * totalWidth);
      float y2 = (float)(m_theoreticalYValues[i + 1] / rangeY * 15.0);

      //--- Compute the segment direction vector components
      float dx = x2 - x1;
      float dy = y2 - y1;
      //--- Compute segment length for normalisation
      float segLen = (float)MathSqrt(dx * dx + dy * dy);
      if(segLen < 0.0001f) segLen = 0.0001f;
      //--- Normalise the direction vector
      float dirX = dx / segLen;
      float dirY = dy / segLen;

      //--- Log the first segment only for diagnostics
      if(firstDebug && i == 0)
         Print("DEBUG curve seg0: x1=", x1, " y1=", y1, " x2=", x2, " y2=", y2,
               " len=", segLen, " z=", zPos);

      //--- Build a custom transform that scales, rotates and translates the unit box
      //--- The transform rows encode: [right, up, depth, translation]
      DXMatrix transform;
      transform.m[0][0] = dirX * segLen;      transform.m[0][1] = dirY * segLen;      transform.m[0][2] = 0.0f;         transform.m[0][3] = 0.0f;
      transform.m[1][0] = -dirY * tubeRadius; transform.m[1][1] = dirX * tubeRadius;  transform.m[1][2] = 0.0f;         transform.m[1][3] = 0.0f;
      transform.m[2][0] = 0.0f;               transform.m[2][1] = 0.0f;               transform.m[2][2] = tubeRadius;   transform.m[2][3] = 0.0f;
      transform.m[3][0] = x1;                 transform.m[3][1] = y1;                 transform.m[3][2] = zPos;         transform.m[3][3] = 1.0f;

      //--- Apply the combined transform to this curve segment
      m_curveSegments[i].TransformMatrixSet(transform);
     }
   //--- Suppress first-call debug logging after the first pass
   firstDebug = false;
  }

В этой части мы определим функцию "update3DCurveSegments" для динамического позиционирования и ориентации каждого трехмерного прямоугольного сегмента, представляющего кривую функции массы вероятности, гарантируя, что она повторяет точки данных и имеет трубчатый вид для увеличения глубины. Если данные не загружены или не существует сегментов, функция немедленно завершит работу. Далее вычислим диапазоны X и Y с соблюдением минимальных мер предосторожности, установим общую ширину, соответствующую гистограмме, для выравнивания и определим смещение, расстояние между столбцами, ширину, радиус трубки и положение по оси Z немного позади столбцов для управления слоями отображения. В цикле по "m_curveSegmentCount" масштабируем координаты x1, y1, x2, y2, чтобы они соответствовали трехмерному пространству до высоты 15,0f, вычислим вектор направления из дельт, нормализованных по длине сегмента (с небольшим минимумом, чтобы избежать деления на ноль), и, при необходимости, выводим отладочную информацию для первого сегмента при первом вызове, используя статический флаг.

Чтобы преобразовать каждый единичный параллелепипед в ориентированную трубку, создадим преобразование "DXMatrix": строка 0 растягивает вдоль направления на длину, строка 1 поворачивает перпендикулярно на толщину радиуса, строка 2 устанавливает радиус глубины, а строка 3 перемещается в исходное положение в zPos. Далее для точного размещения применяем его с помощью "TransformMatrixSet". Наконец, после первого запуска сбросим флаг отладки, завершим обновление, которое преобразует данные плоской кривой во вращающуюся трехмерную структуру. Так улучшится восприятие изменений вероятности по всему распределению. После того, как функции создания и обновления готовы, подключим их к существующей настройке сцены следующим образом.

Включение создания сегментов кривой и их обновлений в конвейер сцен

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

//--- Update "create3DObjects" to attempt creating curve segments if data is loaded

//--- Attempt to create PMF curve tube segments (non-fatal if it fails)
if(m_isDataLoaded && !create3DCurveSegments())
   Print("WARNING: Failed to create 3D curve segments");

//--- Update "setup3DMode" to create and update curve segments if needed

//--- Build curve segments if they were not created during init
if(m_isDataLoaded && m_curveSegmentCount == 0)
   create3DCurveSegments();

//--- Update "loadDistributionData" to update curve segments in 3D mode

if(m_curveSegmentCount == 0)
   create3DCurveSegments();
//--- Reposition every curve segment in 3D space
update3DCurveSegments();

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

3D CURVE SEGMENTS

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

Визуализация значка переключения режима панорамирования

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

//+------------------------------------------------------------------+
//| Draw the pan mode toggle button overlaid on the 3D canvas        |
//+------------------------------------------------------------------+
void drawPanIconOverlay()
  {
   //--- Pan icon is only drawn in 3D mode
   if(m_currentViewMode != VIEW_3D_MODE) return;

   //--- Compute icon position below the header on the right side
   int iconX = m_currentWidth  - PAN_ICON_SIZE - PAN_ICON_MARGIN;
   int iconY = HEADER_BAR_HEIGHT + PAN_ICON_MARGIN;
   int cx    = iconX + PAN_ICON_SIZE / 2;
   int cy    = iconY + PAN_ICON_SIZE / 2;
   int rad   = PAN_ICON_SIZE / 2;

   //--- Compute icon background colour based on interaction state
   color iconBgColor;
   if(m_panMode)
      iconBgColor = clrGreen;                         // Active pan mode: green
   else if(m_isHoveringPanIcon)
      iconBgColor = DarkenColor(themeColor, 0.1);     // Hover: slightly darker
   else
      iconBgColor = LightenColor(themeColor, 0.5);    // Default: lighter shade

   uint argbBg     = ColorToARGB(iconBgColor, 220);
   uint argbBorder = ColorToARGB(themeColor, 255);
   uint argbWhite  = ColorToARGB(clrWhite, 255);

   //--- Fill the circular icon background
   for(int py = cy - rad; py <= cy + rad; py++)
      for(int px = cx - rad; px <= cx + rad; px++)
        {
         int dx = px - cx;
         int dy = py - cy;
         if(dx * dx + dy * dy <= rad * rad)
            blendPixelSet(m_mainCanvas, px, py, argbBg);
        }

   //--- Draw the circular icon border ring
   for(int py = cy - rad; py <= cy + rad; py++)
      for(int px = cx - rad; px <= cx + rad; px++)
        {
         int dx = px - cx;
         int dy = py - cy;
         int distSq = dx * dx + dy * dy;
         if(distSq <= rad * rad && distSq >= (rad - 1) * (rad - 1))
            blendPixelSet(m_mainCanvas, px, py, argbBorder);
        }

   //--- Draw the four-arrow pan icon symbol in white
   int arrLen  = 4;
   int arrHead = 2;
   //--- Draw the horizontal crosshair bar
   for(int i = -arrLen; i <= arrLen; i++)
      blendPixelSet(m_mainCanvas, cx + i, cy, argbWhite);
   //--- Draw the vertical crosshair bar
   for(int i = -arrLen; i <= arrLen; i++)
      blendPixelSet(m_mainCanvas, cx, cy + i, argbWhite);
   //--- Draw the four arrowheads at each end of the crosshair
   for(int i = 0; i <= arrHead; i++)
     {
      blendPixelSet(m_mainCanvas, cx + arrLen - i, cy - i, argbWhite); // Right arrow top
      blendPixelSet(m_mainCanvas, cx + arrLen - i, cy + i, argbWhite); // Right arrow bottom
      blendPixelSet(m_mainCanvas, cx - arrLen + i, cy - i, argbWhite); // Left arrow top
      blendPixelSet(m_mainCanvas, cx - arrLen + i, cy + i, argbWhite); // Left arrow bottom
      blendPixelSet(m_mainCanvas, cx - i, cy - arrLen + i, argbWhite); // Up arrow left
      blendPixelSet(m_mainCanvas, cx + i, cy - arrLen + i, argbWhite); // Up arrow right
      blendPixelSet(m_mainCanvas, cx - i, cy + arrLen - i, argbWhite); // Down arrow left
      blendPixelSet(m_mainCanvas, cx + i, cy + arrLen - i, argbWhite); // Down arrow right
     }
  }

Определим функцию "drawPanIconOverlay" для отрисовки круглого значка-переключателя для режима панорамирования в 3D-представлении, расположенного под заголовком, используя такие константы, как "PAN_ICON_SIZE" и "PAN_ICON_MARGIN". Выберем цвет фона: зеленый, если активен "m_panMode", затемненный "themeColor" при наведении курсора мыши с помощью "DarkenColor" или осветленный в противном случае с помощью "LightenColor". Затем преобразуем в ARGB с частичной непрозрачностью. Чтобы создать кнопку, циклом проходим по квадратной области и смешиваем пиксели внутри радиуса с помощью "blendPixelSet" для заливки и отдельно для границы, проверяем внешнее кольцо, используя ARGB темы для контура.

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

PAN OVERLAY

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

Проецирование и отрисовка наложения куба обзора

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

//+------------------------------------------------------------------+
//| Project a 3D view cube vertex into 2D screen coordinates         |
//+------------------------------------------------------------------+
void vcubeProject(double x, double y, double z, double angX, double angY, int &sx, int &sy)
  {
   //--- Pre-compute trigonometric values for both rotation angles
   double cosAX = MathCos(angX), sinAX = MathSin(angX);
   double cosAY = MathCos(angY), sinAY = MathSin(angY);
   //--- Rotate the point around the X axis (elevation)
   double y2 = y * cosAX - z * sinAX;
   double z2 = y * sinAX + z * cosAX;
   //--- Rotate the result around the Y axis (azimuth)
   double x2 = x * cosAY + z2 * sinAY;
   //--- Apply a fixed orthographic scale relative to the cube widget size
   double scale = VCUBE_SIZE * 0.30;
   //--- Map to screen pixels relative to the view cube centre
   sx = m_vcubeCenterX + (int)(x2 * scale);
   sy = m_vcubeCenterY - (int)(y2 * scale);
  }

//+------------------------------------------------------------------+
//| Draw and label the interactive 3D view cube overlay widget       |
//+------------------------------------------------------------------+
void drawViewCubeOverlay()
  {
   //--- View cube is only drawn in 3D mode
   if(m_currentViewMode != VIEW_3D_MODE) return;

   //--- Compute the view cube bounding area below the pan icon
   int areaX       = m_currentWidth  - VCUBE_SIZE - VCUBE_MARGIN;
   int areaY       = HEADER_BAR_HEIGHT + PAN_ICON_MARGIN + PAN_ICON_SIZE + VCUBE_MARGIN;
   m_vcubeCenterX  = areaX + VCUBE_SIZE / 2;
   m_vcubeCenterY  = areaY + VCUBE_SIZE / 2;

   //--- Optionally draw a semi-transparent background panel behind the cube
   if(showViewCubeBackground)
     {
      uint argbPanelBg     = ColorToARGB(LightenColor(themeColor, 0.92), 200);
      uint argbPanelBorder = ColorToARGB(themeColor, 200);
      //--- Fill the background panel
      for(int py = areaY; py < areaY + VCUBE_SIZE; py++)
         for(int px = areaX; px < areaX + VCUBE_SIZE; px++)
            blendPixelSet(m_mainCanvas, px, py, argbPanelBg);
      //--- Draw top and bottom borders
      for(int px = areaX; px < areaX + VCUBE_SIZE; px++)
        {
         blendPixelSet(m_mainCanvas, px, areaY, argbPanelBorder);
         blendPixelSet(m_mainCanvas, px, areaY + VCUBE_SIZE - 1, argbPanelBorder);
        }
      //--- Draw left and right borders
      for(int py = areaY; py < areaY + VCUBE_SIZE; py++)
        {
         blendPixelSet(m_mainCanvas, areaX, py, argbPanelBorder);
         blendPixelSet(m_mainCanvas, areaX + VCUBE_SIZE - 1, py, argbPanelBorder);
        }
     }

   //--- Use the current camera angles as the cube orientation
   double angX = m_cameraAngleX;
   double angY = m_cameraAngleY;

   //--- Define the 8 vertices of the unit cube in local space
   double verts[8][3];
   verts[0][0] = -1; verts[0][1] = -1; verts[0][2] = -1;
   verts[1][0] =  1; verts[1][1] = -1; verts[1][2] = -1;
   verts[2][0] =  1; verts[2][1] =  1; verts[2][2] = -1;
   verts[3][0] = -1; verts[3][1] =  1; verts[3][2] = -1;
   verts[4][0] = -1; verts[4][1] = -1; verts[4][2] =  1;
   verts[5][0] =  1; verts[5][1] = -1; verts[5][2] =  1;
   verts[6][0] =  1; verts[6][1] =  1; verts[6][2] =  1;
   verts[7][0] = -1; verts[7][1] =  1; verts[7][2] =  1;

   //--- Project all 8 vertices to screen space
   int sv[8][2];
   for(int i = 0; i < 8; i++)
      vcubeProject(verts[i][0], verts[i][1], verts[i][2], angX, angY, sv[i][0], sv[i][1]);

   //--- Define the 6 faces by their vertex indices (counter-clockwise)
   int faces[6][4];
   faces[0][0]=0; faces[0][1]=1; faces[0][2]=2; faces[0][3]=3; // Front
   faces[1][0]=5; faces[1][1]=4; faces[1][2]=7; faces[1][3]=6; // Back
   faces[2][0]=3; faces[2][1]=2; faces[2][2]=6; faces[2][3]=7; // Top
   faces[3][0]=0; faces[3][1]=1; faces[3][2]=5; faces[3][3]=4; // Bottom
   faces[4][0]=1; faces[4][1]=5; faces[4][2]=6; faces[4][3]=2; // Right
   faces[5][0]=4; faces[5][1]=0; faces[5][2]=3; faces[5][3]=7; // Left

   //--- Face label strings for text overlay
   string faceNames[6];
   faceNames[0] = "Front";
   faceNames[1] = "Back";
   faceNames[2] = "Top";
   faceNames[3] = "Bottom";
   faceNames[4] = "Right";
   faceNames[5] = "Left";

   //--- Face outward normals for back-face culling
   double faceNormals[6][3];
   faceNormals[0][0]= 0; faceNormals[0][1]= 0; faceNormals[0][2]=-1;
   faceNormals[1][0]= 0; faceNormals[1][1]= 0; faceNormals[1][2]= 1;
   faceNormals[2][0]= 0; faceNormals[2][1]= 1; faceNormals[2][2]= 0;
   faceNormals[3][0]= 0; faceNormals[3][1]=-1; faceNormals[3][2]= 0;
   faceNormals[4][0]= 1; faceNormals[4][1]= 0; faceNormals[4][2]= 0;
   faceNormals[5][0]=-1; faceNormals[5][1]= 0; faceNormals[5][2]= 0;

   //--- Camera direction vector for depth sorting and back-face culling
   double camDir[3];
   camDir[0] =  MathCos(angX) * MathSin(angY);
   camDir[1] = -MathSin(angX);
   camDir[2] = -MathCos(angX) * MathCos(angY);

   //--- Sort faces back-to-front using their projected depth (painter's algorithm)
   int    faceOrder[6];
   double faceDepth[6];
   for(int i = 0; i < 6; i++)
     {
      faceOrder[i] = i;
      double cx = 0, cy2 = 0, cz = 0;
      //--- Compute the face centre as the average of its four vertices
      for(int j = 0; j < 4; j++)
        {
         cx  += verts[faces[i][j]][0];
         cy2 += verts[faces[i][j]][1];
         cz  += verts[faces[i][j]][2];
        }
      cx /= 4.0; cy2 /= 4.0; cz /= 4.0;
      //--- Project the centre onto the camera direction for depth
      faceDepth[i] = cx * camDir[0] + cy2 * camDir[1] + cz * camDir[2];
     }
   //--- Bubble-sort the face order from farthest to nearest
   for(int i = 0; i < 5; i++)
      for(int j = i + 1; j < 6; j++)
         if(faceDepth[faceOrder[i]] > faceDepth[faceOrder[j]])
           {
            int tmp = faceOrder[i];
            faceOrder[i] = faceOrder[j];
            faceOrder[j] = tmp;
           }

   //--- Assign a distinct base colour to each face for identification
   color faceColors[6];
   faceColors[0] = clrCornflowerBlue; // Front
   faceColors[1] = clrSteelBlue;      // Back
   faceColors[2] = clrLimeGreen;      // Top
   faceColors[3] = clrDarkGreen;      // Bottom
   faceColors[4] = clrOrangeRed;      // Right
   faceColors[5] = clrDarkOrange;     // Left

   //--- Sub-face zone names for all 6 faces (3x3 grid per face)
   string faceSubNames[6][3][3];
   // Front face sub-zones
   faceSubNames[0][0][0] = "BottomFrontLeft";   faceSubNames[0][1][0] = "BottomFront";   faceSubNames[0][2][0] = "BottomFrontRight";
   faceSubNames[0][0][1] = "FrontLeft";          faceSubNames[0][1][1] = "Front";         faceSubNames[0][2][1] = "FrontRight";
   faceSubNames[0][0][2] = "TopFrontLeft";       faceSubNames[0][1][2] = "TopFront";      faceSubNames[0][2][2] = "TopFrontRight";
   // Back face sub-zones
   faceSubNames[1][0][0] = "BottomBackLeft";     faceSubNames[1][1][0] = "BottomBack";    faceSubNames[1][2][0] = "BottomBackRight";
   faceSubNames[1][0][1] = "BackLeft";           faceSubNames[1][1][1] = "Back";          faceSubNames[1][2][1] = "BackRight";
   faceSubNames[1][0][2] = "TopBackLeft";        faceSubNames[1][1][2] = "TopBack";       faceSubNames[1][2][2] = "TopBackRight";
   // Top face sub-zones
   faceSubNames[2][0][0] = "TopFrontLeft";       faceSubNames[2][1][0] = "TopFront";      faceSubNames[2][2][0] = "TopFrontRight";
   faceSubNames[2][0][1] = "TopLeft";            faceSubNames[2][1][1] = "Top";           faceSubNames[2][2][1] = "TopRight";
   faceSubNames[2][0][2] = "TopBackLeft";        faceSubNames[2][1][2] = "TopBack";       faceSubNames[2][2][2] = "TopBackRight";
   // Bottom face sub-zones
   faceSubNames[3][0][0] = "BottomFrontLeft";   faceSubNames[3][1][0] = "BottomFront";   faceSubNames[3][2][0] = "BottomFrontRight";
   faceSubNames[3][0][1] = "BottomLeft";         faceSubNames[3][1][1] = "Bottom";        faceSubNames[3][2][1] = "BottomRight";
   faceSubNames[3][0][2] = "BottomBackLeft";     faceSubNames[3][1][2] = "BottomBack";    faceSubNames[3][2][2] = "BottomBackRight";
   // Right face sub-zones
   faceSubNames[4][0][0] = "BottomFrontRight";  faceSubNames[4][1][0] = "BottomRight";   faceSubNames[4][2][0] = "BottomBackRight";
   faceSubNames[4][0][1] = "FrontRight";         faceSubNames[4][1][1] = "Right";         faceSubNames[4][2][1] = "BackRight";
   faceSubNames[4][0][2] = "TopFrontRight";      faceSubNames[4][1][2] = "TopRight";      faceSubNames[4][2][2] = "TopBackRight";
   // Left face sub-zones
   faceSubNames[5][0][0] = "BottomFrontLeft";   faceSubNames[5][1][0] = "BottomLeft";    faceSubNames[5][2][0] = "BottomBackLeft";
   faceSubNames[5][0][1] = "FrontLeft";          faceSubNames[5][1][1] = "Left";          faceSubNames[5][2][1] = "BackLeft";
   faceSubNames[5][0][2] = "TopFrontLeft";       faceSubNames[5][1][2] = "TopLeft";       faceSubNames[5][2][2] = "TopBackLeft";

   //--- Shrink factor creates a small visible gap between sub-face tiles
   double shrink = 0.03;

   //--- Render visible faces in back-to-front order
   for(int fi = 0; fi < 6; fi++)
     {
      int f = faceOrder[fi];
      //--- Compute the dot product to cull back-facing faces
      double dot = faceNormals[f][0] * camDir[0] +
                   faceNormals[f][1] * camDir[1] +
                   faceNormals[f][2] * camDir[2];
      if(dot >= 0) continue;

      //--- Compute Lambert diffuse brightness from the dot product
      double brightness = MathAbs(dot);
      brightness = 0.4 + 0.6 * brightness;

      //--- Apply brightness to the base face colour
      color fc = faceColors[f];
      uchar fr_base = (uchar)MathMin(255, (int)(((fc >> 16) & 0xFF) * brightness));
      uchar fg_base = (uchar)MathMin(255, (int)(((fc >> 8)  & 0xFF) * brightness));
      uchar fb_base = (uchar)MathMin(255, (int)(( fc        & 0xFF) * brightness));

      uchar alpha = 180;

      //--- Get the four projected screen vertices for this face
      int fx[4], fy[4];
      for(int j = 0; j < 4; j++)
        {
         fx[j] = sv[faces[f][j]][0];
         fy[j] = sv[faces[f][j]][1];
        }

      //--- Draw each 3x3 sub-face tile using bilinear interpolation
      for(int i = 0; i < 3; i++)
         for(int j = 0; j < 3; j++)
           {
            //--- Compute the UV bounds for this sub-tile with shrink margin
            double u1 = i / 3.0 + shrink;
            double u2 = (i + 1) / 3.0 - shrink;
            double v1 = j / 3.0 + shrink;
            double v2 = (j + 1) / 3.0 - shrink;
            if(u1 >= u2 || v1 >= v2) continue;

            //--- Compute the four screen-space corners of this sub-tile
            int sub_fx[4], sub_fy[4];
            sub_fx[0] = (int)bilinear(fx[0], fx[1], fx[3], fx[2], u1, v1);
            sub_fy[0] = (int)bilinear(fy[0], fy[1], fy[3], fy[2], u1, v1);
            sub_fx[1] = (int)bilinear(fx[0], fx[1], fx[3], fx[2], u2, v1);
            sub_fy[1] = (int)bilinear(fy[0], fy[1], fy[3], fy[2], u2, v1);
            sub_fx[2] = (int)bilinear(fx[0], fx[1], fx[3], fx[2], u2, v2);
            sub_fy[2] = (int)bilinear(fy[0], fy[1], fy[3], fy[2], u2, v2);
            sub_fx[3] = (int)bilinear(fx[0], fx[1], fx[3], fx[2], u1, v2);
            sub_fy[3] = (int)bilinear(fy[0], fy[1], fy[3], fy[2], u1, v2);

            //--- Look up the zone name for this sub-tile
            string subZone  = faceSubNames[f][i][j];
            //--- Check if this tile is the one currently hovered
            bool isHovered = (m_vcubeHoverZone == subZone);

            //--- Copy base colour channels for potential brightening
            uchar fr = fr_base;
            uchar fg = fg_base;
            uchar fb = fb_base;
            //--- Increase alpha and brighten colour when hovered
            uchar subAlpha = isHovered ? (uchar)240 : alpha;
            if(isHovered)
              {
               fr = (uchar)MathMin(255, fr + 40);
               fg = (uchar)MathMin(255, fg + 40);
               fb = (uchar)MathMin(255, fb + 40);
              }
            //--- Pack ARGB colour for this sub-tile
            uint argbFace = ((uint)subAlpha << 24) | ((uint)fr << 16) | ((uint)fg << 8) | (uint)fb;
            //--- Fill the sub-tile quad with the computed colour
            fillQuad(sub_fx, sub_fy, argbFace);
           }

      //--- Draw the four black outline edges of the face
      uint argbEdge = ColorToARGB(clrBlack, 200);
      for(int j = 0; j < 4; j++)
        {
         int next = (j + 1) % 4;
         m_mainCanvas.LineAA(fx[j], fy[j], fx[next], fy[next], argbEdge);
        }

      //--- Compute the face screen-space centre for text placement
      int fcx = (fx[0] + fx[1] + fx[2] + fx[3]) / 4;
      int fcy = (fy[0] + fy[1] + fy[2] + fy[3]) / 4;

      //--- Draw the face name label only for well-lit faces
      if(brightness > 0.5)
        {
         m_mainCanvas.FontSet("Arial Bold", 7);
         uint textCol = ColorToARGB(clrWhite, 220);
         m_mainCanvas.TextOut(fcx, fcy - 4, faceNames[f], textCol, TA_CENTER);
        }
     }

   //--- Draw the three coordinate axis indicators on the view cube
   uint argbAxis;
   int  ox, oy;
   vcubeProject(0, 0, 0, angX, angY, ox, oy);

   //--- Draw the X axis indicator and label
   int axEnd;
   argbAxis = ColorToARGB(clrRed, 220);
   vcubeProject(1.4, 0, 0, angX, angY, axEnd, oy);
   m_mainCanvas.LineAA(ox, oy, axEnd, oy, argbAxis);
   m_mainCanvas.FontSet("Arial Bold", 7);
   m_mainCanvas.TextOut(axEnd + 2, oy - 4, "X", ColorToARGB(clrRed, 255), TA_LEFT);

   //--- Draw the Y axis indicator and label
   int dummy;
   argbAxis = ColorToARGB(clrGreen, 220);
   vcubeProject(0, 1.4, 0, angX, angY, dummy, axEnd);
   m_mainCanvas.LineAA(ox, oy, dummy, axEnd, argbAxis);
   m_mainCanvas.TextOut(dummy + 2, axEnd - 4, "Y", ColorToARGB(clrGreen, 255), TA_LEFT);

   //--- Draw the Z axis indicator and label
   argbAxis = ColorToARGB(clrBlue, 220);
   vcubeProject(0, 0, 1.4, angX, angY, axEnd, dummy);
   m_mainCanvas.LineAA(ox, oy, axEnd, dummy, argbAxis);
   m_mainCanvas.TextOut(axEnd + 2, dummy - 4, "Z", ColorToARGB(clrBlue, 255), TA_LEFT);
  }

//+------------------------------------------------------------------+
//| Bilinearly interpolate between four corner values                |
//+------------------------------------------------------------------+
double bilinear(double p00, double p10, double p01, double p11, double u, double v)
  {
   //--- Compute the weighted blend of the four corner values
   return (1 - u) * (1 - v) * p00 + u * (1 - v) * p10 +
          (1 - u) *      v  * p01 + u *       v  * p11;
  }

//+------------------------------------------------------------------+
//| Scanline-fill a convex quadrilateral with the given ARGB colour  |
//+------------------------------------------------------------------+
void fillQuad(int &px[], int &py[], uint clr)
  {
   //--- Determine the vertical span of the quad
   int minY = py[0], maxY = py[0];
   for(int i = 1; i < 4; i++)
     {
      if(py[i] < minY) minY = py[i];
      if(py[i] > maxY) maxY = py[i];
     }

   //--- Scanline-fill the quad row by row
   for(int y = minY; y <= maxY; y++)
     {
      int minX = 99999, maxX = -99999;
      //--- Find the left and right intersection X for this scanline
      for(int i = 0; i < 4; i++)
        {
         int j  = (i + 1) % 4;
         int y1 = py[i], y2 = py[j];
         int x1 = px[i], x2 = px[j];
         //--- Process edges that cross this scanline
         if((y1 <= y && y2 >= y) || (y2 <= y && y1 >= y))
           {
            if(y1 == y2)
              {
               //--- Horizontal edge: include both endpoints
               if(x1 < minX) minX = x1;
               if(x2 < minX) minX = x2;
               if(x1 > maxX) maxX = x1;
               if(x2 > maxX) maxX = x2;
              }
            else
              {
               //--- Non-horizontal edge: interpolate the X intersection
               int ix = x1 + (y - y1) * (x2 - x1) / (y2 - y1);
               if(ix < minX) minX = ix;
               if(ix > maxX) maxX = ix;
              }
           }
        }
      //--- Paint every pixel on this scanline row
      for(int x = minX; x <= maxX; x++)
         blendPixelSet(m_mainCanvas, x, y, clr);
     }
  }

Сначала определим функцию "vcubeProject" для проецирования 3D-точки (x, y, z) в 2D-координаты экрана (sx, sy) на основе углов поворота angX и angY, имитируя изометрическую проекцию для куба обзора. Вычислим косинусы и синусы с помощью MathCos и MathSin, повернем вокруг X, преобразуя y и z в y2 и z2, затем вокруг Y, изменяя x с помощью z2 на x2. Масштабируем с коэффициентом, полученным из "VCUBE_SIZE", и сместим с помощью "m_vcubeCenterX" и "m_vcubeCenterY" для центрирования. Это обеспечит точное двухмерное отображение вершин куба.

Чтобы отобразить интерактивный куб обзора в виде наложения в режиме 3D, реализуем функцию "drawViewCubeOverlay", расположим его под значком панорамирования, используя такие константы, как "VCUBE_SIZE" и "VCUBE_MARGIN", и установим центры "m_vcubeCenterX" и "m_vcubeCenterY". Если "showViewCubeBackground" имеет значение true, смешаем полупрозрачную панель с помощью "blendPixelSet" для заливки и границ, используя осветленный "themeColor". Синхронизируем углы с текущей камерой, определим вершины куба как массив значений типа double, проецируем каждую из них с помощью "vcubeProject" на целые пары в sv. Зададим индексы граней, имена, нормали, вычислим вектор направления камеры по углам, вычисляем глубину каждой грани по её центру, полученному как среднее четырёх вершин и скалярное произведение с нормалями, отсортируем faceOrder по возрастанию глубины для отрисовки сзади вперед, чтобы учесть окклюзию. Для цветов назначим граням разные значения clr, отрегулируем яркость на основе абсолютного значения точки для затенения.

С коэффициентом сжатия для подразделений мы циклически переберем сетку 3x3 для каждой грани, вычислим координаты подквадратов с помощью "bilinear", проверим наведение курсора на subZone из массива "faceSubNames" (большой статический строковый массив 6x3x3, определяющий зоны, например, "TopFrontLeft"), осветлим и увеличим альфа-канал при наведении курсора, скомпонуем ARGB и заполним с помощью "fillQuad". Добавим черные ребра с помощью циклов LineAA, центрируем метки на более светлых гранях с помощью TextOut и небольшого жирного шрифта, затем проецируем и рисуем цветные оси от начала координат с метками "X", "Y", "Z" для ориентации.

Мы создаём функцию "bilinear" для интерполяции квадратичных значений 2D в точке (u,v) от углов p00 до p11 с использованием взвешенных сумм, что обеспечивает плавное подразделение в кубе обзора для точного обнаружения наведения курсора без зазубренных краёв. Наконец, определим функцию "fillQuad" для растеризации и заполнения произвольных четырехугольников, заданных в массивах px и py, найдем минимальное/ максимальное значение Y, отсканируем горизонтальные линии для вычисления минимального/ максимального значения X с помощью пересечений ребер (обработка горизонтальных ребер отдельно) и смешаем каждый пиксель в строке с помощью "blendPixelSet", что позволяет создавать сплошной рендеринг граней в наложении куба. При вызове этой функции, куб обзора отображается следующим образом.

VIEWCUBE RENDERED

После корректной отрисовки куба обзора мы реализуем логику взаимодействия для обнаружения зон, обработки кликов и анимации камеры.

Обнаружение зон наведения курсора и обработка щелчков по кубу обзора

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

//+------------------------------------------------------------------+
//| Detect which face, edge or corner of the view cube is hovered    |
//+------------------------------------------------------------------+
void detectViewCubeZone(int localX, int localY)
  {
   //--- Use the current camera angles for the cube orientation
   double angX = m_cameraAngleX;
   double angY = m_cameraAngleY;

   //--- Compute the camera direction for back-face filtering
   double camDir[3];
   camDir[0] =  MathCos(angX) * MathSin(angY);
   camDir[1] = -MathSin(angX);
   camDir[2] = -MathCos(angX) * MathCos(angY);

   //--- Face name and normal lookup tables
   string faceNames[6];
   faceNames[0] = "Front"; faceNames[1] = "Back"; faceNames[2] = "Top";
   faceNames[3] = "Bottom"; faceNames[4] = "Right"; faceNames[5] = "Left";

   double faceNormals[6][3];
   faceNormals[0][0]= 0; faceNormals[0][1]= 0; faceNormals[0][2]=-1;
   faceNormals[1][0]= 0; faceNormals[1][1]= 0; faceNormals[1][2]= 1;
   faceNormals[2][0]= 0; faceNormals[2][1]= 1; faceNormals[2][2]= 0;
   faceNormals[3][0]= 0; faceNormals[3][1]=-1; faceNormals[3][2]= 0;
   faceNormals[4][0]= 1; faceNormals[4][1]= 0; faceNormals[4][2]= 0;
   faceNormals[5][0]=-1; faceNormals[5][1]= 0; faceNormals[5][2]= 0;

   //--- Face centre lookup table
   double faceCenters[6][3];
   faceCenters[0][0]= 0; faceCenters[0][1]= 0; faceCenters[0][2]=-1;
   faceCenters[1][0]= 0; faceCenters[1][1]= 0; faceCenters[1][2]= 1;
   faceCenters[2][0]= 0; faceCenters[2][1]= 1; faceCenters[2][2]= 0;
   faceCenters[3][0]= 0; faceCenters[3][1]=-1; faceCenters[3][2]= 0;
   faceCenters[4][0]= 1; faceCenters[4][1]= 0; faceCenters[4][2]= 0;
   faceCenters[5][0]=-1; faceCenters[5][1]= 0; faceCenters[5][2]= 0;

   //--- Initialise the best match distance and clear the zone
   double bestDist = 99999;
   m_vcubeHoverZone = "";

   //--- Check each visible face centre against the cursor position
   for(int i = 0; i < 6; i++)
     {
      //--- Skip back-facing faces (they cannot be clicked)
      double dot = faceNormals[i][0] * camDir[0] +
                   faceNormals[i][1] * camDir[1] +
                   faceNormals[i][2] * camDir[2];
      if(dot >= 0) continue;
      int sx, sy;
      vcubeProject(faceCenters[i][0], faceCenters[i][1], faceCenters[i][2], angX, angY, sx, sy);
      double dx = localX - sx;
      double dy = localY - sy;
      double d  = MathSqrt(dx * dx + dy * dy);
      //--- Update the closest zone within a 15-pixel radius
      if(d < 15 && d < bestDist)
        {
         bestDist = d;
         m_vcubeHoverZone = faceNames[i];
        }
     }

   //--- Edge midpoint lookup tables
   double edgeCenters[12][3];
   string edgeNames[12];
   edgeCenters[0][0]= 0;  edgeCenters[0][1]= 1;  edgeCenters[0][2]=-1;  edgeNames[0]  = "TopFront";
   edgeCenters[1][0]= 0;  edgeCenters[1][1]= 1;  edgeCenters[1][2]= 1;  edgeNames[1]  = "TopBack";
   edgeCenters[2][0]= 1;  edgeCenters[2][1]= 1;  edgeCenters[2][2]= 0;  edgeNames[2]  = "TopRight";
   edgeCenters[3][0]=-1;  edgeCenters[3][1]= 1;  edgeCenters[3][2]= 0;  edgeNames[3]  = "TopLeft";
   edgeCenters[4][0]= 0;  edgeCenters[4][1]=-1;  edgeCenters[4][2]=-1;  edgeNames[4]  = "BottomFront";
   edgeCenters[5][0]= 0;  edgeCenters[5][1]=-1;  edgeCenters[5][2]= 1;  edgeNames[5]  = "BottomBack";
   edgeCenters[6][0]= 1;  edgeCenters[6][1]=-1;  edgeCenters[6][2]= 0;  edgeNames[6]  = "BottomRight";
   edgeCenters[7][0]=-1;  edgeCenters[7][1]=-1;  edgeCenters[7][2]= 0;  edgeNames[7]  = "BottomLeft";
   edgeCenters[8][0]= 1;  edgeCenters[8][1]= 0;  edgeCenters[8][2]=-1;  edgeNames[8]  = "FrontRight";
   edgeCenters[9][0]=-1;  edgeCenters[9][1]= 0;  edgeCenters[9][2]=-1;  edgeNames[9]  = "FrontLeft";
   edgeCenters[10][0]= 1; edgeCenters[10][1]= 0; edgeCenters[10][2]= 1; edgeNames[10] = "BackRight";
   edgeCenters[11][0]=-1; edgeCenters[11][1]= 0; edgeCenters[11][2]= 1; edgeNames[11] = "BackLeft";

   //--- Check each edge midpoint against the cursor position (10-pixel radius)
   for(int i = 0; i < 12; i++)
     {
      int sx, sy;
      vcubeProject(edgeCenters[i][0], edgeCenters[i][1], edgeCenters[i][2], angX, angY, sx, sy);
      double dx = localX - sx;
      double dy = localY - sy;
      double d  = MathSqrt(dx * dx + dy * dy);
      if(d < 10 && d < bestDist)
        {
         bestDist = d;
         m_vcubeHoverZone = edgeNames[i];
        }
     }

   //--- Corner position and name lookup tables
   double cornerCenters[8][3];
   string cornerNames[8];
   cornerCenters[0][0]=-1; cornerCenters[0][1]= 1; cornerCenters[0][2]=-1; cornerNames[0]="TopFrontLeft";
   cornerCenters[1][0]= 1; cornerCenters[1][1]= 1; cornerCenters[1][2]=-1; cornerNames[1]="TopFrontRight";
   cornerCenters[2][0]= 1; cornerCenters[2][1]= 1; cornerCenters[2][2]= 1; cornerNames[2]="TopBackRight";
   cornerCenters[3][0]=-1; cornerCenters[3][1]= 1; cornerCenters[3][2]= 1; cornerNames[3]="TopBackLeft";
   cornerCenters[4][0]=-1; cornerCenters[4][1]=-1; cornerCenters[4][2]=-1; cornerNames[4]="BottomFrontLeft";
   cornerCenters[5][0]= 1; cornerCenters[5][1]=-1; cornerCenters[5][2]=-1; cornerNames[5]="BottomFrontRight";
   cornerCenters[6][0]= 1; cornerCenters[6][1]=-1; cornerCenters[6][2]= 1; cornerNames[6]="BottomBackRight";
   cornerCenters[7][0]=-1; cornerCenters[7][1]=-1; cornerCenters[7][2]= 1; cornerNames[7]="BottomBackLeft";

   //--- Check each corner against the cursor position (8-pixel radius)
   for(int i = 0; i < 8; i++)
     {
      int sx, sy;
      vcubeProject(cornerCenters[i][0], cornerCenters[i][1], cornerCenters[i][2], angX, angY, sx, sy);
      double dx = localX - sx;
      double dy = localY - sy;
      double d  = MathSqrt(dx * dx + dy * dy);
      if(d < 8 && d < bestDist)
        {
         bestDist = d;
         m_vcubeHoverZone = cornerNames[i];
        }
     }
  }

//+------------------------------------------------------------------+
//| Snap the camera to the orientation indicated by a cube zone click|
//+------------------------------------------------------------------+
void handleViewCubeClick(int mouseX, int mouseY)
  {
   //--- Abort if no valid zone is hovered
   if(m_vcubeHoverZone == "") return;

   //--- Diagonal tilt angle used for isometric corner/edge views
   double isoTilt = MathArctan(1.0 / MathSqrt(2.0));
   //--- Near-vertical angle used for top/bottom face views
   double nearPole = DX_PI / 2.0 - 0.0001;

   //--- Look up the target camera angles for the clicked zone
   double tX = 0, tY = 0;
   if     (m_vcubeHoverZone == "Front")              { tX = 0.0;        tY = 0.0; }
   else if(m_vcubeHoverZone == "Back")               { tX = 0.0;        tY = DX_PI; }
   else if(m_vcubeHoverZone == "Top")                { tX = nearPole;   tY = 0.0; }
   else if(m_vcubeHoverZone == "Bottom")             { tX = -nearPole;  tY = 0.0; }
   else if(m_vcubeHoverZone == "Right")              { tX = 0.0;        tY =  DX_PI / 2.0; }
   else if(m_vcubeHoverZone == "Left")               { tX = 0.0;        tY = -DX_PI / 2.0; }
   else if(m_vcubeHoverZone == "TopFront")           { tX = isoTilt;    tY = 0.0; }
   else if(m_vcubeHoverZone == "TopBack")            { tX = isoTilt;    tY = DX_PI; }
   else if(m_vcubeHoverZone == "TopRight")           { tX = isoTilt;    tY =  DX_PI / 2.0; }
   else if(m_vcubeHoverZone == "TopLeft")            { tX = isoTilt;    tY = -DX_PI / 2.0; }
   else if(m_vcubeHoverZone == "BottomFront")        { tX = -isoTilt;   tY = 0.0; }
   else if(m_vcubeHoverZone == "BottomBack")         { tX = -isoTilt;   tY = DX_PI; }
   else if(m_vcubeHoverZone == "BottomRight")        { tX = -isoTilt;   tY =  DX_PI / 2.0; }
   else if(m_vcubeHoverZone == "BottomLeft")         { tX = -isoTilt;   tY = -DX_PI / 2.0; }
   else if(m_vcubeHoverZone == "FrontRight")         { tX = 0.0;        tY =  DX_PI / 4.0; }
   else if(m_vcubeHoverZone == "FrontLeft")          { tX = 0.0;        tY = -DX_PI / 4.0; }
   else if(m_vcubeHoverZone == "BackRight")          { tX = 0.0;        tY =  DX_PI * 3.0 / 4.0; }
   else if(m_vcubeHoverZone == "BackLeft")           { tX = 0.0;        tY = -DX_PI * 3.0 / 4.0; }
   else if(m_vcubeHoverZone == "TopFrontRight")      { tX = isoTilt;    tY =  DX_PI / 4.0; }
   else if(m_vcubeHoverZone == "TopFrontLeft")       { tX = isoTilt;    tY = -DX_PI / 4.0; }
   else if(m_vcubeHoverZone == "TopBackRight")       { tX = isoTilt;    tY =  DX_PI * 3.0 / 4.0; }
   else if(m_vcubeHoverZone == "TopBackLeft")        { tX = isoTilt;    tY = -DX_PI * 3.0 / 4.0; }
   else if(m_vcubeHoverZone == "BottomFrontRight")   { tX = -isoTilt;   tY =  DX_PI / 4.0; }
   else if(m_vcubeHoverZone == "BottomFrontLeft")    { tX = -isoTilt;   tY = -DX_PI / 4.0; }
   else if(m_vcubeHoverZone == "BottomBackRight")    { tX = -isoTilt;   tY =  DX_PI * 3.0 / 4.0; }
   else if(m_vcubeHoverZone == "BottomBackLeft")     { tX = -isoTilt;   tY = -DX_PI * 3.0 / 4.0; }
   else return;

   //--- Store the target angles for the animation
   m_targetAngleX    = tX;
   m_targetAngleY    = tY;
   //--- Record the current angles as the animation start
   m_animStartAngleX = m_cameraAngleX;
   m_animStartAngleY = m_cameraAngleY;
   //--- Reset and start the animation
   m_animStep        = 0;
   m_animSteps       = 20;
   m_isAnimating     = true;
   //--- Start the millisecond timer to drive the animation
   EventSetMillisecondTimer(30);
  }

Определим функцию "detectViewCubeZone" для идентификации конкретной подзоны (грани, ребра или угла) под курсором мыши в локальных координатах для интерактивной обратной связи на кубе обзора. Синхронизируем углы с текущей камерой, вычислим вектор направления камеры с помощью MathCos и MathSin, определим массивы для имен граней, нормалей и центров в качестве фиксированных свойств куба и инициализируем оптимальное расстояние высоко, одновременно сбрасывая значение "m_vcubeHoverZone". Перебирая грани в цикле, пропустим грани, обращенные назад, с помощью скалярного произведения с нормалями, проецируем центры с помощью "vcubeProject", вычислим евклидово расстояние до курсора и обновим зону, если она находится ближе всего, в пределах 15 единиц.

Аналогично, для 12 ребер с предопределенными центрами и именами, такими как "TopFront", проверим расстояние в пределах 10 единиц, и 8 углов, таких как "TopFrontLeft", проверяя расстояние в пределах 8 единиц. Приоритет отдается наименьшему расстоянию для точного обнаружения мелкозернистых областей наведения курсора мыши для точной навигации. Такое определение зон имеет решающее значение для удобства взаимодействия с пользователем, поскольку оно делит куб на интерактивные области с использованием простых пороговых значений расстояния без трассировки лучей, позволяя быстро определять ориентационные цели при работе с перспективной проекцией.

Для обработки кликов мы реализуем функцию "handleViewCubeClick". В случае отсутствия зоны функция завершает работу досрочно. Предварительно вычислим изометрический наклон с помощью MathArctan на 1/sqrt(2) для 35-градусных видов и значения вблизи полюса, близкого к PI/2. Далее сопоставим "m_vcubeHoverZone" с целевыми углами tX и tY через большую цепочку условий для стандартных ориентаций (например, "Front" в (0,0), "Top" вблизи полюса по оси X, ребра в вариантах PI/4, углы в isoTilt в сочетании с квадрантами). Установим значения "m_targetAngleX" и "m_targetAngleY", захватим текущие углы в начале анимации, сбросим шаг на 0, при этом параметр "m_animSteps" равен 20. Включим "m_isAnimating" и запустим 30-миллисекундный таймер с помощью EventSetMillisecondTimer для обеспечения плавных переходов. Этот обработчик кликов упрощает интуитивно понятный сброс вида за счет анимации до предопределенных углов. Это повышает удобство использования в 3D-навигации без резких скачков. После обработки кликов добавим тик анимации и обновим обработчик событий мыши, чтобы корректно маршрутизировать взаимодействия с режимом панорамирования и кубом обзора.

Анимация переходов камеры и обработка событий мыши, связанных с режимом панорамирования и обзором

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

//+------------------------------------------------------------------+
//| Advance the camera snap animation by one timer tick              |
//+------------------------------------------------------------------+
void tickAnimation()
  {
   //--- Abort if no animation is running
   if(!m_isAnimating) return;

   //--- Advance to the next animation step
   m_animStep++;
   //--- Compute normalised interpolation parameter [0, 1]
   double t = (double)m_animStep / (double)m_animSteps;
   //--- Apply smoothstep easing for a natural deceleration curve
   t = t * t * (3.0 - 2.0 * t);

   //--- Interpolate the elevation angle toward the target
   m_cameraAngleX = m_animStartAngleX + (m_targetAngleX - m_animStartAngleX) * t;
   //--- Interpolate the azimuth angle toward the target
   m_cameraAngleY = m_animStartAngleY + (m_targetAngleY - m_animStartAngleY) * t;

   //--- Snap to the exact target on the final step
   if(m_animStep >= m_animSteps)
     {
      m_cameraAngleX = m_targetAngleX;
      m_cameraAngleY = m_targetAngleY;
      m_isAnimating = false;
     }

   //--- Re-render and push the frame to the chart
   renderVisualization();
   ChartRedraw();
  }

//--- Update "handleMouseEvent" with new hover checks, panning logic, and view cube handling


bool previousPanHoverState    = m_isHoveringPanIcon;
bool previousVCubeHoverState  = m_isHoveringViewCube;
string previousVCubeZone      = m_vcubeHoverZone;

//--- Update all hover flags for the current cursor position
m_isHoveringCanvas = (mouseX >= m_currentPositionX &&
                      mouseX <= m_currentPositionX + m_currentWidth &&
                      mouseY >= m_currentPositionY &&
                      mouseY <= m_currentPositionY + m_currentHeight);
m_isHoveringHeader      = isMouseOverHeaderBar(mouseX, mouseY);
m_isHoveringSwitchIcon  = isMouseOverSwitchIcon(mouseX, mouseY);
m_isHoveringPanIcon     = isMouseOverPanIcon(mouseX, mouseY);
m_isHoveringResizeZone  = isMouseInResizeZone(mouseX, mouseY, m_hoverResizeMode);

//--- Update view cube hover only in 3D mode
if(m_currentViewMode == VIEW_3D_MODE)
   m_isHoveringViewCube = isMouseOverViewCube(mouseX, mouseY);
else
   m_isHoveringViewCube = false;

//--- Determine if any hover state change requires a redraw
bool needRedraw = (previousHoverState       != m_isHoveringCanvas      ||
                   previousHeaderHoverState != m_isHoveringHeader      ||
                   previousResizeHoverState != m_isHoveringResizeZone  ||
                   previousSwitchHoverState != m_isHoveringSwitchIcon  ||
                   previousPanHoverState    != m_isHoveringPanIcon     ||
                   previousVCubeHoverState  != m_isHoveringViewCube    ||
                   previousVCubeZone        != m_vcubeHoverZone);

//--- Handle 3D orbit and pan drags when over the canvas body (not header or cube)
if(m_currentViewMode == VIEW_3D_MODE && m_isHoveringCanvas &&
   !m_isHoveringHeader && !m_isHoveringViewCube)
  {
   //--- Orbit mode: rotate the camera around the target
   if(!m_panMode)
     {
      //--- Begin orbit on fresh left-button press
      if(mouseState == 1 && m_previousMouseButtonState == 0)
        {
         m_isRotating3D  = true;
         m_mouse3DStartX = mouseX;
         m_mouse3DStartY = mouseY;
         ChartSetInteger(0, CHART_MOUSE_SCROLL, false);
        }
      //--- Continue orbit while button is held
      else if(mouseState == 1 && m_previousMouseButtonState == 1 && m_isRotating3D)
        {
         //--- Update azimuth proportional to horizontal mouse delta
         m_cameraAngleY += (mouseX - m_mouse3DStartX) / 300.0;
         //--- Update elevation proportional to vertical mouse delta
         m_cameraAngleX += (mouseY - m_mouse3DStartY) / 300.0;
         //--- Clamp elevation to avoid gimbal lock at poles
         if(m_cameraAngleX < -DX_PI * 0.499) m_cameraAngleX = -DX_PI * 0.499;
         if(m_cameraAngleX >  DX_PI * 0.499) m_cameraAngleX =  DX_PI * 0.499;
         //--- Update anchor for next delta computation
         m_mouse3DStartX = mouseX;
         m_mouse3DStartY = mouseY;
         //--- Cancel any running snap animation
         m_isAnimating = false;
         needRedraw = true;
        }
      //--- End orbit on button release
      else if(mouseState == 0 && m_previousMouseButtonState == 1)
        {
         m_isRotating3D = false;
         ChartSetInteger(0, CHART_MOUSE_SCROLL, true);
        }
     }
   else
     {
      //--- Pan mode: translate the camera target in the view plane
      if(mouseState == 1 && m_previousMouseButtonState == 0)
        {
         //--- Begin pan drag
         m_isPanning = true;
         m_panStartX = mouseX;
         m_panStartY = mouseY;
         ChartSetInteger(0, CHART_MOUSE_SCROLL, false);
        }
      else if(mouseState == 1 && m_previousMouseButtonState == 1 && m_isPanning)
        {
         //--- Compute mouse delta since last event
         int deltaX = mouseX - m_panStartX;
         int deltaY = mouseY - m_panStartY;

         //--- Reconstruct the camera position vector in world space
         DXVector4 camera(0.0f, 0.0f, (float)-m_cameraDistance, 1.0f);
         DXMatrix rotX;
         DXMatrixRotationX(rotX, (float)m_cameraAngleX);
         DXVec4Transform(camera, camera, rotX);
         DXMatrix rotY;
         DXMatrixRotationY(rotY, (float)m_cameraAngleY);
         DXVec4Transform(camera, camera, rotY);
         DXVector3 cameraPos(camera.x, camera.y, camera.z);

         //--- Compute and normalise the forward direction toward the target
         DXVector3 forward;
         DXVec3Subtract(forward, m_viewTarget, cameraPos);
         float len = DXVec3Length(forward);
         if(len > 0.0f) DXVec3Scale(forward, forward, 1.0f / len);

         //--- Compute the camera right vector via cross product
         DXVector3 worldUp(0.0f, 1.0f, 0.0f);
         DXVector3 right;
         DXVec3Cross(right, worldUp, forward);
         len = DXVec3Length(right);
         if(len > 0.0f) DXVec3Scale(right, right, 1.0f / len);

         //--- Compute the camera up vector orthogonal to forward and right
         DXVector3 camUp;
         DXVec3Cross(camUp, forward, right);
         len = DXVec3Length(camUp);
         if(len > 0.0f) DXVec3Scale(camUp, camUp, 1.0f / len);

         //--- Scale the pan movement proportional to camera distance
         float panFactor = (float)m_cameraDistance / 500.0f;
         DXVector3 moveRight;
         DXVec3Scale(moveRight, right,  (float)(-deltaX) * panFactor);
         DXVector3 moveUp;
         DXVec3Scale(moveUp,   camUp,   (float)( deltaY) * panFactor);

         //--- Translate the view target by the computed pan offset
         DXVector3 temp;
         DXVec3Add(temp,        m_viewTarget, moveRight);
         DXVec3Add(m_viewTarget, temp,        moveUp);

         //--- Update pan anchor for next delta computation
         m_panStartX = mouseX;
         m_panStartY = mouseY;
         needRedraw = true;
        }
      //--- End pan drag on button release
      else if(mouseState == 0 && m_previousMouseButtonState == 1)
        {
         m_isPanning = false;
         ChartSetInteger(0, CHART_MOUSE_SCROLL, true);
        }
     }
  }

//--- In the mouse down block:

//--- Handle view cube face/edge/corner click in 3D mode
if(m_isHoveringViewCube && m_currentViewMode == VIEW_3D_MODE && m_vcubeHoverZone != "")
  {
   handleViewCubeClick(mouseX, mouseY);
   needRedraw = true;
   m_previousMouseButtonState = mouseState;
   return;
  }
//--- Toggle pan mode when the pan icon is clicked
else if(m_isHoveringPanIcon && m_currentViewMode == VIEW_3D_MODE)
  {
   m_panMode = !m_panMode;
   needRedraw = true;
   m_previousMouseButtonState = mouseState;
   return;
  }

//+------------------------------------------------------------------+
//| Return true when the cursor is over the pan mode toggle icon     |
//+------------------------------------------------------------------+
bool isMouseOverPanIcon(int mouseX, int mouseY)
  {
   //--- Pan icon is only visible in 3D mode
   if(m_currentViewMode != VIEW_3D_MODE) return false;
   //--- Align pan icon with the right edge below the header
   int iconX = m_currentPositionX + m_currentWidth - PAN_ICON_SIZE - PAN_ICON_MARGIN;
   int iconY = m_currentPositionY + HEADER_BAR_HEIGHT + PAN_ICON_MARGIN;
   //--- Return true if the cursor falls within the icon bounding box
   return (mouseX >= iconX && mouseX <= iconX + PAN_ICON_SIZE &&
           mouseY >= iconY && mouseY <= iconY + PAN_ICON_SIZE);
  }

//+------------------------------------------------------------------+
//| Return true when the cursor is over the view cube widget         |
//+------------------------------------------------------------------+
bool isMouseOverViewCube(int mouseX, int mouseY)
  {
   //--- View cube is only visible in 3D mode
   if(m_currentViewMode != VIEW_3D_MODE) return false;
   //--- Compute the view cube bounding area below the pan icon
   int cubeAreaX = m_currentPositionX + m_currentWidth - VCUBE_SIZE - VCUBE_MARGIN;
   int cubeAreaY = m_currentPositionY + HEADER_BAR_HEIGHT + PAN_ICON_MARGIN + PAN_ICON_SIZE + VCUBE_MARGIN;
   int cubeAreaW = VCUBE_SIZE;
   int cubeAreaH = VCUBE_SIZE;

   //--- Check if the cursor is within the bounding box
   if(mouseX >= cubeAreaX && mouseX <= cubeAreaX + cubeAreaW &&
      mouseY >= cubeAreaY && mouseY <= cubeAreaY + cubeAreaH)
     {
      //--- Detect the specific cube face, edge or corner under the cursor
      detectViewCubeZone(mouseX - m_currentPositionX, mouseY - m_currentPositionY);
      return (m_vcubeHoverZone != "");
     }
   //--- Cursor is outside the view cube; clear hover zone
   m_vcubeHoverZone = "";
   return false;
  }

//+------------------------------------------------------------------+
//| Deactivate pan mode and trigger a redraw                        |
//+------------------------------------------------------------------+
void exitPanMode()
  {
   //--- Only act if pan mode is currently active
   if(m_panMode)
     {
      m_panMode = false;
      renderVisualization();
      ChartRedraw();
     }
  }

Для точного определения зоны, на которую наведен курсор, на кубе обзора с использованием локальных координат, определим функцию "detectViewCubeZone". Синхронизируем углы с текущей камерой, вычислим вектор направления с помощью "MathCos" и "MathSin", определим массивы для имен граней, нормалей и центров в качестве граней куба. Начиная с наибольшего расстояния и пустой "m_vcubeHoverZone", в цикле переберем грани, пропустим задние грани с помощью скалярного произведения, спроецируем центры с помощью "vcubeProject", вычислим расстояние до курсора с помощью MathSqrt и обновим, если ближайший находится в пределах 15 единиц. Повторяем это действие для 12 ребер с предопределенными центрами и именами, например, "TopFront", проверим в пределах 10 единиц, и для 8 углов, например, "TopFrontLeft", в пределах 8 единиц, выберем ближайшее для обеспечения детального взаимодействия.

В обработчике "handleMouseEvent" добавим предыдущие состояния для наведения курсора на объекты панорамирования и куб обзора, включая зону. Обновим значение "m_isHoveringPanIcon" посредством "isMouseOverPanIcon" и "m_isHoveringViewCube" с помощью "isMouseOverViewCube" (сбрасывая значение, если объект не трехмерный), и включаем эти данные в проверки needRedraw. Для наведения курсора на трехмерный объект Canvas без заголовка или куба, если "m_panMode" выключен, сохраняется логика вращения; если включен, при нажатии устанавливается "m_isPanning" в значение true с параметрами start и отключается прокрутка; при перетаскивании вычисляются дельты, вектор камеры преобразуется с помощью вращений, вычисляется нормализованное направление вперед и вправо с помощью "DXVec3Cross" и "DXVec3Length". Аналогично camUp, масштабируются движения с коэффициентом панорамирования от расстояния, добавляются к "m_viewTarget" с помощью "DXVec3Add". Обновляются параметры start, флаг перерисовки. При отпускании сбрасывается режим панорамирования и включается прокрутка. В блоке нажатия, если курсор находится над кубом обзора в 3D с зоной, вызывается "handleViewCubeClick", устанавливается флаг перерисовки, обновляется состояние, функция завершает работу. Если курсор находится над значком панорамирования в 3D, переключается "m_panMode", устанавливается флаг перерисовки, обновляется состояние, функция завершает работу.

Создадим функцию "isMouseOverPanIcon", чтобы обнаруживать наведение курсора на переключатель панорамирования, возвращается значение false, если не 3D. В противном случае вычислим границы значка на основе положения и констант и проверим, находится ли мышь внутри. Далее, функция "isMouseOverViewCube" проверяет 3D-режим или возвращает false, вычисляет площадь на основе положения и полей. Если курсор мыши находится внутри, вызывает функцию "detectViewCubeZone" с локальными смещениями, возвращает true, если зона задана, в противном случае сбрасывает зону в пустое состояние и возвращает false. Чтобы отключить режим панорамирования, определим "exitPanMode", который, если "m_panMode" активен, сбрасывает его, выполняет перерисовку с помощью "renderVisualization" и перерисовывает график, позволяя выйти с помощью клавиши Escape в соответствии с обработкой событий. Для включения анимации мы вызовем функцию animations в обработчике событий таймера.

Подключение обработчиков событий таймера, деинициализации и графика

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

//+------------------------------------------------------------------+
//| Advance the camera snap animation on each timer tick             |
//+------------------------------------------------------------------+
void OnTimer()
  {
   //--- Delegate to the visualizer's per-tick animation updater
   if(distributionVisualizer != NULL)
      distributionVisualizer.tickAnimation();
  }

//--- Add "EventKillTimer()" in "OnDeinit"

//+------------------------------------------------------------------+
//| Release all resources when the EA is removed                     |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   //--- Stop the animation timer
   EventKillTimer();
   //--- Destroy the visualizer object if it still exists
   if(distributionVisualizer != NULL)
     {
      delete distributionVisualizer;
      distributionVisualizer = NULL;
     }
   //--- Redraw the chart to remove the canvas bitmap object
   ChartRedraw();
   Print("Distribution window deinitialized");
  }

//--- Update "OnChartEvent" to handle ESC key for exiting pan mode
//--- We added this for enhanced simplicity


//+------------------------------------------------------------------+
//| Route chart mouse, wheel and key events to the visualizer        |
//+------------------------------------------------------------------+
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
  {
   //--- Abort if the visualizer has not been initialised
   if(distributionVisualizer == NULL) return;

   //--- Handle mouse move and button events
   if(id == CHARTEVENT_MOUSE_MOVE)
     {
      int mouseX     = (int)lparam;       // Horizontal cursor position in pixels
      int mouseY     = (int)dparam;       // Vertical cursor position in pixels
      int mouseState = (int)sparam;       // Bitmask of pressed mouse buttons

      distributionVisualizer.handleMouseEvent(mouseX, mouseY, mouseState);
     }

   //--- Handle mouse wheel scroll events
   if(id == CHARTEVENT_MOUSE_WHEEL)
     {
      //--- Unpack cursor X from the low 16 bits of lparam
      int mouseX = (int)(short) lparam;
      //--- Unpack cursor Y from the high 16 bits of lparam
      int mouseY = (int)(short)(lparam >> 16);
      distributionVisualizer.handleMouseWheel(mouseX, mouseY, dparam);
     }

   //--- Handle Escape key press to exit pan mode
   if(id == CHARTEVENT_KEYDOWN && lparam == 27)
      distributionVisualizer.exitPanMode();
  }

В этой части определим обработчик OnTimer для периодического обновления анимации. Проверим, не имеет ли "distributionVisualizer" значения NULL, затем вызовем "tickAnimation" для него, обновить анимацию перехода камеры, запускаемую через куб обзора, или другие эффекты по таймеру. В OnDeinit добавим EventKillTimer для остановки таймера, гарантируя отсутствие оставшихся активных событий таймера после деинициализации. Обновим OnChartEvent , чтобы включить обработку кнопок: если идентификатор - CHARTEVENT_KEYDOWN, а параметр lparam равен 27 (клавиша ESC), вызовем "exitPanMode" в визуализаторе, чтобы отключить режим панорамирования, если он активен, и для удобства используем сочетание клавиш. На этом реализация завершена. Осталось провести тестирование системы. Это рассматривается в следующем разделе.


Тестирование на истории

Мы провели тестирование, а ниже показан итоговый результат визуализации в формате Graphics Interchange Format (GIF).

BACKTEST GIF

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


Заключение

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

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

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

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

Последние комментарии | Перейти к обсуждению на форуме трейдеров (1)
BeeXXI Corporation
Nikolai Semko | 15 июн. 2026 в 11:50
Не вижу 3D кривых
Вижу 2D кривые в 3х мерном представлении
Архитектура машинного обучения для MetaTrader 5 (Часть 15): Как калибровать уровни тейк-профита и стоп-лосса по синтетическим данным Архитектура машинного обучения для MetaTrader 5 (Часть 15): Как калибровать уровни тейк-профита и стоп-лосса по синтетическим данным
В статье применяется оптимальное торговое правило из главы 13 AFML для задания уровней тейк-профита и стоп-лосса без внутривыборочной калибровки. Мы моделируем P&L после входа дискретным процессом Орнштейна–Уленбека, выполняем поиск по 100 000 траекториям и используем Python, multiprocessing и параллельное ядро Numba с декоратором @njit (в 242 раза быстрее). Результат — оптимальная пара (PT, SL) для трех спецификаций прогноза с ограничением по дневному лимиту убытка проп-фирмы.
Нейросети в трейдинге: от рыночного шума к устойчивому торговому плану (MomAD) Нейросети в трейдинге: от рыночного шума к устойчивому торговому плану (MomAD)
В статье рассматривается адаптация идей MomAD к задачам нейросетевого трейдинга. Основное внимание уделено проблеме нестабильности торговых решений, когда модель слишком часто меняет сценарий и разрушает прибыльный план. Описаны теоретические основы Momentum-Aware Planning, расстояния Хаусдорфа и их перенос в латентное пространство рыночных состояний. В практической части реализован базовый OpenCL-механизм оценки расхождения между сценариями.
Адаптивный индикатор Malaysian Engulfing (Часть 1): Обнаружение паттернов и валидация ретеста Адаптивный индикатор Malaysian Engulfing (Часть 1): Обнаружение паттернов и валидация ретеста
Реализуем концепцию Malaysian Engulfing в MQL5 с помощью двух согласованных индикаторов. Один применяет строгие правила поглощения по телам свечей для точного обнаружения паттерна; другой использует модель, управляемую состояниями, чтобы отслеживать дальнейшее развитие — откаты и ретесты в заданном временном окне — прямо на графике. В результате получается повторяемый рабочий процесс на основе правил, который заменяет визуальные догадки программируемой логикой.
Инжиниринг признаков для машинного обучения (Часть 2): Реализация дробного дифференцирования с фиксированным окном в MQL5 Инжиниринг признаков для машинного обучения (Часть 2): Реализация дробного дифференцирования с фиксированным окном в MQL5
В этой статье представлена готовая к промышленному применению реализация дробного дифференцирования с фиксированной шириной окна на MQL5 для потока данных MetaTrader 5 в реальном времени. Мы вводим класс CFFDEngine, полностью реализованный в заголовочном файле, который заранее вычисляет веса без фиксированного ограничения, выполняет обновления за O(width) на бар и избегает выделения памяти на каждом тике. Индикатор FFD.mq5 поддерживает все типы ENUM_APPLIED_PRICE и оптимизацию prev_calculated. Скрипты валидации подтверждают численную эквивалентность стандартному конвейеру обработки frac_diff_ffd на Python.