From a61a759cf640e5c2e0cadb14905917ec25132c91 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 7 Apr 2026 21:04:01 -0400 Subject: [PATCH 1/8] Add PoleFigureCompositor header, stub implementation, and build integration Introduces the PoleFigureCompositor class with all required types (CompositePoleFigureConfiguration_t, CompositePoleFigureResult, LayoutMetrics, PoleFigureLayoutType) and stub method bodies. Registers the new .h and .cpp in SourceList.cmake so EbsdLib builds them automatically. Co-Authored-By: Claude Sonnet 4.6 --- .../Utilities/PoleFigureCompositor.cpp | 85 ++++++++ .../EbsdLib/Utilities/PoleFigureCompositor.h | 193 ++++++++++++++++++ Source/EbsdLib/Utilities/SourceList.cmake | 2 + 3 files changed, 280 insertions(+) create mode 100644 Source/EbsdLib/Utilities/PoleFigureCompositor.cpp create mode 100644 Source/EbsdLib/Utilities/PoleFigureCompositor.h diff --git a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp new file mode 100644 index 0000000..e344b1d --- /dev/null +++ b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp @@ -0,0 +1,85 @@ +#include "PoleFigureCompositor.h" + +#include "EbsdLib/LaueOps/LaueOps.h" +#include "EbsdLib/Utilities/CanvasUtilities.hpp" +#include "EbsdLib/Utilities/ColorTable.h" +#include "EbsdLib/Utilities/EbsdStringUtils.hpp" +#include "EbsdLib/Utilities/Fonts.hpp" +#include "EbsdLib/Utilities/PoleFigureUtilities.h" + +#include + +#include + +#ifdef EbsdLib_USE_PARALLEL_ALGORITHMS +#include +#endif + +namespace ebsdlib +{ + +// ----------------------------------------------------------------------------- +CompositePoleFigureResult PoleFigureCompositor::generateCompositeImage(const CompositePoleFigureConfiguration_t& config) +{ + return {}; +} + +// ----------------------------------------------------------------------------- +LayoutMetrics PoleFigureCompositor::computeLayoutMetrics(const CompositePoleFigureConfiguration_t& config) +{ + return {}; +} + +// ----------------------------------------------------------------------------- +std::vector PoleFigureCompositor::generatePoleFigures(const CompositePoleFigureConfiguration_t& config) +{ + return {}; +} + +// ----------------------------------------------------------------------------- +void PoleFigureCompositor::preprocessImages(std::vector& images, int imageDim, bool flipFinalImage) +{ +} + +// ----------------------------------------------------------------------------- +UInt8ArrayType::Pointer PoleFigureCompositor::compositeToCanvas(const CompositePoleFigureConfiguration_t& config, const std::vector& images, const LayoutMetrics& layout) +{ + return nullptr; +} + +// ----------------------------------------------------------------------------- +void PoleFigureCompositor::drawPoleFigure(canvas_ity::canvas& context, const UInt8ArrayType& image, std::array origin, int imageDim, const std::string& directionLabel, float fontPtSize, + float margins, const std::vector& latoBold, const std::vector& firaSans) +{ +} + +// ----------------------------------------------------------------------------- +void PoleFigureCompositor::drawScalarBar(canvas_ity::canvas& context, const CompositePoleFigureConfiguration_t& config, std::array position, float margins, float fontPtSize, + const std::vector& latoRegular) +{ +} + +// ----------------------------------------------------------------------------- +void PoleFigureCompositor::drawInfoBlock(canvas_ity::canvas& context, const CompositePoleFigureConfiguration_t& config, std::array position, float margins, float fontPtSize, + const std::vector& latoRegular) +{ +} + +// ----------------------------------------------------------------------------- +void PoleFigureCompositor::drawTitle(canvas_ity::canvas& context, const std::string& title, float pageWidth, float fontPtSize, float margins, const std::vector& latoBold) +{ +} + +// ----------------------------------------------------------------------------- +UInt8ArrayType::Pointer PoleFigureCompositor::flipAndMirror(UInt8ArrayType* src, int imageDim) +{ + return nullptr; +} + +// ----------------------------------------------------------------------------- +UInt8ArrayType::Pointer PoleFigureCompositor::convertColorOrder(UInt8ArrayType* src, int imageDim) +{ + return nullptr; +} + +} // namespace ebsdlib diff --git a/Source/EbsdLib/Utilities/PoleFigureCompositor.h b/Source/EbsdLib/Utilities/PoleFigureCompositor.h new file mode 100644 index 0000000..890ed12 --- /dev/null +++ b/Source/EbsdLib/Utilities/PoleFigureCompositor.h @@ -0,0 +1,193 @@ +/* ============================================================================ + * Copyright (c) 2009-2025 BlueQuartz Software, LLC + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this + * list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * + * Neither the name of BlueQuartz Software, the US Air Force, nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ + +#pragma once + +#include +#include +#include +#include + +#include "EbsdLib/Core/EbsdDataArray.hpp" +#include "EbsdLib/EbsdLib.h" + +namespace canvas_ity +{ +class canvas; +} // namespace canvas_ity + +namespace ebsdlib +{ + +/** + * @brief Layout arrangement for the 3 pole figures and legend in the composite image. + */ +enum class PoleFigureLayoutType : uint32_t +{ + Horizontal = 0, ///< 3 figures + legend side-by-side in a single row + Vertical = 1, ///< 3 figures + legend stacked in a single column + Square = 2 ///< 2x2 grid arrangement +}; + +/** + * @brief Configuration for generating a complete composite pole figure image. + * + * Contains both the parameters needed to generate individual pole figures + * (Euler angles, image dimensions, color settings) and the composition-specific + * parameters (layout type, phase info for the legend). + */ +struct EbsdLib_EXPORT CompositePoleFigureConfiguration_t +{ + // --- Pole figure generation parameters --- + ebsdlib::FloatArrayType* eulers = nullptr; ///< Euler angles in radians (3-component tuples) + int imageDim = 512; ///< Height/width of each individual pole figure in pixels + int lambertDim = 256; ///< Lambert square dimension for interpolation + int numColors = 32; ///< Number of colors in the color table + double minScale = 0.0; ///< Minimum intensity scale value + double maxScale = 1.0; ///< Maximum intensity scale value + float sphereRadius = 1.0f; ///< Sphere radius (should always be 1.0) + bool discrete = false; ///< Use discrete point sampling instead of Lambert projection + bool discreteHeatMap = false; ///< Use heat map coloring for discrete mode + std::string colorMap; ///< Name of the color map to use + std::vector labels; ///< Labels for the 3 pole figures (e.g., "<001>", "<011>", "<111>") + std::vector order = {0, 1, 2}; ///< Display order of the 3 pole figures + bool flipFinalImage = true; ///< Flip individual images so +Y points up + + // --- Composition parameters --- + PoleFigureLayoutType layoutType = PoleFigureLayoutType::Horizontal; ///< How to arrange figures and legend + uint32_t laueOpsIndex = 0; ///< Index into LaueOps::GetAllOrientationOps() + std::string phaseName; ///< Material/phase name for the legend + int32_t phaseNumber = 1; ///< Phase number for the legend + std::string title; ///< Title text drawn at the top of the composite image +}; + +/** + * @brief Result of generating a composite pole figure image. + */ +struct EbsdLib_EXPORT CompositePoleFigureResult +{ + UInt8ArrayType::Pointer image; ///< RGBA image data (4 components per tuple, row-major order) + int32_t height = 0; ///< Image height in pixels (rows, slow dimension) + int32_t width = 0; ///< Image width in pixels (cols, fast dimension) +}; + +/** + * @brief Internal layout metrics computed from image dimensions and layout type. + */ +struct LayoutMetrics +{ + int32_t pageWidth = 0; + int32_t pageHeight = 0; + float fontPtSize = 0.0f; + float margins = 0.0f; + float subCanvasWidth = 0.0f; + float subCanvasHeight = 0.0f; + std::array, 4> origins; ///< [0-2] = pole figure origins, [3] = legend origin +}; + +/** + * @brief Generates complete composite pole figure images from Euler angle data. + * + * Given a set of Euler angles and a crystal symmetry (LaueOps index), this class + * produces a single RGBA image containing 3 individual pole figures arranged + * according to the specified layout, with axis labels, direction labels, and a + * legend/scalar bar. + * + * The class is stateless. Each call to generateCompositeImage() is self-contained. + * + * Example usage: + * @code + * CompositePoleFigureConfiguration_t config; + * config.eulers = eulerAnglesPtr.get(); + * config.imageDim = 512; + * config.lambertDim = 256; + * config.numColors = 32; + * config.laueOpsIndex = crystalStructure; + * config.phaseName = "Nickel"; + * config.phaseNumber = 1; + * config.layoutType = PoleFigureLayoutType::Horizontal; + * + * PoleFigureCompositor compositor; + * CompositePoleFigureResult result = compositor.generateCompositeImage(config); + * // result.image contains RGBA data, result.width and result.height give dimensions + * @endcode + */ +class EbsdLib_EXPORT PoleFigureCompositor +{ +public: + PoleFigureCompositor() = default; + ~PoleFigureCompositor() = default; + + PoleFigureCompositor(const PoleFigureCompositor&) = delete; + PoleFigureCompositor(PoleFigureCompositor&&) = delete; + PoleFigureCompositor& operator=(const PoleFigureCompositor&) = delete; + PoleFigureCompositor& operator=(PoleFigureCompositor&&) = delete; + + /** + * @brief Generates a complete composite pole figure image. + * + * Produces 3 individual pole figures for the given Euler angles and crystal + * symmetry, then composes them into a single RGBA image with axis labels, + * direction labels, a legend/scalar bar, and a title, arranged according to + * the specified layout type. + * + * @param config Configuration controlling pole figure generation, layout, and appearance + * @return CompositePoleFigureResult containing the RGBA image and its dimensions + */ + CompositePoleFigureResult generateCompositeImage(const CompositePoleFigureConfiguration_t& config); + + /** + * @brief Computes layout metrics without generating an image. + * + * Useful for callers that need to know the output image dimensions before + * generating the composite. + * + * @param config Configuration with imageDim and layoutType set + * @return LayoutMetrics with page dimensions and figure origins + */ + static LayoutMetrics computeLayoutMetrics(const CompositePoleFigureConfiguration_t& config); + +private: + std::vector generatePoleFigures(const CompositePoleFigureConfiguration_t& config); + void preprocessImages(std::vector& images, int imageDim, bool flipFinalImage); + UInt8ArrayType::Pointer compositeToCanvas(const CompositePoleFigureConfiguration_t& config, const std::vector& images, const LayoutMetrics& layout); + + static void drawPoleFigure(canvas_ity::canvas& context, const UInt8ArrayType& image, std::array origin, int imageDim, const std::string& directionLabel, float fontPtSize, float margins, + const std::vector& latoBold, const std::vector& firaSans); + static void drawScalarBar(canvas_ity::canvas& context, const CompositePoleFigureConfiguration_t& config, std::array position, float margins, float fontPtSize, + const std::vector& latoRegular); + static void drawInfoBlock(canvas_ity::canvas& context, const CompositePoleFigureConfiguration_t& config, std::array position, float margins, float fontPtSize, + const std::vector& latoRegular); + static void drawTitle(canvas_ity::canvas& context, const std::string& title, float pageWidth, float fontPtSize, float margins, const std::vector& latoBold); + static UInt8ArrayType::Pointer flipAndMirror(UInt8ArrayType* src, int imageDim); + static UInt8ArrayType::Pointer convertColorOrder(UInt8ArrayType* src, int imageDim); +}; + +} // namespace ebsdlib diff --git a/Source/EbsdLib/Utilities/SourceList.cmake b/Source/EbsdLib/Utilities/SourceList.cmake index 8c520a4..0e7fc72 100644 --- a/Source/EbsdLib/Utilities/SourceList.cmake +++ b/Source/EbsdLib/Utilities/SourceList.cmake @@ -21,6 +21,7 @@ set(EbsdLib_${DIR_NAME}_HDRS ${EbsdLibProj_SOURCE_DIR}/Source/EbsdLib/${DIR_NAME}/LatoBold.hpp ${EbsdLibProj_SOURCE_DIR}/Source/EbsdLib/${DIR_NAME}/LatoRegular.hpp ${EbsdLibProj_SOURCE_DIR}/Source/EbsdLib/${DIR_NAME}/CanvasUtilities.hpp + ${EbsdLibProj_SOURCE_DIR}/Source/EbsdLib/${DIR_NAME}/PoleFigureCompositor.h ${EbsdLibProj_SOURCE_DIR}/Source/EbsdLib/${DIR_NAME}/inipp.h ) @@ -37,6 +38,7 @@ set(EbsdLib_${DIR_NAME}_SRCS ${EbsdLibProj_SOURCE_DIR}/Source/EbsdLib/${DIR_NAME}/TiffWriter.cpp ${EbsdLibProj_SOURCE_DIR}/Source/EbsdLib/${DIR_NAME}/CanvasUtilities.cpp ${EbsdLibProj_SOURCE_DIR}/Source/EbsdLib/${DIR_NAME}/Fonts.cpp + ${EbsdLibProj_SOURCE_DIR}/Source/EbsdLib/${DIR_NAME}/PoleFigureCompositor.cpp ) # # QT5_WRAP_CPP( EbsdLib_Generated_MOC_SRCS ${EbsdLib_Utilities_MOC_HDRS} ) # set_source_files_properties( ${EbsdLib_Generated_MOC_SRCS} PROPERTIES HEADER_FILE_ONLY TRUE) From b87add71ba191b0abe689ba36a7c4af156ff4d39 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 7 Apr 2026 21:06:31 -0400 Subject: [PATCH 2/8] Add PoleFigureCompositor tests and implement computeLayoutMetrics - Create PoleFigureCompositorTest.cpp with 4 test cases covering ConfigDefaults, Horizontal, Vertical, and Square layout metrics - Add test file to Source/Test/CMakeLists.txt - Implement computeLayoutMetrics in PoleFigureCompositor.cpp with font-measured xCharWidth for accurate subCanvasWidth, and correct page dimensions and figure origins for all three layout types Co-Authored-By: Claude Sonnet 4.6 --- .../Utilities/PoleFigureCompositor.cpp | 55 +++++- Source/Test/CMakeLists.txt | 1 + Source/Test/PoleFigureCompositorTest.cpp | 174 ++++++++++++++++++ 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 Source/Test/PoleFigureCompositorTest.cpp diff --git a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp index e344b1d..fbe36cc 100644 --- a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp +++ b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp @@ -27,7 +27,60 @@ CompositePoleFigureResult PoleFigureCompositor::generateCompositeImage(const Com // ----------------------------------------------------------------------------- LayoutMetrics PoleFigureCompositor::computeLayoutMetrics(const CompositePoleFigureConfiguration_t& config) { - return {}; + LayoutMetrics metrics; + const auto imageWidth = static_cast(config.imageDim); + const auto imageHeight = static_cast(config.imageDim); + metrics.fontPtSize = imageHeight / 16.0f; + metrics.margins = imageHeight / 32.0f; + + // Measure "X" character width using a temporary canvas + float xCharWidth = 0.0f; + { + std::vector latoBold = fonts::GetLatoBold(); + canvas_ity::canvas tempContext(config.imageDim, config.imageDim); + tempContext.set_font(latoBold.data(), static_cast(latoBold.size()), metrics.fontPtSize); + const std::array buf = {'X', 0}; + xCharWidth = tempContext.measure_text(buf.data()); + } + + metrics.subCanvasWidth = metrics.margins + imageWidth + xCharWidth + metrics.margins; + metrics.subCanvasHeight = metrics.margins + metrics.fontPtSize + imageHeight + metrics.fontPtSize * 2.0f + metrics.margins * 2.0f; + + switch(config.layoutType) + { + case PoleFigureLayoutType::Horizontal: { + metrics.pageWidth = static_cast(metrics.subCanvasWidth) * 4; + metrics.pageHeight = static_cast(metrics.margins + metrics.fontPtSize + metrics.subCanvasHeight); + const float y = static_cast(metrics.pageHeight) - metrics.subCanvasHeight; + metrics.origins[0] = {0.0f, y}; + metrics.origins[1] = {metrics.subCanvasWidth, y}; + metrics.origins[2] = {metrics.subCanvasWidth * 2.0f, y}; + metrics.origins[3] = {metrics.subCanvasWidth * 3.0f, y}; + break; + } + case PoleFigureLayoutType::Vertical: { + metrics.pageWidth = static_cast(metrics.subCanvasWidth); + metrics.pageHeight = static_cast(metrics.margins + metrics.fontPtSize + metrics.subCanvasHeight * 4.0f); + const float topY = metrics.margins + metrics.fontPtSize; + metrics.origins[0] = {0.0f, topY}; + metrics.origins[1] = {0.0f, topY + metrics.subCanvasHeight}; + metrics.origins[2] = {0.0f, topY + metrics.subCanvasHeight * 2.0f}; + metrics.origins[3] = {0.0f, topY + metrics.subCanvasHeight * 3.0f}; + break; + } + case PoleFigureLayoutType::Square: { + metrics.pageWidth = static_cast(metrics.subCanvasWidth) * 2; + metrics.pageHeight = static_cast(metrics.margins + metrics.fontPtSize + metrics.subCanvasHeight * 2.0f); + const float topY = static_cast(metrics.pageHeight) - 2.0f * metrics.subCanvasHeight; + const float bottomY = static_cast(metrics.pageHeight) - metrics.subCanvasHeight; + metrics.origins[0] = {0.0f, topY}; + metrics.origins[1] = {metrics.subCanvasWidth, topY}; + metrics.origins[2] = {0.0f, bottomY}; + metrics.origins[3] = {metrics.subCanvasWidth, bottomY}; + break; + } + } + return metrics; } // ----------------------------------------------------------------------------- diff --git a/Source/Test/CMakeLists.txt b/Source/Test/CMakeLists.txt index aee0e9f..4b269fb 100644 --- a/Source/Test/CMakeLists.txt +++ b/Source/Test/CMakeLists.txt @@ -47,6 +47,7 @@ set(EbsdLib_UnitTest_SRCS ${EbsdLibProj_SOURCE_DIR}/Source/Test/CanvasUtilitiesTest.cpp ${EbsdLibProj_SOURCE_DIR}/Source/Test/ModifiedLambertProjectionArrayTest.cpp ${EbsdLibProj_SOURCE_DIR}/Source/Test/PoleFigureUtilitiesTest.cpp + ${EbsdLibProj_SOURCE_DIR}/Source/Test/PoleFigureCompositorTest.cpp ${EbsdLibProj_SOURCE_DIR}/Source/Test/TiffWriterTest.cpp ${EbsdLibProj_SOURCE_DIR}/Source/Test/DirectionalStatsTest.cpp diff --git a/Source/Test/PoleFigureCompositorTest.cpp b/Source/Test/PoleFigureCompositorTest.cpp new file mode 100644 index 0000000..5f8077d --- /dev/null +++ b/Source/Test/PoleFigureCompositorTest.cpp @@ -0,0 +1,174 @@ +/* ============================================================================ + * Copyright (c) 2009-2025 BlueQuartz Software, LLC + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this + * list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * + * Neither the name of BlueQuartz Software, the US Air Force, nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ + +#include + +#include "EbsdLib/Core/EbsdDataArray.hpp" +#include "EbsdLib/Utilities/PoleFigureCompositor.h" + +using namespace ebsdlib; + +// ----------------------------------------------------------------------------- +TEST_CASE("ebsdlib::PoleFigureCompositorTest::ConfigDefaults", "[EbsdLib][PoleFigureCompositorTest]") +{ + CompositePoleFigureConfiguration_t config; + + REQUIRE(config.eulers == nullptr); + REQUIRE(config.imageDim == 512); + REQUIRE(config.lambertDim == 256); + REQUIRE(config.numColors == 32); + REQUIRE(config.minScale == Approx(0.0)); + REQUIRE(config.maxScale == Approx(1.0)); + REQUIRE(config.sphereRadius == Approx(1.0f)); + REQUIRE(config.discrete == false); + REQUIRE(config.discreteHeatMap == false); + REQUIRE(config.colorMap.empty()); + REQUIRE(config.labels.empty()); + REQUIRE(config.order.size() == 3); + REQUIRE(config.order[0] == 0); + REQUIRE(config.order[1] == 1); + REQUIRE(config.order[2] == 2); + REQUIRE(config.flipFinalImage == true); + REQUIRE(config.layoutType == PoleFigureLayoutType::Horizontal); + REQUIRE(config.laueOpsIndex == 0); + REQUIRE(config.phaseName.empty()); + REQUIRE(config.phaseNumber == 1); + REQUIRE(config.title.empty()); +} + +// ----------------------------------------------------------------------------- +TEST_CASE("ebsdlib::PoleFigureCompositorTest::LayoutMetrics_Horizontal", "[EbsdLib][PoleFigureCompositorTest]") +{ + CompositePoleFigureConfiguration_t config; + config.imageDim = 256; + config.layoutType = PoleFigureLayoutType::Horizontal; + + LayoutMetrics metrics = PoleFigureCompositor::computeLayoutMetrics(config); + + const float imageDim = 256.0f; + const float expectedFontPtSize = imageDim / 16.0f; // 16.0f + const float expectedMargins = imageDim / 32.0f; // 8.0f + + REQUIRE(metrics.fontPtSize == Approx(expectedFontPtSize)); + REQUIRE(metrics.margins == Approx(expectedMargins)); + + // subCanvasWidth > imageDim (includes xCharWidth from font measurement) + REQUIRE(metrics.subCanvasWidth > imageDim); + + // subCanvasHeight = margins + fontPtSize + imageDim + fontPtSize*2 + margins*2 + const float expectedSubCanvasHeight = expectedMargins + expectedFontPtSize + imageDim + expectedFontPtSize * 2.0f + expectedMargins * 2.0f; + REQUIRE(metrics.subCanvasHeight == Approx(expectedSubCanvasHeight)); + + // Horizontal: pageWidth = subCanvasWidth * 4, pageHeight contains one row + REQUIRE(metrics.pageWidth == static_cast(metrics.subCanvasWidth) * 4); + REQUIRE(metrics.pageHeight > 0); + + // All 4 origins should have the same Y (side-by-side in a row) + const float y0 = metrics.origins[0][1]; + REQUIRE(metrics.origins[1][1] == Approx(y0)); + REQUIRE(metrics.origins[2][1] == Approx(y0)); + REQUIRE(metrics.origins[3][1] == Approx(y0)); + + // X positions should increase by subCanvasWidth each step + REQUIRE(metrics.origins[0][0] == Approx(0.0f)); + REQUIRE(metrics.origins[1][0] == Approx(metrics.subCanvasWidth)); + REQUIRE(metrics.origins[2][0] == Approx(metrics.subCanvasWidth * 2.0f)); + REQUIRE(metrics.origins[3][0] == Approx(metrics.subCanvasWidth * 3.0f)); +} + +// ----------------------------------------------------------------------------- +TEST_CASE("ebsdlib::PoleFigureCompositorTest::LayoutMetrics_Vertical", "[EbsdLib][PoleFigureCompositorTest]") +{ + CompositePoleFigureConfiguration_t config; + config.imageDim = 256; + config.layoutType = PoleFigureLayoutType::Vertical; + + LayoutMetrics metrics = PoleFigureCompositor::computeLayoutMetrics(config); + + const float imageDim = 256.0f; + const float expectedFontPtSize = imageDim / 16.0f; + const float expectedMargins = imageDim / 32.0f; + + REQUIRE(metrics.fontPtSize == Approx(expectedFontPtSize)); + REQUIRE(metrics.margins == Approx(expectedMargins)); + REQUIRE(metrics.subCanvasWidth > imageDim); + + // Vertical: pageWidth = subCanvasWidth (single column) + REQUIRE(metrics.pageWidth == static_cast(metrics.subCanvasWidth)); + REQUIRE(metrics.pageHeight > 0); + + // All 4 origins should have the same X = 0 (stacked in a column) + REQUIRE(metrics.origins[0][0] == Approx(0.0f)); + REQUIRE(metrics.origins[1][0] == Approx(0.0f)); + REQUIRE(metrics.origins[2][0] == Approx(0.0f)); + REQUIRE(metrics.origins[3][0] == Approx(0.0f)); + + // Y positions should increase by subCanvasHeight each step + const float topY = expectedMargins + expectedFontPtSize; + REQUIRE(metrics.origins[0][1] == Approx(topY)); + REQUIRE(metrics.origins[1][1] == Approx(topY + metrics.subCanvasHeight)); + REQUIRE(metrics.origins[2][1] == Approx(topY + metrics.subCanvasHeight * 2.0f)); + REQUIRE(metrics.origins[3][1] == Approx(topY + metrics.subCanvasHeight * 3.0f)); +} + +// ----------------------------------------------------------------------------- +TEST_CASE("ebsdlib::PoleFigureCompositorTest::LayoutMetrics_Square", "[EbsdLib][PoleFigureCompositorTest]") +{ + CompositePoleFigureConfiguration_t config; + config.imageDim = 256; + config.layoutType = PoleFigureLayoutType::Square; + + LayoutMetrics metrics = PoleFigureCompositor::computeLayoutMetrics(config); + + const float imageDim = 256.0f; + const float expectedFontPtSize = imageDim / 16.0f; + const float expectedMargins = imageDim / 32.0f; + + REQUIRE(metrics.fontPtSize == Approx(expectedFontPtSize)); + REQUIRE(metrics.margins == Approx(expectedMargins)); + REQUIRE(metrics.subCanvasWidth > imageDim); + + // Square: pageWidth = subCanvasWidth * 2 (2 columns) + REQUIRE(metrics.pageWidth == static_cast(metrics.subCanvasWidth) * 2); + REQUIRE(metrics.pageHeight > 0); + + // Top row: origins[0] and origins[1] share the same Y + REQUIRE(metrics.origins[0][0] == Approx(0.0f)); + REQUIRE(metrics.origins[1][0] == Approx(metrics.subCanvasWidth)); + REQUIRE(metrics.origins[0][1] == Approx(metrics.origins[1][1])); + + // Bottom row: origins[2] and origins[3] share the same Y + REQUIRE(metrics.origins[2][0] == Approx(0.0f)); + REQUIRE(metrics.origins[3][0] == Approx(metrics.subCanvasWidth)); + REQUIRE(metrics.origins[2][1] == Approx(metrics.origins[3][1])); + + // Bottom row Y is one subCanvasHeight below top row Y + REQUIRE(metrics.origins[2][1] == Approx(metrics.origins[0][1] + metrics.subCanvasHeight)); +} From 16dd677f58b887efa41918e0fe4d780cf08f7147 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 7 Apr 2026 21:12:31 -0400 Subject: [PATCH 3/8] Implement pole figure generation, preprocessing, drawing helpers, and canvas composition Fill in all remaining stub methods in PoleFigureCompositor: generatePoleFigures, preprocessImages, flipAndMirror, convertColorOrder, drawPoleFigure, drawScalarBar, drawInfoBlock, drawTitle, compositeToCanvas, and generateCompositeImage. Add fmt::fmt link dependency to EbsdLib. Add 3 end-to-end tests covering horizontal/discrete/all layout modes. Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/EbsdLib/SourceList.cmake | 1 + .../Utilities/PoleFigureCompositor.cpp | 334 +++++++++++++++++- Source/Test/PoleFigureCompositorTest.cpp | 135 +++++++ 3 files changed, 464 insertions(+), 6 deletions(-) diff --git a/Source/EbsdLib/SourceList.cmake b/Source/EbsdLib/SourceList.cmake index 5d313c4..14b71b1 100644 --- a/Source/EbsdLib/SourceList.cmake +++ b/Source/EbsdLib/SourceList.cmake @@ -178,6 +178,7 @@ target_include_directories(${PROJECT_NAME} target_link_libraries(${PROJECT_NAME} PUBLIC Eigen3::Eigen + fmt::fmt ) if(WIN32 AND BUILD_SHARED_LIBS) diff --git a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp index fbe36cc..0f80e5b 100644 --- a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp +++ b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp @@ -21,7 +21,27 @@ namespace ebsdlib // ----------------------------------------------------------------------------- CompositePoleFigureResult PoleFigureCompositor::generateCompositeImage(const CompositePoleFigureConfiguration_t& config) { - return {}; + CompositePoleFigureResult result; + + // Stage 1: Generate 3 individual pole figure RGBA images + std::vector figures = generatePoleFigures(config); + if(figures.size() != 3) + { + return result; + } + + // Stage 2: Preprocess (flip + color conversion, optionally parallel) + preprocessImages(figures, config.imageDim, config.flipFinalImage); + + // Stage 3: Compute layout + LayoutMetrics layout = computeLayoutMetrics(config); + + // Stage 4: Composite onto canvas + result.image = compositeToCanvas(config, figures, layout); + result.width = layout.pageWidth; + result.height = layout.pageHeight; + + return result; } // ----------------------------------------------------------------------------- @@ -86,53 +106,355 @@ LayoutMetrics PoleFigureCompositor::computeLayoutMetrics(const CompositePoleFigu // ----------------------------------------------------------------------------- std::vector PoleFigureCompositor::generatePoleFigures(const CompositePoleFigureConfiguration_t& config) { - return {}; + PoleFigureConfiguration_t pfConfig; + pfConfig.eulers = config.eulers; + pfConfig.imageDim = config.imageDim; + pfConfig.lambertDim = config.lambertDim; + pfConfig.numColors = config.numColors; + pfConfig.minScale = config.minScale; + pfConfig.maxScale = config.maxScale; + pfConfig.sphereRadius = config.sphereRadius; + pfConfig.discrete = config.discrete; + pfConfig.discreteHeatMap = config.discreteHeatMap; + pfConfig.colorMap = config.colorMap; + pfConfig.labels = config.labels; + pfConfig.order = config.order; + pfConfig.phaseName = config.phaseName; + pfConfig.FlipFinalImage = config.flipFinalImage; + + std::vector orientationOps = LaueOps::GetAllOrientationOps(); + if(config.laueOpsIndex >= orientationOps.size()) + { + return {}; + } + + return orientationOps[config.laueOpsIndex]->generatePoleFigure(pfConfig); } // ----------------------------------------------------------------------------- -void PoleFigureCompositor::preprocessImages(std::vector& images, int imageDim, bool flipFinalImage) +void PoleFigureCompositor::preprocessImages(std::vector& images, int imageDim, bool flip) { +#ifdef EbsdLib_USE_PARALLEL_ALGORITHMS + tbb::task_group g; + for(auto& image : images) + { + g.run([&image, imageDim, flip]() { + if(flip) + { + image = flipAndMirror(image.get(), imageDim); + } + image = convertColorOrder(image.get(), imageDim); + }); + } + g.wait(); +#else + for(auto& image : images) + { + if(flip) + { + image = flipAndMirror(image.get(), imageDim); + } + image = convertColorOrder(image.get(), imageDim); + } +#endif } // ----------------------------------------------------------------------------- UInt8ArrayType::Pointer PoleFigureCompositor::compositeToCanvas(const CompositePoleFigureConfiguration_t& config, const std::vector& images, const LayoutMetrics& layout) { - return nullptr; + std::vector latoBold = fonts::GetLatoBold(); + std::vector latoRegular = fonts::GetLatoRegular(); + std::vector firaSans = fonts::GetFiraSansRegular(); + + canvas_ity::canvas context(layout.pageWidth, layout.pageHeight); + + context.set_font(latoBold.data(), static_cast(latoBold.size()), layout.fontPtSize); + context.set_color(canvas_ity::fill_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.text_baseline = canvas_ity::alphabetic; + + // White background + context.move_to(0.0f, 0.0f); + context.line_to(static_cast(layout.pageWidth), 0.0f); + context.line_to(static_cast(layout.pageWidth), static_cast(layout.pageHeight)); + context.line_to(0.0f, static_cast(layout.pageHeight)); + context.line_to(0.0f, 0.0f); + context.close_path(); + context.set_color(canvas_ity::fill_style, 1.0f, 1.0f, 1.0f, 1.0f); + context.fill(); + + // Draw each of the 3 pole figures + for(int i = 0; i < 3 && i < static_cast(images.size()); i++) + { + std::string directionLabel = images[i]->getName(); + drawPoleFigure(context, *images[i], layout.origins[i], config.imageDim, directionLabel, layout.fontPtSize, layout.margins, latoBold, firaSans); + } + + // Title + drawTitle(context, config.title, static_cast(layout.pageWidth), layout.fontPtSize, layout.margins, latoBold); + + // Legend at 4th position + const float legendFontPtSize = static_cast(config.imageDim) / 20.0f; + if(config.discrete) + { + drawInfoBlock(context, config, layout.origins[3], layout.margins, legendFontPtSize, latoRegular); + } + else + { + drawScalarBar(context, config, layout.origins[3], layout.margins, legendFontPtSize, latoRegular); + } + + // Extract RGBA pixels + auto result = UInt8ArrayType::CreateArray(static_cast(layout.pageWidth) * layout.pageHeight, {4ULL}, "CompositePoleFigure", true); + context.get_image_data(result->getPointer(0), layout.pageWidth, layout.pageHeight, layout.pageWidth * 4, 0, 0); + + return result; } // ----------------------------------------------------------------------------- void PoleFigureCompositor::drawPoleFigure(canvas_ity::canvas& context, const UInt8ArrayType& image, std::array origin, int imageDim, const std::string& directionLabel, float fontPtSize, float margins, const std::vector& latoBold, const std::vector& firaSans) { + const auto imageSize = static_cast(imageDim); + + // Draw the pole figure image + context.draw_image(const_cast(image.getPointer(0)), imageDim, imageDim, imageDim * static_cast(image.getNumberOfComponents()), origin[0] + margins, + origin[1] + fontPtSize * 2.0f + margins * 2.0f, imageSize, imageSize); + + // Circle outline + context.begin_path(); + context.line_cap = canvas_ity::circle; + context.set_line_width(3.0f); + context.set_color(canvas_ity::stroke_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.arc(origin[0] + margins + imageSize / 2.0f, origin[1] + fontPtSize * 2.0f + margins * 2.0f + imageSize / 2.0f, imageSize / 2.0f, 0, 2.0f * 3.14159265358979323846f); + context.stroke(); + context.close_path(); + + // X axis line + context.begin_path(); + context.line_cap = canvas_ity::square; + context.set_line_width(2.0f); + context.set_color(canvas_ity::stroke_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.move_to(origin[0] + margins, origin[1] + fontPtSize * 2.0f + margins * 2.0f + imageSize / 2.0f); + context.line_to(origin[0] + margins + imageSize, origin[1] + fontPtSize * 2.0f + margins * 2.0f + imageSize / 2.0f); + context.stroke(); + context.close_path(); + + // Y axis line + context.begin_path(); + context.line_cap = canvas_ity::square; + context.set_line_width(2.0f); + context.set_color(canvas_ity::stroke_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.move_to(origin[0] + margins + imageSize / 2.0f, origin[1] + fontPtSize * 2.0f + margins * 2.0f); + context.line_to(origin[0] + margins + imageSize / 2.0f, origin[1] + fontPtSize * 2.0f + margins * 2.0f + imageSize); + context.stroke(); + context.close_path(); + + // "X" axis label + context.begin_path(); + context.set_font(const_cast(latoBold.data()), static_cast(latoBold.size()), fontPtSize); + context.set_color(canvas_ity::fill_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.text_baseline = canvas_ity::alphabetic; + context.fill_text("X", origin[0] + margins * 2.0f + imageSize, origin[1] + fontPtSize * 2.25f + margins * 2.0f + imageSize / 2.0f); + context.close_path(); + + // "Y" axis label + context.begin_path(); + context.set_font(const_cast(latoBold.data()), static_cast(latoBold.size()), fontPtSize); + context.set_color(canvas_ity::fill_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.text_baseline = canvas_ity::alphabetic; + const float yFontWidth = context.measure_text("Y"); + context.fill_text("Y", origin[0] + margins - (0.5f * yFontWidth) + imageSize / 2.0f, origin[1] + fontPtSize * 2.0f + margins); + context.close_path(); + + // Direction label (e.g., "<001>" displayed as "(001)") + std::string subtitle = EbsdStringUtils::replace(directionLabel, "<", "("); + subtitle = EbsdStringUtils::replace(subtitle, ">", ")"); + + std::string bottomPart; + std::array textOrigin = {origin[0] + margins, origin[1] + fontPtSize + 2.0f * margins}; + + // Handle overbar notation: "-" before a digit draws a line above the digit + for(size_t idx = 0; idx < subtitle.size(); idx++) + { + if(subtitle.at(idx) == '-' && idx + 1 < subtitle.size()) + { + const char charBuf[] = {subtitle[idx + 1], 0}; + context.set_font(const_cast(firaSans.data()), static_cast(firaSans.size()), fontPtSize); + float tw = 0.0f; + if(!bottomPart.empty()) + { + tw = context.measure_text(bottomPart.c_str()); + } + const float charWidth = context.measure_text(charBuf); + const float dashWidth = charWidth * 0.5f; + const float dashOffset = charWidth * 0.25f; + + context.begin_path(); + context.line_cap = canvas_ity::square; + context.set_line_width(2.0f); + context.set_color(canvas_ity::stroke_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.move_to(textOrigin[0] + tw + dashOffset, textOrigin[1] - (0.8f * fontPtSize)); + context.line_to(textOrigin[0] + tw + dashOffset + dashWidth, textOrigin[1] - (0.8f * fontPtSize)); + context.stroke(); + context.close_path(); + } + else + { + bottomPart.push_back(subtitle.at(idx)); + } + } + + // Draw the direction subtitle text + context.begin_path(); + context.set_font(const_cast(firaSans.data()), static_cast(firaSans.size()), fontPtSize); + context.set_color(canvas_ity::fill_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.text_baseline = canvas_ity::alphabetic; + context.fill_text(bottomPart.c_str(), textOrigin[0], textOrigin[1]); + context.close_path(); } // ----------------------------------------------------------------------------- void PoleFigureCompositor::drawScalarBar(canvas_ity::canvas& context, const CompositePoleFigureConfiguration_t& config, std::array position, float margins, float fontPtSize, const std::vector& latoRegular) { + const int numColors = config.numColors; + + std::vector colorTable(numColors); + std::vector colors(3 * numColors, 0.0f); + EbsdColorTable::GetColorTable(numColors, colors); + for(int i = 0; i < numColors; i++) + { + float r = colors[3 * i]; + float g = colors[3 * i + 1]; + float b = colors[3 * i + 2]; + colorTable[i] = ebsdlib::RgbColor::dRgb(static_cast(r * 255.0f), static_cast(g * 255.0f), static_cast(b * 255.0f), 255); + } + + const float scaleBarRelativeWidth = 0.10f; + const float colorHeight = static_cast(config.imageDim) / static_cast(numColors); + const float rectWidth = static_cast(config.imageDim) * scaleBarRelativeWidth; + + // Max value text + context.begin_path(); + const std::string maxStr = fmt::format("{:#.6}", config.maxScale); + context.set_font(const_cast(latoRegular.data()), static_cast(latoRegular.size()), fontPtSize); + context.set_color(canvas_ity::fill_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.text_baseline = canvas_ity::alphabetic; + context.fill_text(maxStr.c_str(), position[0] + 2.0f * margins + rectWidth, position[1] + 2.0f * margins + 2.0f * fontPtSize + colorHeight); + context.close_path(); + + // Min value text + context.begin_path(); + const std::string minStr = fmt::format("{:#.6}", config.minScale); + context.set_font(const_cast(latoRegular.data()), static_cast(latoRegular.size()), fontPtSize); + context.set_color(canvas_ity::fill_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.text_baseline = canvas_ity::alphabetic; + context.fill_text(minStr.c_str(), position[0] + 2.0f * margins + rectWidth, position[1] + 2.0f * margins + 2.0f * fontPtSize + static_cast(numColors) * colorHeight); + context.close_path(); + + // Color bar rectangles + for(int i = 0; i < numColors; i++) + { + const ebsdlib::Rgb c = colorTable[numColors - i - 1]; + auto [r, g, b] = ebsdlib::RgbColor::fRgb(c); + + const float x = position[0] + margins; + const float y = position[1] + 2.0f * margins + 2.0f * fontPtSize + static_cast(i) * colorHeight; + + context.begin_path(); + context.set_color(canvas_ity::fill_style, r, g, b, 1.0f); + context.fill_rectangle(x, y, rectWidth, colorHeight); + context.set_color(canvas_ity::stroke_style, r, g, b, 1.0f); + context.set_line_width(1.0f); + context.stroke_rectangle(x, y, rectWidth, colorHeight); + } + + drawInfoBlock(context, config, position, margins, fontPtSize, latoRegular); } // ----------------------------------------------------------------------------- void PoleFigureCompositor::drawInfoBlock(canvas_ity::canvas& context, const CompositePoleFigureConfiguration_t& config, std::array position, float margins, float fontPtSize, const std::vector& latoRegular) { + const float scaleBarRelativeWidth = 0.10f; + const auto imageWidth = static_cast(config.imageDim); + const float rectWidth = imageWidth * scaleBarRelativeWidth; + + std::vector laueNames = LaueOps::GetLaueNames(); + std::string laueGroupName; + if(config.laueOpsIndex < laueNames.size()) + { + laueGroupName = laueNames[config.laueOpsIndex]; + } + + const std::vector labels = { + fmt::format("Phase Num: {}", config.phaseNumber), + fmt::format("Material Name: {}", config.phaseName), + fmt::format("Laue Group: {}", laueGroupName), + fmt::format("Upper & Lower:"), + fmt::format("Samples: {}", config.eulers != nullptr ? config.eulers->getNumberOfTuples() : 0), + fmt::format("Lambert Sq. Dim: {}", config.lambertDim)}; + + float heightInc = 1.0f; + for(const auto& label : labels) + { + context.begin_path(); + context.set_font(const_cast(latoRegular.data()), static_cast(latoRegular.size()), fontPtSize); + context.set_color(canvas_ity::fill_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.text_baseline = canvas_ity::alphabetic; + context.fill_text(label.c_str(), position[0] + margins + rectWidth + margins, position[1] + margins + (static_cast(config.imageDim) / 3.0f) + (heightInc * fontPtSize)); + context.close_path(); + heightInc++; + } } // ----------------------------------------------------------------------------- void PoleFigureCompositor::drawTitle(canvas_ity::canvas& context, const std::string& title, float pageWidth, float fontPtSize, float margins, const std::vector& latoBold) { + if(title.empty()) + { + return; + } + context.begin_path(); + context.set_font(const_cast(latoBold.data()), static_cast(latoBold.size()), fontPtSize); + context.set_color(canvas_ity::fill_style, 0.0f, 0.0f, 0.0f, 1.0f); + context.text_baseline = canvas_ity::alphabetic; + context.fill_text(title.c_str(), margins, margins + fontPtSize); + context.close_path(); } // ----------------------------------------------------------------------------- UInt8ArrayType::Pointer PoleFigureCompositor::flipAndMirror(UInt8ArrayType* src, int imageDim) { - return nullptr; + UInt8ArrayType::Pointer converted = UInt8ArrayType::CreateArray(static_cast(imageDim) * imageDim, src->getComponentDimensions(), src->getName(), true); + for(int y = 0; y < imageDim; y++) + { + const int destY = imageDim - 1 - y; + for(int x = 0; x < imageDim; x++) + { + const size_t indexSrc = static_cast(y) * imageDim + x; + const size_t indexDest = static_cast(destY) * imageDim + x; + uint8_t* srcPtr = src->getTuplePointer(indexSrc); + converted->setTuple(indexDest, srcPtr); + } + } + return converted; } // ----------------------------------------------------------------------------- UInt8ArrayType::Pointer PoleFigureCompositor::convertColorOrder(UInt8ArrayType* src, int imageDim) { - return nullptr; + UInt8ArrayType::Pointer converted = UInt8ArrayType::CreateArray(src->getNumberOfTuples(), src->getComponentDimensions(), src->getName(), true); + for(size_t tIdx = 0; tIdx < src->getNumberOfTuples(); tIdx++) + { + uint8_t* argbPtr = src->getTuplePointer(tIdx); + uint8_t* destPtr = converted->getTuplePointer(tIdx); + destPtr[0] = argbPtr[2]; + destPtr[1] = argbPtr[1]; + destPtr[2] = argbPtr[0]; + destPtr[3] = argbPtr[3]; + } + return converted; } } // namespace ebsdlib diff --git a/Source/Test/PoleFigureCompositorTest.cpp b/Source/Test/PoleFigureCompositorTest.cpp index 5f8077d..7df0ff7 100644 --- a/Source/Test/PoleFigureCompositorTest.cpp +++ b/Source/Test/PoleFigureCompositorTest.cpp @@ -172,3 +172,138 @@ TEST_CASE("ebsdlib::PoleFigureCompositorTest::LayoutMetrics_Square", "[EbsdLib][ // Bottom row Y is one subCanvasHeight below top row Y REQUIRE(metrics.origins[2][1] == Approx(metrics.origins[0][1] + metrics.subCanvasHeight)); } + +// ----------------------------------------------------------------------------- +TEST_CASE("ebsdlib::PoleFigureCompositorTest::GenerateComposite_Cubic_Horizontal", "[EbsdLib][PoleFigureCompositorTest]") +{ + const size_t numOrientations = 100; + std::vector compDims = {3}; + auto eulers = FloatArrayType::CreateArray(numOrientations, compDims, "TestEulers", true); + for(size_t i = 0; i < numOrientations; i++) + { + float* ptr = eulers->getTuplePointer(i); + ptr[0] = static_cast((i * 7 + 3) % 360) * 0.0174533f; + ptr[1] = static_cast((i * 13 + 5) % 180) * 0.0174533f; + ptr[2] = static_cast((i * 19 + 11) % 360) * 0.0174533f; + } + + CompositePoleFigureConfiguration_t config; + config.eulers = eulers.get(); + config.imageDim = 64; + config.lambertDim = 32; + config.numColors = 16; + config.discrete = false; + config.discreteHeatMap = false; + config.flipFinalImage = true; + config.laueOpsIndex = 1; // CubicOps (Cubic_High) + config.layoutType = PoleFigureLayoutType::Horizontal; + config.phaseName = "TestPhase"; + config.phaseNumber = 1; + config.title = "Test Pole Figure"; + + PoleFigureCompositor compositor; + CompositePoleFigureResult result = compositor.generateCompositeImage(config); + + REQUIRE(result.image != nullptr); + REQUIRE(result.width > 0); + REQUIRE(result.height > 0); + REQUIRE(result.image->getNumberOfComponents() == 4); + REQUIRE(result.image->getNumberOfTuples() == static_cast(result.width * result.height)); + + LayoutMetrics metrics = PoleFigureCompositor::computeLayoutMetrics(config); + REQUIRE(result.width == metrics.pageWidth); + REQUIRE(result.height == metrics.pageHeight); + + // Verify content (not all white) + bool hasNonWhite = false; + for(size_t i = 0; i < result.image->getNumberOfTuples() && !hasNonWhite; i++) + { + uint8_t* pixel = result.image->getTuplePointer(i); + if(pixel[0] != 255 || pixel[1] != 255 || pixel[2] != 255) + { + hasNonWhite = true; + } + } + REQUIRE(hasNonWhite); +} + +// ----------------------------------------------------------------------------- +TEST_CASE("ebsdlib::PoleFigureCompositorTest::GenerateComposite_Cubic_Discrete", "[EbsdLib][PoleFigureCompositorTest]") +{ + const size_t numOrientations = 50; + std::vector compDims = {3}; + auto eulers = FloatArrayType::CreateArray(numOrientations, compDims, "TestEulers", true); + for(size_t i = 0; i < numOrientations; i++) + { + float* ptr = eulers->getTuplePointer(i); + ptr[0] = static_cast((i * 7 + 3) % 360) * 0.0174533f; + ptr[1] = static_cast((i * 13 + 5) % 180) * 0.0174533f; + ptr[2] = static_cast((i * 19 + 11) % 360) * 0.0174533f; + } + + CompositePoleFigureConfiguration_t config; + config.eulers = eulers.get(); + config.imageDim = 64; + config.lambertDim = 32; + config.numColors = 16; + config.discrete = true; + config.discreteHeatMap = false; + config.flipFinalImage = true; + config.laueOpsIndex = 1; + config.layoutType = PoleFigureLayoutType::Horizontal; + config.phaseName = "DiscretePhase"; + config.phaseNumber = 1; + config.title = "Discrete Test"; + + PoleFigureCompositor compositor; + CompositePoleFigureResult result = compositor.generateCompositeImage(config); + + REQUIRE(result.image != nullptr); + REQUIRE(result.width > 0); + REQUIRE(result.height > 0); + REQUIRE(result.image->getNumberOfComponents() == 4); + REQUIRE(result.image->getNumberOfTuples() == static_cast(result.width * result.height)); +} + +// ----------------------------------------------------------------------------- +TEST_CASE("ebsdlib::PoleFigureCompositorTest::GenerateComposite_AllLayouts", "[EbsdLib][PoleFigureCompositorTest]") +{ + const size_t numOrientations = 50; + std::vector compDims = {3}; + auto eulers = FloatArrayType::CreateArray(numOrientations, compDims, "TestEulers", true); + for(size_t i = 0; i < numOrientations; i++) + { + float* ptr = eulers->getTuplePointer(i); + ptr[0] = static_cast((i * 7 + 3) % 360) * 0.0174533f; + ptr[1] = static_cast((i * 13 + 5) % 180) * 0.0174533f; + ptr[2] = static_cast((i * 19 + 11) % 360) * 0.0174533f; + } + + std::vector layouts = {PoleFigureLayoutType::Horizontal, PoleFigureLayoutType::Vertical, PoleFigureLayoutType::Square}; + + for(auto layout : layouts) + { + DYNAMIC_SECTION("Layout " << static_cast(layout)) + { + CompositePoleFigureConfiguration_t config; + config.eulers = eulers.get(); + config.imageDim = 64; + config.lambertDim = 32; + config.numColors = 16; + config.laueOpsIndex = 1; + config.layoutType = layout; + config.phaseName = "TestPhase"; + config.phaseNumber = 1; + config.title = "Layout Test"; + + PoleFigureCompositor compositor; + CompositePoleFigureResult result = compositor.generateCompositeImage(config); + + REQUIRE(result.image != nullptr); + + LayoutMetrics metrics = PoleFigureCompositor::computeLayoutMetrics(config); + REQUIRE(result.width == metrics.pageWidth); + REQUIRE(result.height == metrics.pageHeight); + } + } +} From fa114c10e8057c73bcfc30993816ed497d12a7bd Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 7 Apr 2026 21:32:56 -0400 Subject: [PATCH 4/8] Fix Pi constant, ColorTable casting, font caching, and minScale/maxScale propagation - Use ebsdlib::constants::k_2PiF instead of inline 2*pi for arc drawing - Match SIMPLNX ColorTable static_cast pattern for float consistency - Fix font accessor functions to not re-decode base64 on every call - Propagate minScale/maxScale from LaueOps back through generateCompositeImage - Change generateCompositeImage to take non-const config reference Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/EbsdLib/Utilities/ColorTable.cpp | 4 ++-- Source/EbsdLib/Utilities/Fonts.cpp | 15 ++++++++++++--- .../EbsdLib/Utilities/PoleFigureCompositor.cpp | 16 ++++++++++++---- Source/EbsdLib/Utilities/PoleFigureCompositor.h | 8 +++++--- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/Source/EbsdLib/Utilities/ColorTable.cpp b/Source/EbsdLib/Utilities/ColorTable.cpp index a97b46b..b28fb96 100644 --- a/Source/EbsdLib/Utilities/ColorTable.cpp +++ b/Source/EbsdLib/Utilities/ColorTable.cpp @@ -67,9 +67,9 @@ void EbsdColorTable::GetColorTable(int numColors, std::vector& colorsOut) const float nodeStepSize = 1.0F / (maxNodeIndex); for(int i = 0; i < numColors; i++) { - float pos = i * stepSize; // [0, 1] range + const float pos = static_cast(i) * stepSize; // [0, 1] range int currColorBin = static_cast(pos / nodeStepSize); - float currFraction = (pos / nodeStepSize) - currColorBin; + const float currFraction = (pos / nodeStepSize) - static_cast(currColorBin); float r; float g; diff --git a/Source/EbsdLib/Utilities/Fonts.cpp b/Source/EbsdLib/Utilities/Fonts.cpp index cfceee6..d17f659 100644 --- a/Source/EbsdLib/Utilities/Fonts.cpp +++ b/Source/EbsdLib/Utilities/Fonts.cpp @@ -10,21 +10,30 @@ namespace ebsdlib::fonts std::vector GetFiraSansRegular() { static std::vector fontData; - Base64Decode(fonts::k_FiraSansRegularBase64, fontData); + if(fontData.empty()) + { + Base64Decode(fonts::k_FiraSansRegularBase64, fontData); + } return fontData; } std::vector GetLatoRegular() { static std::vector fontData; - Base64Decode(fonts::k_LatoRegularBase64, fontData); + if(fontData.empty()) + { + Base64Decode(fonts::k_LatoRegularBase64, fontData); + } return fontData; } std::vector GetLatoBold() { static std::vector fontData; - Base64Decode(fonts::k_LatoBoldBase64, fontData); + if(fontData.empty()) + { + Base64Decode(fonts::k_LatoBoldBase64, fontData); + } return fontData; } diff --git a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp index 0f80e5b..186b768 100644 --- a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp +++ b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp @@ -1,6 +1,7 @@ #include "PoleFigureCompositor.h" #include "EbsdLib/LaueOps/LaueOps.h" +#include "EbsdLib/Math/EbsdLibMath.h" #include "EbsdLib/Utilities/CanvasUtilities.hpp" #include "EbsdLib/Utilities/ColorTable.h" #include "EbsdLib/Utilities/EbsdStringUtils.hpp" @@ -19,7 +20,7 @@ namespace ebsdlib { // ----------------------------------------------------------------------------- -CompositePoleFigureResult PoleFigureCompositor::generateCompositeImage(const CompositePoleFigureConfiguration_t& config) +CompositePoleFigureResult PoleFigureCompositor::generateCompositeImage(CompositePoleFigureConfiguration_t& config) { CompositePoleFigureResult result; @@ -104,7 +105,7 @@ LayoutMetrics PoleFigureCompositor::computeLayoutMetrics(const CompositePoleFigu } // ----------------------------------------------------------------------------- -std::vector PoleFigureCompositor::generatePoleFigures(const CompositePoleFigureConfiguration_t& config) +std::vector PoleFigureCompositor::generatePoleFigures(CompositePoleFigureConfiguration_t& config) { PoleFigureConfiguration_t pfConfig; pfConfig.eulers = config.eulers; @@ -128,7 +129,14 @@ std::vector PoleFigureCompositor::generatePoleFigures(c return {}; } - return orientationOps[config.laueOpsIndex]->generatePoleFigure(pfConfig); + auto result = orientationOps[config.laueOpsIndex]->generatePoleFigure(pfConfig); + + // LaueOps::generatePoleFigure updates minScale/maxScale to reflect the actual + // data range. Propagate these back so the scalar bar shows correct values. + config.minScale = pfConfig.minScale; + config.maxScale = pfConfig.maxScale; + + return result; } // ----------------------------------------------------------------------------- @@ -225,7 +233,7 @@ void PoleFigureCompositor::drawPoleFigure(canvas_ity::canvas& context, const UIn context.line_cap = canvas_ity::circle; context.set_line_width(3.0f); context.set_color(canvas_ity::stroke_style, 0.0f, 0.0f, 0.0f, 1.0f); - context.arc(origin[0] + margins + imageSize / 2.0f, origin[1] + fontPtSize * 2.0f + margins * 2.0f + imageSize / 2.0f, imageSize / 2.0f, 0, 2.0f * 3.14159265358979323846f); + context.arc(origin[0] + margins + imageSize / 2.0f, origin[1] + fontPtSize * 2.0f + margins * 2.0f + imageSize / 2.0f, imageSize / 2.0f, 0, ebsdlib::constants::k_2PiF); context.stroke(); context.close_path(); diff --git a/Source/EbsdLib/Utilities/PoleFigureCompositor.h b/Source/EbsdLib/Utilities/PoleFigureCompositor.h index 890ed12..cfa6f4d 100644 --- a/Source/EbsdLib/Utilities/PoleFigureCompositor.h +++ b/Source/EbsdLib/Utilities/PoleFigureCompositor.h @@ -158,10 +158,12 @@ class EbsdLib_EXPORT PoleFigureCompositor * direction labels, a legend/scalar bar, and a title, arranged according to * the specified layout type. * - * @param config Configuration controlling pole figure generation, layout, and appearance + * @param config Configuration controlling pole figure generation, layout, and appearance. + * Note: minScale and maxScale may be updated by the pole figure generation + * to reflect the actual data range. * @return CompositePoleFigureResult containing the RGBA image and its dimensions */ - CompositePoleFigureResult generateCompositeImage(const CompositePoleFigureConfiguration_t& config); + CompositePoleFigureResult generateCompositeImage(CompositePoleFigureConfiguration_t& config); /** * @brief Computes layout metrics without generating an image. @@ -175,7 +177,7 @@ class EbsdLib_EXPORT PoleFigureCompositor static LayoutMetrics computeLayoutMetrics(const CompositePoleFigureConfiguration_t& config); private: - std::vector generatePoleFigures(const CompositePoleFigureConfiguration_t& config); + std::vector generatePoleFigures(CompositePoleFigureConfiguration_t& config); void preprocessImages(std::vector& images, int imageDim, bool flipFinalImage); UInt8ArrayType::Pointer compositeToCanvas(const CompositePoleFigureConfiguration_t& config, const std::vector& images, const LayoutMetrics& layout); From 55c434a7a578231d04fe420fe7bfc5f312d68fbc Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 7 Apr 2026 22:01:35 -0400 Subject: [PATCH 5/8] VERS: Update version to 2.4.0 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f129585..d6071fd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,7 +37,7 @@ option(EbsdLib_BUILD_H5SUPPORT "Build H5Support Library" OFF) # set project's name -project(EbsdLibProj VERSION 2.3.0) +project(EbsdLibProj VERSION 2.4.0) # Request C++17 standard, using new CMake variables. From 3931e995ab2af8fb2a3702461cd2a89aee1649d7 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 7 Apr 2026 22:41:58 -0400 Subject: [PATCH 6/8] Add extra right margin to prevent X axis label clipping in Square layout The sub-canvas width now includes an additional margin between the pole figure image and the X axis label, preventing the label from being cut off at the right edge, particularly visible in Square layout. Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/EbsdLib/Utilities/PoleFigureCompositor.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp index 186b768..d71c70b 100644 --- a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp +++ b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp @@ -64,7 +64,8 @@ LayoutMetrics PoleFigureCompositor::computeLayoutMetrics(const CompositePoleFigu xCharWidth = tempContext.measure_text(buf.data()); } - metrics.subCanvasWidth = metrics.margins + imageWidth + xCharWidth + metrics.margins; + // Extra margin on the right to ensure the "X" axis label is not clipped + metrics.subCanvasWidth = metrics.margins + imageWidth + metrics.margins + xCharWidth + metrics.margins; metrics.subCanvasHeight = metrics.margins + metrics.fontPtSize + imageHeight + metrics.fontPtSize * 2.0f + metrics.margins * 2.0f; switch(config.layoutType) From 551b9de5bba4d5bd98b0292903040e2eabe7017c Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 8 Apr 2026 11:02:42 -0400 Subject: [PATCH 7/8] BUG: Fix bugs in Pole Figure unit test. Add exemplar images for Pole Figures Exemplars added for discrete pole figures. --- Source/Apps/generate_ipf_legends.cpp | 2 +- .../Utilities/PoleFigureCompositor.cpp | 13 +- .../EbsdLib/Utilities/PoleFigureCompositor.h | 36 ++-- Source/Test/CMakeLists.txt | 2 + Source/Test/CtfReaderTest.cpp | 2 +- Source/Test/IPFLegendTest.cpp | 2 +- Source/Test/PoleFigureCompositorTest.cpp | 161 +++++++++++++++++- Source/Test/TestFileLocations.h.in | 2 +- Source/Test/TiffWriterTest.cpp | 6 +- 9 files changed, 192 insertions(+), 34 deletions(-) diff --git a/Source/Apps/generate_ipf_legends.cpp b/Source/Apps/generate_ipf_legends.cpp index bf1e872..705f691 100644 --- a/Source/Apps/generate_ipf_legends.cpp +++ b/Source/Apps/generate_ipf_legends.cpp @@ -34,7 +34,7 @@ using namespace ebsdlib; // const std::string k_Output_Dir(ebsdlib::unit_test::DataDir + "/IPF_Legend/"); -const std::string k_Output_Dir(ebsdlib::unit_test::TestTempDir + "/IPF_Legend/"); +const std::string k_Output_Dir(ebsdlib::unit_test::k_TestTempDir + "/IPF_Legend/"); using EbsdDoubleArrayType = EbsdDataArray; using EbsdDoubleArrayPointerType = EbsdDoubleArrayType::Pointer; diff --git a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp index d71c70b..0d5f38b 100644 --- a/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp +++ b/Source/EbsdLib/Utilities/PoleFigureCompositor.cpp @@ -396,13 +396,12 @@ void PoleFigureCompositor::drawInfoBlock(canvas_ity::canvas& context, const Comp laueGroupName = laueNames[config.laueOpsIndex]; } - const std::vector labels = { - fmt::format("Phase Num: {}", config.phaseNumber), - fmt::format("Material Name: {}", config.phaseName), - fmt::format("Laue Group: {}", laueGroupName), - fmt::format("Upper & Lower:"), - fmt::format("Samples: {}", config.eulers != nullptr ? config.eulers->getNumberOfTuples() : 0), - fmt::format("Lambert Sq. Dim: {}", config.lambertDim)}; + const std::vector labels = {fmt::format("Phase Num: {}", config.phaseNumber), + fmt::format("Material Name: {}", config.phaseName), + fmt::format("Laue Group: {}", laueGroupName), + fmt::format("Upper & Lower:"), + fmt::format("Samples: {}", config.eulers != nullptr ? config.eulers->getNumberOfTuples() : 0), + fmt::format("Lambert Sq. Dim: {}", config.lambertDim)}; float heightInc = 1.0f; for(const auto& label : labels) diff --git a/Source/EbsdLib/Utilities/PoleFigureCompositor.h b/Source/EbsdLib/Utilities/PoleFigureCompositor.h index cfa6f4d..d20deff 100644 --- a/Source/EbsdLib/Utilities/PoleFigureCompositor.h +++ b/Source/EbsdLib/Utilities/PoleFigureCompositor.h @@ -66,26 +66,26 @@ enum class PoleFigureLayoutType : uint32_t struct EbsdLib_EXPORT CompositePoleFigureConfiguration_t { // --- Pole figure generation parameters --- - ebsdlib::FloatArrayType* eulers = nullptr; ///< Euler angles in radians (3-component tuples) - int imageDim = 512; ///< Height/width of each individual pole figure in pixels - int lambertDim = 256; ///< Lambert square dimension for interpolation - int numColors = 32; ///< Number of colors in the color table - double minScale = 0.0; ///< Minimum intensity scale value - double maxScale = 1.0; ///< Maximum intensity scale value - float sphereRadius = 1.0f; ///< Sphere radius (should always be 1.0) - bool discrete = false; ///< Use discrete point sampling instead of Lambert projection - bool discreteHeatMap = false; ///< Use heat map coloring for discrete mode - std::string colorMap; ///< Name of the color map to use - std::vector labels; ///< Labels for the 3 pole figures (e.g., "<001>", "<011>", "<111>") + ebsdlib::FloatArrayType* eulers = nullptr; ///< Euler angles in radians (3-component tuples) + int imageDim = 512; ///< Height/width of each individual pole figure in pixels + int lambertDim = 256; ///< Lambert square dimension for interpolation + int numColors = 32; ///< Number of colors in the color table + double minScale = 0.0; ///< Minimum intensity scale value + double maxScale = 1.0; ///< Maximum intensity scale value + float sphereRadius = 1.0f; ///< Sphere radius (should always be 1.0) + bool discrete = false; ///< Use discrete point sampling instead of Lambert projection + bool discreteHeatMap = false; ///< Use heat map coloring for discrete mode + std::string colorMap; ///< Name of the color map to use + std::vector labels; ///< Labels for the 3 pole figures (e.g., "<001>", "<011>", "<111>") std::vector order = {0, 1, 2}; ///< Display order of the 3 pole figures - bool flipFinalImage = true; ///< Flip individual images so +Y points up + bool flipFinalImage = true; ///< Flip individual images so +Y points up // --- Composition parameters --- PoleFigureLayoutType layoutType = PoleFigureLayoutType::Horizontal; ///< How to arrange figures and legend - uint32_t laueOpsIndex = 0; ///< Index into LaueOps::GetAllOrientationOps() - std::string phaseName; ///< Material/phase name for the legend - int32_t phaseNumber = 1; ///< Phase number for the legend - std::string title; ///< Title text drawn at the top of the composite image + uint32_t laueOpsIndex = 0; ///< Index into LaueOps::GetAllOrientationOps() + std::string phaseName; ///< Material/phase name for the legend + int32_t phaseNumber = 1; ///< Phase number for the legend + std::string title; ///< Title text drawn at the top of the composite image }; /** @@ -94,8 +94,8 @@ struct EbsdLib_EXPORT CompositePoleFigureConfiguration_t struct EbsdLib_EXPORT CompositePoleFigureResult { UInt8ArrayType::Pointer image; ///< RGBA image data (4 components per tuple, row-major order) - int32_t height = 0; ///< Image height in pixels (rows, slow dimension) - int32_t width = 0; ///< Image width in pixels (cols, fast dimension) + int32_t height = 0; ///< Image height in pixels (rows, slow dimension) + int32_t width = 0; ///< Image width in pixels (cols, fast dimension) }; /** diff --git a/Source/Test/CMakeLists.txt b/Source/Test/CMakeLists.txt index 4b269fb..019289c 100644 --- a/Source/Test/CMakeLists.txt +++ b/Source/Test/CMakeLists.txt @@ -3,6 +3,7 @@ set(UNIT_TEST_TARGET EbsdLibUnitTest) set(EBSDLIB_TEST_DIRS_HEADER ${EbsdLibProj_BINARY_DIR}/EbsdLib/Test/EbsdLibTestFileLocations.h) configure_file(${EbsdLibProj_SOURCE_DIR}/Source/Test/TestFileLocations.h.in ${EBSDLIB_TEST_DIRS_HEADER} @ONLY) +file(MAKE_DIRECTORY ${EbsdLibProj_BINARY_DIR}/Testing/Temporary/) #------------------------------------------------------------------------------ # Find the Catch2 unit testing package find_package(Catch2 CONFIG REQUIRED) @@ -78,6 +79,7 @@ if(EBSDLIB_DOWNLOAD_TEST_FILES) endif() ebsdlib_download_test_data(EBSDLIB_DATA_DIR ${EBSDLIB_DATA_DIR} ARCHIVE_NAME Laue_Orientation_Clusters_v6.tar.gz SHA512 f327d3d2a86d539b3c1f3fc755d8f5741d8eb68aab45fc1ab54d9e5db48643903f9a37898366203e6eb2e7585ce57c6e186cca2107acb1a53318b813345cb10a) + ebsdlib_download_test_data(EBSDLIB_DATA_DIR ${EBSDLIB_DATA_DIR} ARCHIVE_NAME Pole_Figure_Images.tar.gz SHA512 c3111bd3dcb89ec3ddcfcee4792bb7a9a1815475ac3f9a70bd93915c2a6dbbdcc9091dfe5ddb064a655758d77f9718c1661dd2005bcc60502ba336e40eb205a1) endif() diff --git a/Source/Test/CtfReaderTest.cpp b/Source/Test/CtfReaderTest.cpp index e170e86..505aff5 100644 --- a/Source/Test/CtfReaderTest.cpp +++ b/Source/Test/CtfReaderTest.cpp @@ -205,7 +205,7 @@ TEST_CASE("ebsdlib::CtfReaderTest::TestWriteCtfFile", "[EbsdLib][CtfReaderTest]" } std::stringstream filePath; - filePath << ebsdlib::unit_test::TestTempDir << "/CTF_WriteFile_test.ctf"; + filePath << ebsdlib::unit_test::k_TestTempDir << "/CTF_WriteFile_test.ctf"; err = reader.writeFile(filePath.str()); DREAM3D_REQUIRE(err == 0); if(REMOVE_TEST_FILES == 1) diff --git a/Source/Test/IPFLegendTest.cpp b/Source/Test/IPFLegendTest.cpp index e703866..5fc9b49 100644 --- a/Source/Test/IPFLegendTest.cpp +++ b/Source/Test/IPFLegendTest.cpp @@ -60,7 +60,7 @@ TEST_CASE("ebsdlib::IPFLegendTest", "[EbsdLib][IPFLegendTest]") { ebsdlib::UInt8ArrayType::Pointer image = ops[index]->generateIPFTriangleLegend(IMAGE_WIDTH, false); std::stringstream outputFilePathStream; - outputFilePathStream << ebsdlib::unit_test::TestTempDir << "/" << ops[index]->getNameOfClass() << ".tiff"; + outputFilePathStream << ebsdlib::unit_test::k_TestTempDir << "/" << ops[index]->getNameOfClass() << ".tiff"; auto result = TiffWriter::WriteColorImage(outputFilePathStream.str(), IMAGE_WIDTH, IMAGE_WIDTH, 3, image->data()); REQUIRE(result.first == 0); } diff --git a/Source/Test/PoleFigureCompositorTest.cpp b/Source/Test/PoleFigureCompositorTest.cpp index 7df0ff7..8a25a05 100644 --- a/Source/Test/PoleFigureCompositorTest.cpp +++ b/Source/Test/PoleFigureCompositorTest.cpp @@ -31,7 +31,26 @@ #include #include "EbsdLib/Core/EbsdDataArray.hpp" +#include "EbsdLib/LaueOps/LaueOps.h" +#include "EbsdLib/Test/EbsdLibTestFileLocations.h" #include "EbsdLib/Utilities/PoleFigureCompositor.h" +#include "EbsdLib/Utilities/TiffWriter.h" +#include "UnitTestCommon.hpp" +#include "UnitTestSupport.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include using namespace ebsdlib; @@ -63,6 +82,144 @@ TEST_CASE("ebsdlib::PoleFigureCompositorTest::ConfigDefaults", "[EbsdLib][PoleFi REQUIRE(config.title.empty()); } +void GeneratePoleFigures(const std::string& phaseName, size_t opsIndex, hid_t exemplarFileId) +{ + constexpr size_t k_NumSamplingGroups = 8; + constexpr size_t k_NumQuats = 10000; + constexpr size_t k_QuatSize = 4; + const std::string distributionType("WAT"); + + std::string inputFilePath = fmt::format("{}/Laue_Orientation_Clusters_v6/{}.h5", ebsdlib::unit_test::k_TestFilesDir, phaseName); + hid_t fid = H5Support::H5Utilities::openFile(inputFilePath, true); + REQUIRE(fid > 0); + H5Support::H5ScopedFileSentinel fileSentinel(fid, false); + + std::string prefix = (distributionType == "VMF") ? "vMF" : "WAT"; + std::vector quatarray; + herr_t err = H5Support::H5Lite::readVectorDataset(fid, fmt::format("/EMData/Sampler/{}quatarray", prefix), quatarray); + REQUIRE(err == 0); + + std::vector ops = LaueOps::GetAllOrientationOps(); + LaueOps::Pointer op = ops[opsIndex]; + + std::vector layoutTypes = {PoleFigureLayoutType::Horizontal, PoleFigureLayoutType::Vertical, PoleFigureLayoutType::Square}; + for(const auto& layoutType : layoutTypes) + { + std::string layoutStr = (layoutType == PoleFigureLayoutType::Horizontal) ? "Horz" : (layoutType == PoleFigureLayoutType::Vertical) ? "Vert" : "Sqr"; + hid_t layoutGroupId = H5Support::H5Utilities::createGroup(exemplarFileId, layoutStr); + H5Support::H5ScopedGroupSentinel layoutGroupSentinel(layoutGroupId, true); + + for(size_t sampleId = 0; sampleId < k_NumSamplingGroups; ++sampleId) + { + std::vector compDims = {3}; + auto eulers = FloatArrayType::CreateArray(k_NumQuats, compDims, "TestEulers", true); + + // Generate Euler Angles from Quaternions in the file + for(size_t quatIdx = 0; quatIdx < k_NumQuats; ++quatIdx) + { + // HDF5 stores quaternions as WXYZ (EMsoft), convert to XYZW (EbsdLib) + size_t idx = (sampleId * k_NumQuats * k_QuatSize) + (quatIdx * k_QuatSize); + QuatD q(quatarray[idx + 1], quatarray[idx + 2], quatarray[idx + 3], quatarray[idx]); + q = op->getFZQuat(q); + EulerDType euler = q.toEuler(); + + // Assign Euler Angles + (*eulers)[quatIdx * 3] = static_cast(euler[0]); + (*eulers)[quatIdx * 3 + 1] = static_cast(euler[1]); + (*eulers)[quatIdx * 3 + 2] = static_cast(euler[2]); + } + + CompositePoleFigureConfiguration_t config; + config.eulers = eulers.get(); + config.imageDim = 512; + config.lambertDim = 32; + config.numColors = 16; + config.discrete = true; + config.discreteHeatMap = false; + config.flipFinalImage = true; + config.laueOpsIndex = opsIndex; + config.layoutType = layoutType; + config.phaseName = "TestPhase"; + config.phaseNumber = 1; + config.title = fmt::format("Laue Symmetry:{} Rotation Point Group: {}", op->getSymmetryName(), op->getRotationPointGroup()); + + PoleFigureCompositor compositor; + CompositePoleFigureResult result = compositor.generateCompositeImage(config); + + REQUIRE(result.image != nullptr); + REQUIRE(result.width > 0); + REQUIRE(result.height > 0); + REQUIRE(result.image->getNumberOfComponents() == 4); + REQUIRE(result.image->getNumberOfTuples() == static_cast(result.width * result.height)); + + LayoutMetrics metrics = PoleFigureCompositor::computeLayoutMetrics(config); + REQUIRE(result.width == metrics.pageWidth); + REQUIRE(result.height == metrics.pageHeight); + + UInt8ArrayType::Pointer image = result.image; + // std::string outputPath = fmt::format("{}/Pole_Figure_Images/Pole_Figure_{}_{}_{}.tif", ebsdlib::unit_test::k_TestFilesDir, layoutStr,op->getRotationPointGroup() , sampleId); + // auto writerResult = TiffWriter::WriteColorImage(outputPath, result.width, result.height, 4, result.image->data()); + // REQUIRE(writerResult.first == 0); + // + std::string datasetName = fmt::format("{}", sampleId); + // std::vector dims = {static_cast(result.height), static_cast(result.width), 4ULL}; + // herr_t err = H5Support::H5Lite::writePointerDataset(layoutGroupId, datasetName, dims.size(), dims.data(), result.image->data()); + // REQUIRE(err == 0); + std::vector exemplarData; + err = H5Support::H5Lite::readVectorDataset(layoutGroupId, datasetName, exemplarData); + REQUIRE(err == 0); + REQUIRE(exemplarData.size() == static_cast(result.width * result.height * 4)); + for(size_t i = 0; i < exemplarData.size(); i++) + { + REQUIRE(exemplarData[i] == (*image)[i]); + } + } + } +} + +// ----------------------------------------------------------------------------- +TEST_CASE("ebsdlib::PoleFigureCompositorTest::All_Laue_Classes", "[EbsdLib][PoleFigureCompositorTest]") +{ + const ebsdlib::unit_test::TestFileSentinel testDataSentinel(ebsdlib::unit_test::k_TestFilesDir, "Laue_Orientation_Clusters_v6.tar.gz", "Laue_Orientation_Clusters_v6", true, true); + const ebsdlib::unit_test::TestFileSentinel testDataSentinel1(ebsdlib::unit_test::k_TestFilesDir, "Pole_Figure_Images.tar.gz", "Pole_Figure_Images", true, true); + + const std::string hdfInputFile = fmt::format("{}/Pole_Figure_Images/Exemplar_Data.h5", ebsdlib::unit_test::k_TestFilesDir); + hid_t fileId = -1; + // if(!std::filesystem::exists(hdfInputFile)) + // { + // fileId = H5Support::H5Utilities::createFile(hdfInputFile); + // } + // else + { + fileId = H5Support::H5Utilities::openFile(hdfInputFile, true); + } + H5Support::H5ScopedFileSentinel fileSentinel(fileId, true); + + std::vector ops = LaueOps::GetAllOrientationOps(); + + std::set tested; + for(size_t opsIdx = 0; opsIdx < ops.size(); opsIdx++) + { + LaueOps::Pointer op = ops[opsIdx]; + const std::string rpg = op->getRotationPointGroup(); + // Skip Triclinic (no FZ boundary) and duplicates (OrthoRhombicOps appears twice) + if(rpg == "1" || tested.count(rpg) > 0) + { + continue; + } + tested.insert(rpg); + + hid_t layoutGroupId = H5Support::H5Utilities::createGroup(fileId, rpg); + H5Support::H5ScopedGroupSentinel layoutGroupSentinel(layoutGroupId, true); + + const std::string phaseName = fmt::format("Laue_{}", rpg); + SECTION(phaseName + " VMF") + { + GeneratePoleFigures(phaseName, opsIdx, layoutGroupId); + } + } +} + // ----------------------------------------------------------------------------- TEST_CASE("ebsdlib::PoleFigureCompositorTest::LayoutMetrics_Horizontal", "[EbsdLib][PoleFigureCompositorTest]") { @@ -73,8 +230,8 @@ TEST_CASE("ebsdlib::PoleFigureCompositorTest::LayoutMetrics_Horizontal", "[EbsdL LayoutMetrics metrics = PoleFigureCompositor::computeLayoutMetrics(config); const float imageDim = 256.0f; - const float expectedFontPtSize = imageDim / 16.0f; // 16.0f - const float expectedMargins = imageDim / 32.0f; // 8.0f + const float expectedFontPtSize = imageDim / 16.0f; // 16.0f + const float expectedMargins = imageDim / 32.0f; // 8.0f REQUIRE(metrics.fontPtSize == Approx(expectedFontPtSize)); REQUIRE(metrics.margins == Approx(expectedMargins)); diff --git a/Source/Test/TestFileLocations.h.in b/Source/Test/TestFileLocations.h.in index b88869a..a83bdc3 100644 --- a/Source/Test/TestFileLocations.h.in +++ b/Source/Test/TestFileLocations.h.in @@ -50,7 +50,7 @@ namespace ebsdlib::unit_test { const std::string DataDir("@EbsdLibProj_SOURCE_DIR@/Data/"); -const std::string TestTempDir("@EbsdLibProj_BINARY_DIR@/Testing/Temporary/"); +const std::string k_TestTempDir("@EbsdLibProj_BINARY_DIR@/Testing/Temporary/"); const std::string EbsdLibProjDir("@EbsdLibProj_SOURCE_DIR@"); const std::string k_TestFilesDir = "@EBSDLIB_DATA_DIR_NORM@/TestFiles"; diff --git a/Source/Test/TiffWriterTest.cpp b/Source/Test/TiffWriterTest.cpp index 26b4728..2630e58 100644 --- a/Source/Test/TiffWriterTest.cpp +++ b/Source/Test/TiffWriterTest.cpp @@ -13,7 +13,7 @@ namespace fs = std::filesystem; // ----------------------------------------------------------------------------- TEST_CASE("ebsdlib::TiffWriterTest::WriteColorImage_RGB", "[EbsdLib][TiffWriterTest]") { - std::string outputPath = ebsdlib::unit_test::TestTempDir + "TiffWriterTest_RGB.tif"; + std::string outputPath = ebsdlib::unit_test::k_TestTempDir + "TiffWriterTest_RGB.tif"; int32_t width = 4; int32_t height = 4; @@ -41,7 +41,7 @@ TEST_CASE("ebsdlib::TiffWriterTest::WriteColorImage_RGB", "[EbsdLib][TiffWriterT // ----------------------------------------------------------------------------- TEST_CASE("ebsdlib::TiffWriterTest::WriteColorImage_RGBA", "[EbsdLib][TiffWriterTest]") { - std::string outputPath = ebsdlib::unit_test::TestTempDir + "TiffWriterTest_RGBA.tif"; + std::string outputPath = ebsdlib::unit_test::k_TestTempDir + "TiffWriterTest_RGBA.tif"; int32_t width = 4; int32_t height = 4; @@ -70,7 +70,7 @@ TEST_CASE("ebsdlib::TiffWriterTest::WriteColorImage_RGBA", "[EbsdLib][TiffWriter // ----------------------------------------------------------------------------- TEST_CASE("ebsdlib::TiffWriterTest::WriteGrayScaleImage", "[EbsdLib][TiffWriterTest]") { - std::string outputPath = ebsdlib::unit_test::TestTempDir + "TiffWriterTest_Gray.tif"; + std::string outputPath = ebsdlib::unit_test::k_TestTempDir + "TiffWriterTest_Gray.tif"; int32_t width = 4; int32_t height = 4; From d6f889b91e41dbbd992eb03efdc86df04aba68a6 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Thu, 9 Apr 2026 14:08:12 -0400 Subject: [PATCH 8/8] TEST: fix failing unit tests --- CMakePresets.json | 2 +- Source/Test/CMakeLists.txt | 2 +- Source/Test/PoleFigureCompositorTest.cpp | 92 +++++++++++++++++++----- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index 8f4fd6b..33226c9 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -147,7 +147,7 @@ }, "CMAKE_BUILD_TYPE": { "type": "STRING", - "value": "Debug" + "value": "Release" } } }, diff --git a/Source/Test/CMakeLists.txt b/Source/Test/CMakeLists.txt index 019289c..c0754d1 100644 --- a/Source/Test/CMakeLists.txt +++ b/Source/Test/CMakeLists.txt @@ -79,7 +79,7 @@ if(EBSDLIB_DOWNLOAD_TEST_FILES) endif() ebsdlib_download_test_data(EBSDLIB_DATA_DIR ${EBSDLIB_DATA_DIR} ARCHIVE_NAME Laue_Orientation_Clusters_v6.tar.gz SHA512 f327d3d2a86d539b3c1f3fc755d8f5741d8eb68aab45fc1ab54d9e5db48643903f9a37898366203e6eb2e7585ce57c6e186cca2107acb1a53318b813345cb10a) - ebsdlib_download_test_data(EBSDLIB_DATA_DIR ${EBSDLIB_DATA_DIR} ARCHIVE_NAME Pole_Figure_Images.tar.gz SHA512 c3111bd3dcb89ec3ddcfcee4792bb7a9a1815475ac3f9a70bd93915c2a6dbbdcc9091dfe5ddb064a655758d77f9718c1661dd2005bcc60502ba336e40eb205a1) + ebsdlib_download_test_data(EBSDLIB_DATA_DIR ${EBSDLIB_DATA_DIR} ARCHIVE_NAME Pole_Figure_Images.tar.gz SHA512 fe395dbc05c5408e806b6271873a21a9cef343c940126fed682233b21a793d6e62b3d33a4ac2a77bf5789c514187a595798e823f1f1fd7f71360fdea6e6500e8) endif() diff --git a/Source/Test/PoleFigureCompositorTest.cpp b/Source/Test/PoleFigureCompositorTest.cpp index 8a25a05..2ff4153 100644 --- a/Source/Test/PoleFigureCompositorTest.cpp +++ b/Source/Test/PoleFigureCompositorTest.cpp @@ -28,6 +28,42 @@ * * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ + +/** +Test result: 39 mismatched pixels in Debug mode (confirmed Release passes). + + Root Cause: Floating-point non-determinism in canvas_ity rendering + + The issue is not UB from float-to-uint8 casts. I've traced the full data flow and the values are properly bounded. The real cause is + floating-point precision differences between -O0 (Debug) and -O2 (Release) in the canvas_ity rendering pipeline. + + Key areas where Debug/Release produce different float results: + + 1. canvas_ity.hpp:2195-2218 — Bicubic image resampling in paint_pixel(): cubic polynomial evaluation, weighted accumulation, and division. In + Release, the compiler may use FMA (fused multiply-add) instructions which have different rounding than separate multiply+add in Debug. + 2. canvas_ity.hpp:2452-2454 — Compositing/blending: rgba blend = mix_fore * fore + mix_back * back — multiple float multiply-adds sensitive to + optimization. + 3. canvas_ity.hpp:3075 — Bayer dithering + sRGB conversion: When 255.0f * delinearized_value + bayer_threshold lands close to an integer + boundary (e.g., 182.99999 vs 183.00001), the static_cast truncation gives different results between Debug and Release. + + I verified the float-to-unsigned-char cast (canvas_ity.hpp:3076-3079) is NOT UB because: + - clamped() constrains to [0.0, 1.0] + - delinearized() maps [0,1] → [0, ~1.0] + - * 255.0f → [0, ~255.0], plus Bayer threshold (max 0.97) → max ~255.97 + - Truncation to 255, which is in range for unsigned char + + I also verified PoleFigureUtilities.cpp:170 (static_cast(r * 255.0f)) — this casts to int (not uint8), and dRgb masks with & 0xff, so it's + well-defined regardless. + + The real problem: byte-exact test comparison + + The test at PoleFigureCompositorTest.cpp:180 does: + if(exemplarData[i] != (*image)[i]) + + This requires bit-exact reproduction across optimization levels, which floating-point math doesn't guarantee. + +*/ + #include #include "EbsdLib/Core/EbsdDataArray.hpp" @@ -107,6 +143,7 @@ void GeneratePoleFigures(const std::string& phaseName, size_t opsIndex, hid_t ex { std::string layoutStr = (layoutType == PoleFigureLayoutType::Horizontal) ? "Horz" : (layoutType == PoleFigureLayoutType::Vertical) ? "Vert" : "Sqr"; hid_t layoutGroupId = H5Support::H5Utilities::createGroup(exemplarFileId, layoutStr); + REQUIRE(layoutGroupId > 0); H5Support::H5ScopedGroupSentinel layoutGroupSentinel(layoutGroupId, true); for(size_t sampleId = 0; sampleId < k_NumSamplingGroups; ++sampleId) @@ -157,22 +194,32 @@ void GeneratePoleFigures(const std::string& phaseName, size_t opsIndex, hid_t ex REQUIRE(result.height == metrics.pageHeight); UInt8ArrayType::Pointer image = result.image; - // std::string outputPath = fmt::format("{}/Pole_Figure_Images/Pole_Figure_{}_{}_{}.tif", ebsdlib::unit_test::k_TestFilesDir, layoutStr,op->getRotationPointGroup() , sampleId); - // auto writerResult = TiffWriter::WriteColorImage(outputPath, result.width, result.height, 4, result.image->data()); - // REQUIRE(writerResult.first == 0); - // std::string datasetName = fmt::format("{}", sampleId); - // std::vector dims = {static_cast(result.height), static_cast(result.width), 4ULL}; - // herr_t err = H5Support::H5Lite::writePointerDataset(layoutGroupId, datasetName, dims.size(), dims.data(), result.image->data()); - // REQUIRE(err == 0); +#if WRITE_EXEMPLAR_IMAGES + std::string outputPath = fmt::format("{}/Pole_Figure_Images/Pole_Figure_{}_{}_{}.tif", ebsdlib::unit_test::k_TestFilesDir, layoutStr,op->getRotationPointGroup() , sampleId); + auto writerResult = TiffWriter::WriteColorImage(outputPath, result.width, result.height, 4, result.image->data()); + REQUIRE(writerResult.first == 0); + // + + std::vector dims = {static_cast(result.height), static_cast(result.width), 4ULL}; + herr_t err = H5Support::H5Lite::writePointerDataset(layoutGroupId, datasetName, dims.size(), dims.data(), result.image->data()); + REQUIRE(err == 0); +#else + std::vector exemplarData; err = H5Support::H5Lite::readVectorDataset(layoutGroupId, datasetName, exemplarData); REQUIRE(err == 0); REQUIRE(exemplarData.size() == static_cast(result.width * result.height * 4)); + size_t misMatchCount = 0; for(size_t i = 0; i < exemplarData.size(); i++) { - REQUIRE(exemplarData[i] == (*image)[i]); + if(std::abs(static_cast(exemplarData[i]) - static_cast((*image)[i])) > 1) + { + misMatchCount++; + } } + REQUIRE(misMatchCount == 0); +#endif } } } @@ -181,19 +228,29 @@ void GeneratePoleFigures(const std::string& phaseName, size_t opsIndex, hid_t ex TEST_CASE("ebsdlib::PoleFigureCompositorTest::All_Laue_Classes", "[EbsdLib][PoleFigureCompositorTest]") { const ebsdlib::unit_test::TestFileSentinel testDataSentinel(ebsdlib::unit_test::k_TestFilesDir, "Laue_Orientation_Clusters_v6.tar.gz", "Laue_Orientation_Clusters_v6", true, true); - const ebsdlib::unit_test::TestFileSentinel testDataSentinel1(ebsdlib::unit_test::k_TestFilesDir, "Pole_Figure_Images.tar.gz", "Pole_Figure_Images", true, true); + const ebsdlib::unit_test::TestFileSentinel testDataSentinel1(ebsdlib::unit_test::k_TestFilesDir, "Pole_Figure_Images.tar.gz", "Pole_Figure_Images" +#if WRITE_EXEMPLAR_IMAGES + , false, false +#endif + ); const std::string hdfInputFile = fmt::format("{}/Pole_Figure_Images/Exemplar_Data.h5", ebsdlib::unit_test::k_TestFilesDir); hid_t fileId = -1; - // if(!std::filesystem::exists(hdfInputFile)) - // { - // fileId = H5Support::H5Utilities::createFile(hdfInputFile); - // } - // else +#if WRITE_EXEMPLAR_IMAGES + if(!std::filesystem::exists(hdfInputFile)) { + std::cout << "Creating " << hdfInputFile << std::endl; + fileId = H5Support::H5Utilities::createFile(hdfInputFile); + } + else +#else + { + std::cout << "Opening " << hdfInputFile << std::endl; fileId = H5Support::H5Utilities::openFile(hdfInputFile, true); } - H5Support::H5ScopedFileSentinel fileSentinel(fileId, true); +#endif + REQUIRE(fileId > 0); + H5Support::H5ScopedFileSentinel fileSentinel(fileId, false); std::vector ops = LaueOps::GetAllOrientationOps(); @@ -210,10 +267,13 @@ TEST_CASE("ebsdlib::PoleFigureCompositorTest::All_Laue_Classes", "[EbsdLib][Pole tested.insert(rpg); hid_t layoutGroupId = H5Support::H5Utilities::createGroup(fileId, rpg); - H5Support::H5ScopedGroupSentinel layoutGroupSentinel(layoutGroupId, true); + REQUIRE(layoutGroupId > 0); + H5Support::H5ScopedGroupSentinel layoutGroupSentinel(layoutGroupId, false); const std::string phaseName = fmt::format("Laue_{}", rpg); +#if !WRITE_EXEMPLAR_IMAGES SECTION(phaseName + " VMF") +#endif { GeneratePoleFigures(phaseName, opsIdx, layoutGroupId); }