Skip to content

Instantly share code, notes, and snippets.

@kudaba
Last active June 7, 2020 04:36
Show Gist options
  • Save kudaba/a553ca56eb528a867868fdf974c92c16 to your computer and use it in GitHub Desktop.
Save kudaba/a553ca56eb528a867868fdf974c92c16 to your computer and use it in GitHub Desktop.
Virtual canvas utility class.
#include "ImguiCanvas.h"
#define IMGUI_DEFINE_MATH_OPERATORS
#include <imgui_internal.h>
// Not true epsilon, this one is much more useful
constexpr float ImEpsilonf = 0.00001f;
//-------------------------------------------------------------------------------------------------
// Return -1 or +1 (0 returns +1)
//-------------------------------------------------------------------------------------------------
template<typename Type>
inline Type ImSign(Type const& aValue)
{
return Type((aValue >= 0) - (aValue < 0));
}
//-------------------------------------------------------------------------------------------------
// Smooth step converts a linear curve to an s curve, great for animations!
// https://en.wikipedia.org/wiki/Smoothstep
//-------------------------------------------------------------------------------------------------
template <class Type>
inline Type ImSmoothStep(Type const& aValue)
{
Type x = ImSaturate(aValue);
return x * x*(3 - 2 * x);
}
//-------------------------------------------------------------------------------------------------
//-------------------------------------------------------------------------------------------------
bool ImPointOnLine(ImVec2 const& aPoint, ImVec2 const& aStart, ImVec2 const& anEnd, float aMinDistance)
{
IM_ASSERT(aMinDistance >= 0);
ImVec2 startToPoint = aPoint - aStart;
ImVec2 startToEnd = anEnd - aStart;
float lineLengthSqr = ImLengthSqr(startToEnd);
if (lineLengthSqr < ImEpsilonf)
{
aMinDistance += ImEpsilonf;
// distance to point
float distanceSqr = ImLengthSqr(startToPoint);
return distanceSqr <= aMinDistance * aMinDistance;
}
startToEnd /= ImSqrt(lineLengthSqr);
ImVec2 startToPointOnLine = startToEnd * ImDot(startToPoint, startToEnd);
float distanceToPointOnLineSqr = ImLengthSqr(startToPointOnLine);
if (distanceToPointOnLineSqr > lineLengthSqr)
return false;
ImVec2 distanceToPoint = startToPoint - startToPointOnLine;
float distanceToPointSqr = ImLengthSqr(distanceToPoint);
aMinDistance += ImEpsilonf;
return distanceToPointSqr <= aMinDistance * aMinDistance;
}
//-------------------------------------------------------------------------------------------------
// Unlerp will give you at what point the input is relative to the start and end points
// I.e. give the start 0 and end 4, 2 would be at the halfway point (0.5) between them.
//-------------------------------------------------------------------------------------------------
float ImUnlerp(float aValue, float aStart, float anEnd)
{
float range = anEnd - aStart;
return ImFabs(range) > ImEpsilonf ? (aValue - aStart) / range : 0.f;
}
//-------------------------------------------------------------------------------------------------
// Remap will convert a value in the input range to a value in the output range at the same point
// GC_Remap(50, 0, 100, 20, 30) will return 25
//-------------------------------------------------------------------------------------------------
template <typename Type1, typename Type2>
Type2 ImRemap(Type1 const& aValue, Type1 const& anInStart, Type1 const& anInEnd, Type2 const& anOutStart, Type2 const& anOutEnd)
{
return ImLerp(anOutStart, anOutEnd, ImUnlerp(aValue, anInStart, anInEnd));
}
//-------------------------------------------------------------------------------------------------
//-------------------------------------------------------------------------------------------------
ImCanvas::ImCanvas(float gridSize)
: MouseCapture(0, 0)
, IsMouseCaptured(false)
, PixelOffset(0, 0)
, ScrollInterpolation(-1)
, Zoom(1.0f)
, GridSize(gridSize)
, LineWidth(1)
, LineWidthThick(3)
{
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::Update(bool checkPrimaryMouse, float deltaTime)
{
WindowMin = ImGui::GetWindowPos() + ImGui::GetWindowContentRegionMin();
WindowMax = ImGui::GetWindowPos() + ImGui::GetWindowContentRegionMax();
UpdateZoom();
UpdateScroll(checkPrimaryMouse, deltaTime);
VirtualMin = PixelOffset / -Zoom;
VirtualMax = ((WindowMax - WindowMin) - PixelOffset) / Zoom;
float dpiScale = ImGui::GetWindowViewport()->DpiScale;
if (!dpiScale) dpiScale = 1;
LineWidth = dpiScale;
LineWidthThick = dpiScale * 3;
}
//-------------------------------------------------------------------------------------------------
bool ImCanvas::IsHovered() const
{
ImVec2 mousePos = ImGui::GetMousePos();
return ImGui::IsWindowHovered() && ImRect(WindowMin, WindowMax).Contains(mousePos);
}
//-------------------------------------------------------------------------------------------------
bool ImCanvas::IsVisible(ImVec2 start, ImVec2 end) const
{
// AABB Test
if (end.x < VirtualMin.x || end.y < VirtualMin.y ||
start.x > VirtualMax.x || start.y > VirtualMax.y)
return false;
return true;
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::DrawGrid()
{
// Ideal pixels between lines
float gridSizeScaled = GridSize * LineWidth;
float gridSizeZoomed = gridSizeScaled * Zoom;
while (gridSizeZoomed >= gridSizeScaled) gridSizeZoomed /= 2;
while (gridSizeZoomed < gridSizeScaled / 2) gridSizeZoomed *= 2;
IM_ASSERT(gridSizeZoomed > 5); // Grid is too condensed, use a bigger size
// draw two grids that scale in color and size
if (gridSizeZoomed > gridSizeScaled)
DrawGrid(gridSizeZoomed / 2, gridSizeScaled);
DrawGrid(gridSizeZoomed, gridSizeScaled);
if (gridSizeZoomed < gridSizeScaled)
DrawGrid(gridSizeZoomed * 2, gridSizeScaled);
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::DrawLine(ImU32 color, ImVec2 start, ImVec2 end, LineType type) const
{
DrawLine(color, start, end, type, false, nullptr);
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::DrawLineHovered(ImU32 color, ImVec2 start, ImVec2 end, LineType type) const
{
DrawLine(color, start, end, type, true, nullptr);
}
//-------------------------------------------------------------------------------------------------
bool ImCanvas::DrawLineCheckHover(ImU32 color, ImVec2 start, ImVec2 end, LineType type) const
{
bool isHovered = false;
DrawLine(color, start, end, type, false, &isHovered);
return isHovered;
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::DrawLine(ImU32 color, ImVec2 start, ImVec2 end, LineType type, bool isHovered, bool* checkHover) const
{
if (!ImRect(VirtualMin, VirtualMax).Overlaps(ImRect(start, end)))
return;
ImVec2 s = ToScreenSpace(start);
ImVec2 e = ToScreenSpace(end);
uint const MaxPoints = 40;
ImVec2 points[MaxPoints];
points[0] = s;
uint pointCount = 0;
switch (type)
{
// simple straight line
case Linear:
{
points[1] = e;
pointCount = 2;
break;
}
// ---
// |
// ---
case Stepped:
{
float mid = ImLerp(s.x, e.x, 0.5f);
points[1] = { mid, s.y };
points[2] = { mid, e.y };
points[3] = e;
pointCount = 4;
break;
}
// Normal s-curve
case Bezier:
{
float const step = 1.0f / MaxPoints;
float f = 0;
for (uint i = 0; i < MaxPoints - 1; ++i, f += step)
points[i] = { ImLerp(s.x, e.x, f), ImLerp(s.y, e.y, ImSmoothStep(f)) };
points[MaxPoints - 1] = e;
pointCount = MaxPoints;
break;
}
// --\
// |
// \--
case SteppedBezier:
{
ImVec2 distance = e - s;
ImVec2 mid = s + distance * 0.5f;
ImVec2 absDistance = { ImFabs(distance.x), ImFabs(distance.y) };
uint const curveSize = (MaxPoints - 6) / 2;
uint curve1;
uint curve2;
float const curveLength = (absDistance.x <= absDistance.y ? absDistance.x : absDistance.y) / 2;
float const curveLengthX = curveLength * ImSign(distance.x);
float const curveLengthY = curveLength * ImSign(distance.y);
if (absDistance.x <= absDistance.y)
{
// tall curves have 4 control points that are the curve end points
// 1\
// 2
// |
// 3
// \4
curve1 = 0;
curve2 = curveSize + 1;
points[curveSize] = { mid.x, s.y + curveLengthY };
points[curve2] = { mid.x, e.y - curveLengthY };
points[curve2 + curveSize] = e;
pointCount = curve2 + curveSize + 1;
}
else
{
// wide curves have 5 control points
// 1--2\
// 3
// \4--5
curve1 = 1;
curve2 = 1 + curveSize;
points[1] = { mid.x - curveLengthX, s.y };
points[curve1 + curveSize] = mid;
points[curve1 + curveSize * 2] = { mid.x + curveLengthX, e.y };
points[curve1 + curveSize * 2 + 1] = e;
pointCount = curve1 + curveSize * 2 + 2;
}
// lookup table for the y of the curve given the x
static float yLookup[curveSize];
static bool yLookupGen;
if (!yLookupGen)
{
for (uint i = 0; i < curveSize; ++i)
{
float x = (float)i / curveSize;
yLookup[i] = 1.0f - ImSqrt(1.f - x * x);
}
}
IM_ASSERT(pointCount <= MaxPoints);
IM_ASSERT(curve1 <= curve2 - curveSize);
IM_ASSERT(curve2 <= MaxPoints - curveSize);
float const step = 1.0f / curveSize;
float stepX = step;
for (uint i = 1; i < curveSize; ++i, stepX += step)
{
ImVec2 curveOffset(stepX * curveLengthX, yLookup[i] * curveLengthY);
points[curve1 + i] = points[curve1] + curveOffset;
points[curve2 + (curveSize - i)] = points[curve2 + curveSize] - curveOffset;
}
break;
}
default:
IM_ASSERT(false); // unreachable
}
IM_ASSERT(pointCount);
if (checkHover)
{
ImVec2 mpos = ImGui::GetMousePos();
isHovered = false;
for (uint i = 1; i < pointCount && !isHovered; ++i)
isHovered = ImPointOnLine(mpos, points[i - 1], points[i], LineWidthThick);
*checkHover = isHovered;
}
ImGui::GetWindowDrawList()->AddPolyline(points, pointCount, color, false, isHovered ? LineWidthThick : LineWidth);
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::DrawRect(ImU32 color, ImVec2 start, ImVec2 end, float rounding) const
{
ImVec2 s = ToScreenSpace(start);
ImVec2 e = ToScreenSpace(end);
ImGui::GetWindowDrawList()->AddRectFilled(s, e, color, rounding);
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::DrawRectOutline(ImU32 color, ImVec2 start, ImVec2 end, float rounding, float thickness) const
{
ImVec2 s = ToScreenSpace(start);
ImVec2 e = ToScreenSpace(end);
ImGui::GetWindowDrawList()->AddRect(s, e, color, rounding, ImDrawCornerFlags_All, thickness);
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::DrawText(char const* text, ImU32 color, ImVec2 pos) const
{
ImVec2 s = ToScreenSpace(pos);
ImGui::GetWindowDrawList()->AddText(ImGui::GetFont(), ImGui::GetFontSize() * Zoom, s, color, text);
}
//-------------------------------------------------------------------------------------------------
ImVec2 ImCanvas::ToVirtualSpace(ImVec2 screenPos) const
{
return (screenPos - (PixelOffset + WindowMin)) / Zoom;
}
//-------------------------------------------------------------------------------------------------
ImVec2 ImCanvas::ToScreenSpace(ImVec2 virtualPos) const
{
return virtualPos * Zoom + PixelOffset + WindowMin;
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::ScrollTo(ImVec2 virtualPos, float overTime)
{
ImVec2 halfSize = (WindowMax - WindowMin) * 0.5f;
ImVec2 targetOffset = (virtualPos * Zoom - halfSize) * -1;
if (overTime > 0)
{
// Start an animated scroll
ScrollSourcePosition = PixelOffset;
ScrollTargetPosition = targetOffset;
ScrollTime = overTime;
ScrollInterpolation = 0;
}
else
{
SetOffset(targetOffset);
}
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::SetOffset(ImVec2 offset)
{
PixelOffset = offset;
VirtualMin = PixelOffset / -Zoom;
VirtualMax = ((WindowMax - WindowMin) - PixelOffset) / Zoom;
}
//-------------------------------------------------------------------------------------------------
// Draw grid lines to help navigate the virtual area
//-------------------------------------------------------------------------------------------------
void ImCanvas::DrawGrid(float gridSizeZoomed, float scale) const
{
// Scale color give better zoom motion, closer to ideal size it more opaque
float alpha = 1 - ImFabs(ImRemap(gridSizeZoomed, scale / 2, scale * 2, -1.f, 1.f));
if (alpha < 0)
return;
ImColor color = ImVec4(1, 1, 1, alpha);
// Need to offset one when hitting negative coordinates
ImVec2 windowOffset(fmod(PixelOffset.x, gridSizeZoomed), fmod(PixelOffset.y, gridSizeZoomed));
if (windowOffset.x < 0) windowOffset.x += gridSizeZoomed;
if (windowOffset.y < 0) windowOffset.y += gridSizeZoomed;
for (float i = WindowMin.x + windowOffset.x; i < WindowMax.x; i += gridSizeZoomed)
ImGui::GetWindowDrawList()->AddLine({ i, WindowMin.y }, { i, WindowMax.y }, color);
for (float i = WindowMin.y + windowOffset.y; i < WindowMax.y; i += gridSizeZoomed)
ImGui::GetWindowDrawList()->AddLine({ WindowMin.x, i }, { WindowMax.x, i }, color);
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::UpdateZoom()
{
float const zoomMin = 0.1f;
float const zoomMax = 2.f;
float const zoomMinLog = logf(zoomMin);
float const zoomMaxLog = logf(zoomMax);
float const zoomSteps = 30;
if (ImGui::IsWindowHovered())
{
float logZoom = logf(Zoom);
float newLogZoom = logZoom + (zoomMaxLog - zoomMinLog) / zoomSteps * ImGui::GetIO().InputCurrentFrame->MouseWheel;
float newZoom = ImClamp(expf(newLogZoom), zoomMin, zoomMax);
if (newZoom != Zoom)
{
// Adjust offset to make sure we're zooming on mouse point
ImVec2 mpos = ImGui::GetMousePos() - WindowMin;
PixelOffset = ((PixelOffset - mpos) / Zoom) * newZoom + mpos;
Zoom = newZoom;
}
}
}
//-------------------------------------------------------------------------------------------------
void ImCanvas::UpdateScroll(bool checkPrimaryMouse, float deltaTime)
{
if (!IsMouseCaptured)
{
if (IsHovered() && ((checkPrimaryMouse && ImGui::GetIO().MouseClicked[0]) || ImGui::GetIO().MouseClicked[2]))
{
MouseCapture = ImGui::GetMousePos();
PixelOffsetCapture = PixelOffset;
IsMouseCaptured = true;
}
}
else if (!ImGui::GetIO().MouseDown[0] && !ImGui::GetIO().MouseDown[2])
IsMouseCaptured = false;
if (IsMouseCaptured)
{
PixelOffset = PixelOffsetCapture + ImGui::GetMousePos() - MouseCapture;
ScrollInterpolation = -1;
}
else if (deltaTime > 0 && ScrollInterpolation >= 0 && ScrollInterpolation <= 1)
{
ScrollInterpolation += deltaTime / ScrollTime;
if (ScrollInterpolation >= 1)
{
PixelOffset = ScrollTargetPosition;
ScrollInterpolation = -1;
}
else
{
// Scroll using the upper half of a smooth step function to give fast motion to start that slows down as we approach the target
float const smoothStepPos = 0.5f + (ScrollInterpolation * 0.5f);
float const smoothStepValue = smoothStepPos * smoothStepPos * (3 - 2 * smoothStepPos);
float const finalInterpolation = (smoothStepValue - 0.5f) * 2;
PixelOffset = ImLerp(ScrollSourcePosition, ScrollTargetPosition, finalInterpolation);
}
}
}
#pragma once
#include <imgui.h>
//-------------------------------------------------------------------------------------------------
// Helper for managing an unbounded virtual area. Includes mouse interaction, zoom and draw helpers.
//-------------------------------------------------------------------------------------------------
struct ImCanvas
{
enum LineType { Linear, Stepped, Bezier, SteppedBezier };
ImCanvas(float gridSize = 50);
// Call once per frame. Updates mouse state and drawing constants
void Update(bool checkPrimaryMouse, float deltaTime);
// Check that it's window is hovered and that the mouse is in the canvas area
bool IsHovered() const;
// No drawing, just does a cull check against the area and returns true if valid to draw (no End required)
bool IsVisible(ImVec2 start, ImVec2 end) const;
// Draw an even spaced grid for users to reference position and scale
void DrawGrid();
// Simply draw a line
void DrawLine(ImU32 color, ImVec2 start, ImVec2 end, LineType type = Linear) const;
// Draw a line hovered (i.e. thick)
void DrawLineHovered(ImU32 color, ImVec2 start, ImVec2 end, LineType type = Linear) const;
// Draw a line while checking for hover status, prevents having to generate line multiple times
bool DrawLineCheckHover(ImU32 color, ImVec2 start, ImVec2 end, LineType type = Linear) const;
// The following assume they are being used on a widget so no additional culling is performed
void DrawRect(ImU32 color, ImVec2 start, ImVec2 end, float rounding = 0) const;
void DrawRectOutline(ImU32 color, ImVec2 start, ImVec2 end, float rounding = 0, float thickness = 1) const;
void DrawText(char const* text, ImU32 color, ImVec2 pos) const;
ImVec2 ToVirtualSpace(ImVec2 screenPos) const;
ImVec2 ToScreenSpace(ImVec2 virtualPos) const;
void ScrollTo(ImVec2 virtualPos, float overTime = 0.4f); // Make the input position centered
void SetOffset(ImVec2 offset);
float GetZoom() const { return Zoom; }
ImVec2 GetOffset() const { return PixelOffset; }
ImVec2 GetVirtualMin() const { return VirtualMin; }
ImVec2 GetVirtualMax() const { return VirtualMax; }
private:
void DrawLine(ImU32 color, ImVec2 start, ImVec2 end, LineType type, bool isHovered, bool* checkHover) const;
void DrawGrid(float gridSizeZoomed, float scale) const;
void UpdateZoom();
void UpdateScroll(bool checkPrimaryMouse, float deltaTime);
ImVec2 MouseCapture;
ImVec2 PixelOffsetCapture;
bool IsMouseCaptured;
ImVec2 PixelOffset;
ImVec2 WindowMin;
ImVec2 WindowMax;
ImVec2 VirtualMin;
ImVec2 VirtualMax;
// Animated scrolling
ImVec2 ScrollSourcePosition;
ImVec2 ScrollTargetPosition;
float ScrollInterpolation;
float ScrollTime;
float Zoom;
float GridSize;
float LineWidth;
float LineWidthThick;
};
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImGuiColors::Dark_grey);
ImGui::Begin("Canvas", nullptr, { 300, 300 }, 1.0f);
static ImCanvas canvas;
canvas.Update(true, dt);
if (canvas.IsVisible({ 100, 100 }, { 200, 200 }))
canvas.DrawRect(ImColor(0.f, 0.f, 1.f), { 100, 100 }, { 200, 200 });
ImGui::BeginChild("debug", { 200, 80 }, true);
ImGui::Text("%dx%d", (int)canvas.GetOffset().x, (int)canvas.GetOffset().y);
ImGui::Text("%.1f%%", canvas.GetZoom()*100);
ImGui::End();
ImGui::End();
ImGui::PopStyleColor();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment