302 lines
13 KiB
C++
302 lines
13 KiB
C++
#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 <cmath> // For pow, round, floor
|
|
#include <vector>
|
|
#include <cstdint>
|
|
#include <algorithm> // For std::max, std::min
|
|
|
|
#define IMGUI_DEFINE_MATH_OPERATORS // Allows ImVec2 operators
|
|
// --- Graphics API ---
|
|
#if defined(IMGUI_IMPL_OPENGL_ES2)
|
|
#include <SDL_opengles2.h>
|
|
#else
|
|
#include <SDL_opengl.h>
|
|
#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<int>(appImage.getWidth());
|
|
const int height = static_cast<int>(appImage.getHeight());
|
|
const int channels = static_cast<int>(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<unsigned char> textureData(static_cast<size_t>(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<unsigned char>(std::max(0, std::min(255, static_cast<int>(ImGuiImageViewerUtil::Round(sr*255.f)))));
|
|
*outPtr++=static_cast<unsigned char>(std::max(0, std::min(255, static_cast<int>(ImGuiImageViewerUtil::Round(sg*255.f)))));
|
|
*outPtr++=static_cast<unsigned char>(std::max(0, std::min(255, static_cast<int>(ImGuiImageViewerUtil::Round(sb*255.f)))));
|
|
*outPtr++=static_cast<unsigned char>(std::max(0, std::min(255, static_cast<int>(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<float>(m_textureWidth) / static_cast<float>(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
|