#ifndef IMGUI_IMAGE_VIEWER_H #define IMGUI_IMAGE_VIEWER_H #include "app_image.h" // Needs the AppImage class definition #include "imgui.h" #define IMGUI_DEFINE_MATH_OPERATORS // Allows ImVec2 operators #include "imgui_internal.h" // Need ImFloorSigned, ImClamp, ImMax, ImMin, ImAbs #include // For pow, round, floor #include #include #include // For std::max, std::min #define IMGUI_DEFINE_MATH_OPERATORS // Allows ImVec2 operators // --- Graphics API --- #if defined(IMGUI_IMPL_OPENGL_ES2) #include #else #include #endif // --- User Instructions --- // (Same as before: Include after dependencies, define IMPLEMENTATION once) class ImGuiImageViewer { public: ImGuiImageViewer(); ~ImGuiImageViewer(); ImGuiImageViewer(const ImGuiImageViewer&) = delete; ImGuiImageViewer& operator=(const ImGuiImageViewer&) = delete; bool LoadImage(const AppImage& appImage); void UnloadImage(); // Renders within the available space, maintaining aspect ratio. void Render(const ImVec2& availableSize); void ResetView(); // Resets zoom to 100%, centers view // --- Getters --- float GetZoom() const { return m_zoom; } // Pan offset returns the center of the view in UV coordinates (0.0-1.0) ImVec2 GetPanOffsetUV() const { return m_panOffsetUV; } bool IsImageLoaded() const { return m_textureId != 0; } // Get the raw OpenGL texture ID for external use GLuint GetTextureId() const { return m_textureId; } private: GLuint m_textureId = 0; int m_textureWidth = 0; int m_textureHeight = 0; float m_zoom = 1.0f; // Scale factor (1.0 = 100%) // Pan offset: Center of the view in UV coordinates (0.0 to 1.0) ImVec2 m_panOffsetUV = ImVec2(0.5f, 0.5f); // Interaction state bool m_isDragging = false; ImVec2 m_dragStartMousePos = ImVec2(0, 0); ImVec2 m_dragStartPanOffsetUV = ImVec2(0, 0); // Configuration const float m_minZoom = 0.01f; // 1% const float m_maxZoom = 50.0f; // 5000% const float m_zoomSpeed = 1.1f; // Clamps pan offset (UV) based on the container size and zoom. void ClampPanOffset(const ImVec2& containerSize); }; // ============================================================================ // =================== IMPLEMENTATION SECTION ================================= // ============================================================================ #ifdef IMGUI_IMAGE_VIEWER_IMPLEMENTATION // --- Helper namespace --- namespace ImGuiImageViewerUtil { // Linear float [0,1+] -> sRGB approx [0,1] inline float linear_to_srgb_approx(float linearVal) { if (linearVal <= 0.0f) return 0.0f; linearVal = std::fmax(0.0f, std::fmin(1.0f, linearVal)); // Clamp for display if (linearVal <= 0.0031308f) { return linearVal * 12.92f; } else { return 1.055f * std::pow(linearVal, 1.0f / 2.4f) - 0.055f; } } // Round float to nearest integer inline float Round(float f) { return ImFloor(f + 0.5f); } } // namespace ImGuiImageViewerUtil ImGuiImageViewer::ImGuiImageViewer() {} ImGuiImageViewer::~ImGuiImageViewer() { UnloadImage(); } void ImGuiImageViewer::UnloadImage() { if (m_textureId != 0) { glDeleteTextures(1, &m_textureId); m_textureId = 0; m_textureWidth = 0; m_textureHeight = 0; ResetView(); } } bool ImGuiImageViewer::LoadImage(const AppImage& appImage) { // --- Basic Error Checking --- if (appImage.isEmpty() || appImage.getWidth() == 0 || appImage.getHeight() == 0) { UnloadImage(); return false; } if (!appImage.isLinear()) { /* Log warning */ } const int width = static_cast(appImage.getWidth()); const int height = static_cast(appImage.getHeight()); const int channels = static_cast(appImage.getChannels()); if (channels != 1 && channels != 3 && channels != 4) { UnloadImage(); return false; } const float* linearData = appImage.getData(); // --- Prepare 8-bit sRGB data for OpenGL (RGBA format) --- GLenum internalFormat = GL_RGBA8; GLenum dataFormat = GL_RGBA; int outputChannels = 4; std::vector textureData(static_cast(width) * height * outputChannels); unsigned char* outPtr = textureData.data(); const float* inPtr = linearData; for (int y = 0; y < height; ++y) { for (int x = 0; x < width; ++x) { float r=0,g=0,b=0,a=1; if(channels==1){r=g=b=*inPtr++;} else if(channels==3){r=*inPtr++;g=*inPtr++;b=*inPtr++;} else{r=*inPtr++;g=*inPtr++;b=*inPtr++;a=*inPtr++;} float sr=ImGuiImageViewerUtil::linear_to_srgb_approx(r), sg=ImGuiImageViewerUtil::linear_to_srgb_approx(g), sb=ImGuiImageViewerUtil::linear_to_srgb_approx(b); a=std::fmax(0.f,std::fmin(1.f,a)); *outPtr++=static_cast(std::max(0, std::min(255, static_cast(ImGuiImageViewerUtil::Round(sr*255.f))))); *outPtr++=static_cast(std::max(0, std::min(255, static_cast(ImGuiImageViewerUtil::Round(sg*255.f))))); *outPtr++=static_cast(std::max(0, std::min(255, static_cast(ImGuiImageViewerUtil::Round(sb*255.f))))); *outPtr++=static_cast(std::max(0, std::min(255, static_cast(ImGuiImageViewerUtil::Round(a*255.f))))); } } // --- Upload to OpenGL Texture --- GLint lastTexture; glGetIntegerv(GL_TEXTURE_BINDING_2D, &lastTexture); if (m_textureId == 0) { glGenTextures(1, &m_textureId); } glBindTexture(GL_TEXTURE_2D, m_textureId); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); // No filtering glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); // No filtering glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); glTexImage2D(GL_TEXTURE_2D, 0, internalFormat, width, height, 0, dataFormat, GL_UNSIGNED_BYTE, textureData.data()); glBindTexture(GL_TEXTURE_2D, lastTexture); m_textureWidth = width; m_textureHeight = height; ResetView(); // Reset view when loading a new image return true; } void ImGuiImageViewer::ResetView() { m_zoom = 1.0f; m_panOffsetUV = ImVec2(0.5f, 0.5f); // Center view m_isDragging = false; } // Clamp pan offset (UV coordinates) void ImGuiImageViewer::ClampPanOffset(const ImVec2& containerSize) { if (m_textureWidth <= 0 || m_textureHeight <= 0 || m_zoom <= 0) return; // Size of the image content projected onto the screen at current zoom ImVec2 zoomedImageScreenSize = ImVec2(m_textureWidth * m_zoom, m_textureHeight * m_zoom); // Calculate the size of the visible area in UV coordinates ImVec2 viewSizeUV = containerSize / zoomedImageScreenSize; // If zoom=1, size=texSize, this is containerSize/texSize viewSizeUV.x = ImMin(viewSizeUV.x, 1.0f); // Cannot see more than 1.0 UV width viewSizeUV.y = ImMin(viewSizeUV.y, 1.0f); // Cannot see more than 1.0 UV height // Calculate min/max allowed pan values (center of view in UV space) ImVec2 minPanUV, maxPanUV; // If the zoomed image is smaller than the container, center it. // The pan offset (center of view) should be locked to 0.5 UV. if (zoomedImageScreenSize.x <= containerSize.x) { minPanUV.x = maxPanUV.x = 0.5f; } else { // Image is wider: Allow panning. // Min pan: Center of view is half the view-width (in UV) from the left edge (UV=0) minPanUV.x = viewSizeUV.x * 0.5f; // Max pan: Center of view is half the view-width (in UV) from the right edge (UV=1) maxPanUV.x = 1.0f - viewSizeUV.x * 0.5f; } if (zoomedImageScreenSize.y <= containerSize.y) { // Image is shorter: Center it. minPanUV.y = maxPanUV.y = 0.5f; } else { // Image is taller: Allow panning. minPanUV.y = viewSizeUV.y * 0.5f; maxPanUV.y = 1.0f - viewSizeUV.y * 0.5f; } // Apply clamping m_panOffsetUV.x = ImClamp(m_panOffsetUV.x, minPanUV.x, maxPanUV.x); m_panOffsetUV.y = ImClamp(m_panOffsetUV.y, minPanUV.y, maxPanUV.y); } void ImGuiImageViewer::Render(const ImVec2& availableSize) { if (m_textureId == 0 || m_textureWidth <= 0 || m_textureHeight <= 0) { ImGui::Dummy(availableSize); return; } ImGuiIO& io = ImGui::GetIO(); ImGuiStyle& style = ImGui::GetStyle(); // Begin a child window to establish a canvas with a background ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.1f, 0.1f, 0.1f, 1.0f)); // Dark background for contrast ImGui::BeginChild("ImageViewerCanvas", availableSize, false, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration); ImVec2 containerSize = ImGui::GetContentRegionAvail(); if (containerSize.x < 1.0f) containerSize.x = 1.0f; if (containerSize.y < 1.0f) containerSize.y = 1.0f; ImVec2 containerTopLeftScreen = ImGui::GetCursorScreenPos(); // Calculate aspect-correct display size and padding within the container float imageAspect = static_cast(m_textureWidth) / static_cast(m_textureHeight); float containerAspect = containerSize.x / containerSize.y; ImVec2 displaySize = containerSize; // Final screen size of the image content ImVec2 displayPadding = ImVec2(0, 0); // Padding for letter/pillarboxing if (containerAspect > imageAspect) { // Letterbox: Container wider than image displaySize.x = containerSize.y * imageAspect; displayPadding.x = (containerSize.x - displaySize.x) * 0.5f; } else { // Pillarbox: Container taller than image (or same aspect) displaySize.y = containerSize.x / imageAspect; displayPadding.y = (containerSize.y - displaySize.y) * 0.5f; } // Round padding to avoid pixel jitter displayPadding.x = ImFloor(displayPadding.x); displayPadding.y = ImFloor(displayPadding.y); displaySize = containerSize - displayPadding * 2.0f; // Recalculate based on rounded padding // Screen position where the image content rendering starts ImVec2 displayTopLeftScreen = containerTopLeftScreen + displayPadding; // Invisible button covering the *entire container* for capturing inputs ImGui::SetCursorScreenPos(containerTopLeftScreen); ImGui::InvisibleButton("##canvas_interaction", containerSize); bool isHoveredOnCanvas = ImGui::IsItemHovered(); bool isActiveOnCanvas = ImGui::IsItemActive(); // Calculate mouse position relative to the *display area* top-left corner ImVec2 mousePosDisplay = io.MousePos - displayTopLeftScreen; // Check if the mouse is actually over the image content, not the padding bool isMouseInDisplayArea = (mousePosDisplay.x >= 0.0f && mousePosDisplay.x < displaySize.x && mousePosDisplay.y >= 0.0f && mousePosDisplay.y < displaySize.y); // 1. Zooming (centered on mouse, only when hovering the image content) if (isHoveredOnCanvas && isMouseInDisplayArea && io.MouseWheel != 0.0f) { // Mouse position relative to display center, in screen pixels ImVec2 mouseRelCenter = mousePosDisplay - displaySize * 0.5f; // Convert mouse position to UV coordinates *before* zoom change ImVec2 mouseUV = m_panOffsetUV + mouseRelCenter / (ImVec2((float)m_textureWidth, (float)m_textureHeight) * m_zoom); float oldZoom = m_zoom; m_zoom *= std::pow(m_zoomSpeed, io.MouseWheel); m_zoom = ImClamp(m_zoom, m_minZoom, m_maxZoom); // Clamp zoom // Keep the same UV coordinate under the mouse cursor after zoom // NewPan = MouseUV - (MouseScreenPosRelCenter / NewZoomedImageScreenSize) m_panOffsetUV = mouseUV - mouseRelCenter / (ImVec2((float)m_textureWidth, (float)m_textureHeight) * m_zoom); ClampPanOffset(containerSize); // Re-clamp after zoom } // 2. Panning (allow dragging anywhere on the canvas) if (isActiveOnCanvas && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { if (!m_isDragging) { m_isDragging = true; m_dragStartMousePos = io.MousePos; m_dragStartPanOffsetUV = m_panOffsetUV; } ImVec2 mouseDelta = io.MousePos - m_dragStartMousePos; // Convert screen pixel delta to UV delta ImVec2 uvDelta = mouseDelta / (ImVec2((float)m_textureWidth, (float)m_textureHeight) * m_zoom); m_panOffsetUV = m_dragStartPanOffsetUV - uvDelta; // Subtract because dragging right moves content left (reduces UV offset) ClampPanOffset(containerSize); // Clamp during drag } else if (m_isDragging) { m_isDragging = false; } // --- UV Calculation --- // Calculate the size of the viewable rectangle in UV coordinates ImVec2 viewSizeUV = displaySize / (ImVec2((float)m_textureWidth, (float)m_textureHeight) * m_zoom); // Calculate top-left (uv0) and bottom-right (uv1) UV coordinates based on the centered pan offset ImVec2 uv0 = m_panOffsetUV - viewSizeUV * 0.5f; ImVec2 uv1 = m_panOffsetUV + viewSizeUV * 0.5f; // --- Draw the Image --- ImGui::SetCursorScreenPos(displayTopLeftScreen); // Position cursor for image drawing ImGui::Image((ImTextureID)(intptr_t)m_textureId, displaySize, uv0, uv1); ImGui::EndChild(); ImGui::PopStyleColor(); // Pop ChildBg color } #endif // IMGUI_IMAGE_VIEWER_IMPLEMENTATION #endif // IMGUI_IMAGE_VIEWER_H