Торговые инструменты MQL5 (Часть 24): Улучшение восприятия глубины с помощью 3D-кривых, режима панорамирования и навигации через виджет ViewCube
Введение
У вас есть средство просмотра трёхмерного биномиального распределения с вращением и масштабированием, но без кривой с корректной передачей глубины, без возможности смещать целевую точку обзора и без быстрого сброса ориентации. Поэтому анализ формы функции массы вероятности требует постоянного ручного вращения, области, расположенные вне центра сцены, остаются труднодоступными для просмотра, а возвращение к стандартному виду означает перетаскивание вслепую. Эта статья предназначена для разработчиков MetaQuotes Language 5 (MQL5) и алгоритмических трейдеров, стремящихся расширить интерактивные трехмерные статистические инструменты с интуитивно понятными элементами управления навигацией для более глубокого вероятностного анализа.
В своей предыдущей статье (Часть 23) мы интегрировали Direct3D в инструмент просмотра биномиального распределения в MQL5, позволяющий переключать режимы 2D/3D и управлять камерой для поворота, масштабирования и автоподбора положения камеры. В Части 24 мы улучшим инструмент посредством добавления сегментированной трехмерной кривой для улучшения восприятия глубины функции массы вероятности. Также интегрируем режим панорамирования для смещения целевой точки камеры и реализуем интерактивный куб обзора с зонами наведения курсора и анимационными переходами камеры для обеспечения быстрой смены ориентации. В статье рассмотрим следующие темы:
- Понимание 3D-кривых, режима панорамирования и структуры виджета ViewCube
- Реализация средствами MQL5
- Тестирование на истории
- Заключение
В итоге у вас будет продвинутый инструмент на MQL5 с улучшенной 3D-навигацией для анализа распределения, готовый к настройке. Перейдём к реализации!
Понимание 3D-кривых, режима панорамирования и структуры виджета ViewCube
Представление функции массы вероятности в виде плоской спроецированной линии в трехмерном пространстве теряет глубину, которая делает трехмерную визуализацию ценной — сегментированная трубчатая кривая, построенная из ориентированных примитивов-параллелепипедов в виде прямоугольников, выровненных по последовательным точкам данных, придает кривой физическое присутствие в сцене, так что разница высот между бинами (интервалами гистограммы) воспринимается естественно в перспективе. Режим панорамирования решает другую проблему: вращение и масштабирование удерживают сцену на фиксированной точке фокуса камеры, поэтому смещенные распределения или большое количество испытаний смещают область интереса к краю. Смещение целевой точки камеры с помощью векторной арифметики относительно камеры позволяет нам исследовать всю сцену, не нарушая текущий угол или уровень масштабирования. Куб обзора — это компактный виджет ориентации, который в реальном времени зеркально отображает текущее вращение камеры, разделенный на кликабельные грани, ребра и углы, каждый из которых соответствует предопределенной паре углов. В результате получается плавный интерполированный переход, а не мгновенный скачок.
На рынке используем кривую с точной глубиной, вместе с гистограммой, чтобы оценить, совпадает ли пик функции массы вероятности с бином с максимальной частотой, что указывает на хорошо откалиброванную модель. Сместим вид так, чтобы хвосты распределения с низкой вероятностью оказались в центре экрана для более детального анализа перед определением размера позиций вблизи экстремальных исходов. Используем вид сверху куба обзора для одновременного сравнения высоты столбцов во всех бинах, а вид спереди — чтобы удобно читать значения отдельных столбцов.
Мы расширим инструмент, создав сегменты кривой на основе прямоугольников, масштабированные и повернутые для образования непрерывной трубки, реализуем логику панорамирования с использованием векторных вычислений относительно камеры и отрисуем куб обзора с проецированными вершинами, сортировкой граней для корректного порядка отрисовки и билинейной интерполяцией для подразделенных зон при наведении курсора. Мы также интегрируем анимацию, управляемую таймером, для переходов между режимами просмотра, наложение смешанных пикселей для иконок и куба, а также обработку событий для кликов и наведения курсора, чтобы сделать интерфейс полностью отзывчивым. Наши цели обозначены ниже.

Реализация средствами 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();
После компиляции сегменты трехмерной кривой отображаются рядом со столбцами гистограммы следующим образом.

Теперь, когда кривая готова, добавим наложение значков панорамирования, которое позволяет пользователю включать и выключать режим панорамирования непосредственно из трехмерного представления.
Визуализация значка переключения режима панорамирования
Чтобы предоставить пользователю видимый элемент управления для активации режима панорамирования, отобразим круглый значок, расположенный непосредственно под кнопкой переключения режимов. Значок становится зеленым, когда активен режим панорамирования, темнеет при наведении курсора мыши и отображает символ в виде креста со стрелками, нарисованный из смешанных пикселей для обозначения цели перетаскивания для режима панорамирования.
//+------------------------------------------------------------------+ //| 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. При вызове функции наложение панорамирования отображается следующим образом.

Установив наложение панорамирования, мы создадим наложение куба обзора, который отражает ориентацию камеры и предоставляет интерактивные зоны навигации.
Проецирование и отрисовка наложения куба обзора
Куб обзора рисуется в виде двумерного наложения путем проецирования восьми вершин единичного куба на текущие ракурсы камеры, сортировки его шести граней задом наперед для правильного перекрытия, с разделением каждой видимой грани на сетку 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", что позволяет создавать сплошной рендеринг граней в наложении куба. При вызове этой функции, куб обзора отображается следующим образом.

После корректной отрисовки куба обзора мы реализуем логику взаимодействия для обнаружения зон, обработки кликов и анимации камеры.
Обнаружение зон наведения курсора и обработка щелчков по кубу обзора
Зона под курсором определяется так: мы проецируем центры граней, рёбер и углов и выбираем ближайшую точку в пределах заданного порога. Результат сохраняется в "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).

Во время тестирования сегментированная трехмерная кривая последовательно выравнивалась по пиковым значениям гистограммы при различных значениях числа испытаний. Режим панорамирования плавно выполнял смещение целевой точки камеры, не нарушая текущего поворота или уровня масштабирования, а щелчки на кубе обзора придавали камере правильную стандартную ориентацию для всех протестированных зон граней, рёбер и углов.
Заключение
В заключение отметим, что мы усовершенствовали программу просмотра трехмерного биномиального распределения в MQL5, добавив сегментированную трубчатую кривую для визуализации функции массы вероятности с точностью по глубине, интегрировав режим панорамирования для перемещения целевой точка камеры относительно камеры и реализовав интерактивный куб обзора с зонами наведения на грани, края и углы, которые анимируют камеру к стандартным ориентациям. В реализации мы строим сегменты кривой из прямоугольных примитивов и ориентируем их с помощью матрицы преобразования, билинейное разделение для определения зоны наведения курсора, плавные переходы камеры по таймеру и обновленную обработку событий для щелчков, режима панорамирования и сочетаний клавиш. После прочтения статьи вы сможете:
- использовать трехмерную кривую для визуального подтверждения того, совпадает ли пик функции массы вероятности с бином гистограммы с максимальной частотой, определяя соответствие модели непосредственно из сцены без переключения в двумерный режим.
- активировать режим панорамирования для смещения точки, на которую направлена камера, в сторону хвостов с низкой вероятностью или широких диапазонов испытаний, смещенных от центра, без потери текущего угла поворота или уровня масштабирования
- кликнуть по любой грани, ребру или углу на кубе обзора, чтобы анимировать камеру в стандартной ориентации, использовать вид сверху для одновременного сравнения высот столбцов по всем бинам
В следующих частях мы приступим к двумерным статистическим распределениям и построим дополнительные графики распределений. Следите за обновлениями!
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/21335
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Архитектура машинного обучения для MetaTrader 5 (Часть 15): Как калибровать уровни тейк-профита и стоп-лосса по синтетическим данным
Нейросети в трейдинге: от рыночного шума к устойчивому торговому плану (MomAD)
Адаптивный индикатор Malaysian Engulfing (Часть 1): Обнаружение паттернов и валидация ретеста
Инжиниринг признаков для машинного обучения (Часть 2): Реализация дробного дифференцирования с фиксированным окном в MQL5
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования