From ab16908ac9af02f6b1825c1c78afcb9c33a6ab87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Wed, 18 Mar 2026 11:58:46 -0700 Subject: [PATCH] add split viewport support with independent cameras Adds support for split viewports with independent per-viewport camera state and arbitrary grid layouts (1x1, 1x2, 2x1, 2x2). Each viewport owns its own scene framebuffers, swapped with the engine during rendering so all existing rendering paths work without modification. Addresses #387. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/demo-app/demo_app.cpp | 18 ++ include/polyscope/context.h | 11 + include/polyscope/polyscope.h | 21 ++ include/polyscope/viewport.h | 120 +++++++++++ src/CMakeLists.txt | 2 + src/polyscope.cpp | 358 ++++++++++++++++++++++++++++++++- src/view.cpp | 11 + src/viewport.cpp | 261 ++++++++++++++++++++++++ test/CMakeLists.txt | 1 + test/src/viewport_test.cpp | 307 ++++++++++++++++++++++++++++ 10 files changed, 1100 insertions(+), 10 deletions(-) create mode 100644 include/polyscope/viewport.h create mode 100644 src/viewport.cpp create mode 100644 test/src/viewport_test.cpp diff --git a/examples/demo-app/demo_app.cpp b/examples/demo-app/demo_app.cpp index afe26351..b7a35252 100644 --- a/examples/demo-app/demo_app.cpp +++ b/examples/demo-app/demo_app.cpp @@ -16,6 +16,7 @@ #include "polyscope/surface_mesh.h" #include "polyscope/types.h" #include "polyscope/view.h" +#include "polyscope/viewport.h" #include "polyscope/volume_grid.h" #include "polyscope/volume_mesh.h" @@ -954,6 +955,23 @@ void callback() { addSparseVolumeGrid(); } + // Split viewport controls (per-viewport settings are in the built-in View panel) + if (ImGui::Button("Single")) { + polyscope::setSingleViewport(); + } + ImGui::SameLine(); + if (ImGui::Button("V-Split")) { + polyscope::setVerticalSplitViewport(); + } + ImGui::SameLine(); + if (ImGui::Button("H-Split")) { + polyscope::setHorizontalSplitViewport(); + } + ImGui::SameLine(); + if (ImGui::Button("Quad")) { + polyscope::setQuadViewport(); + } + // ImPlot // dummy data if (ImGui::TreeNode("ImPlot")) { diff --git a/include/polyscope/context.h b/include/polyscope/context.h index 9a339bf2..26e32967 100644 --- a/include/polyscope/context.h +++ b/include/polyscope/context.h @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -119,6 +120,16 @@ struct Context { bool pointCloudEfficiencyWarningReported = false; FloatingQuantityStructure* globalFloatingQuantityStructure = nullptr; + // ====================================================== + // === Viewport grid + // ====================================================== + + int viewportGridRows = 1; + int viewportGridCols = 1; + std::vector> viewports; + Viewport* activeViewport = nullptr; // the viewport currently receiving mouse input (null when no button held) + Viewport* lastActiveViewport = nullptr; // the most recently interacted viewport (persists after mouse release) + // ====================================================== // === Other various global lists // ====================================================== diff --git a/include/polyscope/polyscope.h b/include/polyscope/polyscope.h index d228c316..2833b056 100644 --- a/include/polyscope/polyscope.h +++ b/include/polyscope/polyscope.h @@ -21,6 +21,7 @@ #include "polyscope/structure.h" #include "polyscope/transformation_gizmo.h" #include "polyscope/utilities.h" +#include "polyscope/viewport.h" #include "polyscope/weak_handle.h" #include "polyscope/widget.h" @@ -196,6 +197,26 @@ void buildPickGui(); void buildUserGuiAndInvokeCallback(); +// === Viewport management + +// Set the viewport grid layout (e.g. 1x1, 1x2, 2x1, 2x2) +void setViewportGridLayout(int rows, int cols); +int getViewportGridRows(); +int getViewportGridCols(); + +// Convenience presets +void setSingleViewport(); +void setVerticalSplitViewport(); // 1 row, 2 columns (left | right) +void setHorizontalSplitViewport(); // 2 rows, 1 column (top / bottom) +void setQuadViewport(); // 2 rows, 2 columns + +// Get a specific viewport (nullptr if out of range) +Viewport* getViewport(int row, int col); +Viewport* getActiveViewport(); + +// Internal: update viewport layouts after window resize +void updateViewportLayouts(); + // === Utility // Execute one iteration of the main loop diff --git a/include/polyscope/viewport.h b/include/polyscope/viewport.h new file mode 100644 index 00000000..9be10228 --- /dev/null +++ b/include/polyscope/viewport.h @@ -0,0 +1,120 @@ +// Copyright 2017-2023, Nicholas Sharp and the Polyscope contributors. https://polyscope.run + +#pragma once + +#include +#include +#include + +#include "polyscope/types.h" +#include "polyscope/view.h" + +namespace polyscope { + +// Forward declarations +namespace render { +class FrameBuffer; +class TextureBuffer; +class ShaderProgram; +} // namespace render + +// A snapshot of the global view state, used for push/pop when rendering viewports +struct ViewStateSnapshot { + glm::mat4x4 viewMat; + float fov; + glm::vec3 viewCenter; + NavigateStyle navigateStyle; + UpDir upDir; + FrontDir frontDir; + ProjectionMode projectionMode; + float nearClip, farClip; + float moveScale; + ViewRelativeMode viewRelativeMode; + std::array bgColor; + int bufferWidth, bufferHeight; + int windowWidth, windowHeight; + + // Flight state + bool midflight; + float flightStartTime, flightEndTime; + glm::dualquat flightTargetViewR, flightInitialViewR; + glm::vec3 flightTargetViewT, flightInitialViewT; + float flightTargetFov, flightInitialFov; +}; + +// Save/restore the current global view state +ViewStateSnapshot saveViewState(); +void restoreViewState(const ViewStateSnapshot& snapshot); + + +class Viewport { +public: + Viewport(std::string name, int gridRow, int gridCol); + ~Viewport(); + + // === Identity + std::string name; + int gridRow, gridCol; + + // === Camera state (independent per viewport) + glm::mat4x4 viewMat; + float fov; + glm::vec3 viewCenter; + NavigateStyle navigateStyle; + UpDir upDir; + FrontDir frontDir; + ProjectionMode projectionMode; + float nearClip, farClip; + float moveScale; + ViewRelativeMode viewRelativeMode; + std::array bgColor; + + // Flight state + bool midflight; + float flightStartTime, flightEndTime; + glm::dualquat flightTargetViewR, flightInitialViewR; + glm::vec3 flightTargetViewT, flightInitialViewT; + float flightTargetFov, flightInitialFov; + + // === Pixel region (computed from grid layout + window size) + int pixelX, pixelY; // lower-left corner in buffer coords + int pixelWidth, pixelHeight; // size in buffer pixels + int windowX, windowY; // lower-left corner in window coords + int windowW, windowH; // size in window pixels + + // === Framebuffers (per-viewport) + std::shared_ptr sceneBuffer; + std::shared_ptr sceneBufferFinal; + std::shared_ptr sceneDepthMinFrame; + std::shared_ptr sceneColor, sceneColorFinal, sceneDepth, sceneDepthMin; + std::shared_ptr compositePeel; + + // === Methods + + // Push this viewport's camera state into the global view:: variables + void pushViewState(); + + // Store the current global view state back into this viewport + void pullViewState(); + + // Recompute pixel dimensions from grid layout and window size + void updateLayout(int totalBufferW, int totalBufferH, int totalWindowW, int totalWindowH, int gridRows, int gridCols); + + // Create or resize per-viewport framebuffers + void ensureBuffersAllocated(); + void resizeBuffers(); + + // Reset camera to the home view for this viewport + void resetCameraToHomeView(); + + // Set navigate style with proper camera adjustment (use instead of setting navigateStyle directly) + void setNavigateStyle(NavigateStyle newStyle, bool animateFlight = false); + + // Set up direction with proper camera adjustment (use instead of setting upDir directly) + void setUpDir(UpDir newUpDir, bool animateFlight = false); + + // Check if screen coordinates (in window space) fall within this viewport + bool containsScreenCoords(float x, float y) const; +}; + +} // namespace polyscope diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8b4eca3b..7725b57a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -165,6 +165,7 @@ SET(SRCS group.cpp utilities.cpp view.cpp + viewport.cpp screenshot.cpp messages.cpp pick.cpp @@ -352,6 +353,7 @@ SET(HEADERS ${INCLUDE_ROOT}/types.h ${INCLUDE_ROOT}/utilities.h ${INCLUDE_ROOT}/view.h + ${INCLUDE_ROOT}/viewport.h ${INCLUDE_ROOT}/vector_quantity.h ${INCLUDE_ROOT}/vector_quantity.ipp ${INCLUDE_ROOT}/volume_mesh.h diff --git a/src/polyscope.cpp b/src/polyscope.cpp index 0cd27c2f..133fd965 100644 --- a/src/polyscope.cpp +++ b/src/polyscope.cpp @@ -17,6 +17,7 @@ #include "polyscope/render/engine.h" #include "polyscope/utilities.h" #include "polyscope/view.h" +#include "polyscope/viewport.h" #include "stb_image.h" @@ -451,8 +452,19 @@ bool pendingPickActive = false; PickResult pendingPickResult; float pendingPickTime = 0.0f; +// Helper: find the viewport under the given screen coordinates +Viewport* findViewportAtScreenCoords(float x, float y) { + Context& ctx = state::globalContext; + for (auto& vp : ctx.viewports) { + if (vp->containsScreenCoords(x, y)) return vp.get(); + } + return nullptr; +} + + void processInputEvents() { ImGuiIO& io = ImGui::GetIO(); + Context& ctx = state::globalContext; // RECALL: in ImGUI language, on MacOS "ctrl" == "cmd", so all the options // below referring to ctrl really mean cmd on MacOS. @@ -462,6 +474,34 @@ void processInputEvents() { requestRedraw(); } + // === Multi-viewport: determine active viewport on mouse press + if (!ctx.viewports.empty()) { + if (ImGui::IsMouseClicked(0) || ImGui::IsMouseClicked(1)) { + // Only set active/last-active when clicking on the scene, not on ImGui panels + if (!io.WantCaptureMouse) { + Viewport* clicked = findViewportAtScreenCoords(io.MousePos.x, io.MousePos.y); + ctx.activeViewport = clicked; + if (clicked) { + ctx.lastActiveViewport = clicked; + } + } + } + if (!ImGui::IsAnyMouseDown()) { + ctx.activeViewport = nullptr; + } + } + + // If we have an active viewport, push its state into globals for input processing. + // Capture the pointer now — activeViewport can be cleared later in this function + // if the mouse button is released on the same frame. + ViewStateSnapshot savedState; + Viewport* pushedViewport = nullptr; + if (ctx.activeViewport) { + savedState = saveViewState(); + ctx.activeViewport->pushViewState(); + pushedViewport = ctx.activeViewport; + } + bool widgetCapturedMouse = false; // Handle scroll events for 3D view @@ -480,6 +520,24 @@ void processInputEvents() { // === Mouse inputs if (!io.WantCaptureMouse && !widgetCapturedMouse) { + // For multi-viewport: if no viewport is active (e.g. scroll without click), find the hovered one. + // Push camera state but preserve full window dimensions so scroll handlers behave consistently. + Viewport* hoveredViewport = nullptr; + if (!ctx.viewports.empty() && !ctx.activeViewport) { + hoveredViewport = findViewportAtScreenCoords(io.MousePos.x, io.MousePos.y); + if (hoveredViewport) { + if (!pushedViewport) { + savedState = saveViewState(); + } + hoveredViewport->pushViewState(); + // Restore full window dimensions so scroll handlers see consistent sizes + view::bufferWidth = savedState.bufferWidth; + view::bufferHeight = savedState.bufferHeight; + view::windowWidth = savedState.windowWidth; + view::windowHeight = savedState.windowHeight; + } + } + { // Process scroll via "mouse wheel" (which might be a touchpad) float xoffset = io.MouseWheelH; float yoffset = io.MouseWheel; @@ -504,6 +562,14 @@ void processInputEvents() { } } + // If we pushed a hovered viewport for scroll, pull state back + if (hoveredViewport) { + hoveredViewport->pullViewState(); + if (!pushedViewport) { + restoreViewState(savedState); + } + } + { // Process drags bool dragLeft = ImGui::IsMouseDragging(0); @@ -519,12 +585,21 @@ void processInputEvents() { bool isTranslate = (dragLeft && io.KeyShift && !io.KeyCtrl) || dragRight; bool isDragZoom = dragLeft && io.KeyShift && io.KeyCtrl; + // Compute viewport-local mouse position for rotation. + // When a viewport is active, view::windowWidth/Height are the viewport's dimensions, + // so io.MousePos (in full-window coords) must be offset to the viewport's origin. + float localMouseX = io.MousePos.x; + float localMouseY = io.MousePos.y; + if (ctx.activeViewport) { + localMouseX -= ctx.activeViewport->windowX; + localMouseY -= ctx.activeViewport->windowY; + } + if (isDragZoom) { view::processZoom(dragDelta.y * 5); } if (isRotate) { - glm::vec2 currPos{io.MousePos.x / view::windowWidth, - (view::windowHeight - io.MousePos.y) / view::windowHeight}; + glm::vec2 currPos{localMouseX / view::windowWidth, (view::windowHeight - localMouseY) / view::windowHeight}; currPos = (currPos * 2.0f) - glm::vec2{1.0, 1.0}; if (std::abs(currPos.x) <= 1.0 && std::abs(currPos.y) <= 1.0) { view::processRotate(currPos - 2.0f * dragDelta, currPos); @@ -556,12 +631,32 @@ void processInputEvents() { } } + // For pick/recenter: on the mouse-release frame, activeViewport is already null (cleared + // above when !IsAnyMouseDown). Use lastActiveViewport to push the correct camera state so + // the pick render uses the right projection, and convert screen coords to viewport-local. + // The push/pull is scoped to this block only. + ViewStateSnapshot pickSavedState; + bool didPushForPick = false; + Viewport* pickViewport = ctx.lastActiveViewport; + if (pickViewport && !pushedViewport) { + pickSavedState = saveViewState(); + pickViewport->pushViewState(); + didPushForPick = true; + } + + // Compute viewport-local screen coords for pick/recenter + glm::vec2 pickScreenCoords{io.MousePos.x, io.MousePos.y}; + Viewport* coordViewport = pushedViewport ? pushedViewport : pickViewport; + if (coordViewport) { + pickScreenCoords.x -= coordViewport->windowX; + pickScreenCoords.y -= coordViewport->windowY; + } + if (!anyModifierHeld && (io.MouseReleased[0] && io.MouseClickedLastCount[0] == 1)) { // don't pick at the end of a long drag if (dragDistSinceLastRelease < dragIgnoreThreshold) { - glm::vec2 screenCoords{io.MousePos.x, io.MousePos.y}; - PickResult pickResult = pickAtScreenCoords(screenCoords); + PickResult pickResult = pickAtScreenCoords(pickScreenCoords); // queue the pick for delayed application pendingPickResult = pickResult; pendingPickTime = ImGui::GetTime(); @@ -581,15 +676,26 @@ void processInputEvents() { // Double-click or Ctrl-shift left-click to set new center if ((io.MouseReleased[0] && io.MouseClickedLastCount[0] == 2) || (io.MouseReleased[0] && ctrlShiftHeld)) { if (dragDistSinceLastRelease < dragIgnoreThreshold) { - glm::vec2 screenCoords{io.MousePos.x, io.MousePos.y}; - view::processSetCenter(screenCoords); + view::processSetCenter(pickScreenCoords); pendingPickActive = false; // cancel any pending pick from the first click } } + + if (didPushForPick) { + pickViewport->pullViewState(); + restoreViewState(pickSavedState); + } } } } + // Pull state back to the viewport that was pushed (use captured pointer, + // not ctx.activeViewport, which may have been cleared if mouse was released this frame) + if (pushedViewport) { + pushedViewport->pullViewState(); + restoreViewState(savedState); + } + // Reset the drag distance after any release if (io.MouseReleased[0]) { dragDistSinceLastRelease = 0.0; @@ -608,7 +714,9 @@ void renderSlicePlanes() { } } -void renderScene() { +// Render the scene into the engine's current sceneBuffer/sceneBufferFinal. +// This function is called once per viewport in multi-viewport mode. +void renderSceneSingleView() { render::engine->applyTransparencySettings(); @@ -688,15 +796,108 @@ void renderScene() { } } + +// Helper: swap engine's scene buffers with a viewport's buffers +void swapEngineBuffersWithViewport(Viewport& vp) { + std::swap(render::engine->sceneBuffer, vp.sceneBuffer); + std::swap(render::engine->sceneBufferFinal, vp.sceneBufferFinal); + std::swap(render::engine->sceneDepthMinFrame, vp.sceneDepthMinFrame); + std::swap(render::engine->sceneColor, vp.sceneColor); + std::swap(render::engine->sceneColorFinal, vp.sceneColorFinal); + std::swap(render::engine->sceneDepth, vp.sceneDepth); + std::swap(render::engine->sceneDepthMin, vp.sceneDepthMin); + std::swap(render::engine->compositePeel, vp.compositePeel); +} + + +void renderScene() { + Context& ctx = state::globalContext; + + if (ctx.viewports.empty()) { + // Legacy single-viewport path + renderSceneSingleView(); + return; + } + + // Multi-viewport path + ViewStateSnapshot globalState = saveViewState(); + + for (auto& vp : ctx.viewports) { + // Push this viewport's camera into the globals + vp->pushViewState(); + + // Swap engine buffers with viewport buffers + swapEngineBuffersWithViewport(*vp); + + // Update the compositePeel texture binding to point at the (now-swapped) sceneColor + render::engine->compositePeel->setTextureFromBuffer("t_image", render::engine->sceneColor.get()); + + // Update the copyDepth texture binding + render::engine->copyDepth->setTextureFromBuffer("t_depth", render::engine->sceneDepth.get()); + + // Render the scene for this viewport + renderSceneSingleView(); + + // Pull any state changes back (e.g., flight animation updates) + vp->pullViewState(); + + // Swap buffers back + swapEngineBuffersWithViewport(*vp); + } + + // Restore global state + restoreViewState(globalState); + + // Restore engine shader texture bindings back to the engine's own buffers. + // The swap restores the shared_ptr ownership, but setTextureFromBuffer stores raw + // pointers that still reference the viewport's textures (which will be freed if + // the viewport layout changes). Rebind to the engine's own textures. + render::engine->compositePeel->setTextureFromBuffer("t_image", render::engine->sceneColor.get()); + render::engine->copyDepth->setTextureFromBuffer("t_depth", render::engine->sceneDepth.get()); +} + void renderSceneToScreen() { + Context& ctx = state::globalContext; + render::engine->bindDisplay(); + if (options::debugDrawPickBuffer) { // special debug draw pick::evaluatePickQuery(-1, -1); // populate the buffer render::engine->pickFramebuffer->blitTo(render::engine->displayBuffer.get()); - } else { + return; + } + + if (ctx.viewports.empty()) { + // Legacy single-viewport path render::engine->applyLightingTransform(render::engine->sceneColorFinal); + return; + } + + // Multi-viewport path: composite each viewport's result into its region of the display + ViewStateSnapshot globalState = saveViewState(); + + render::FrameBuffer& displayBuf = render::engine->getDisplayBuffer(); + + for (auto& vp : ctx.viewports) { + // Push viewport state so bgColor etc. are correct for lighting transform. + // No pullViewState() needed — applyLightingTransform is read-only w.r.t. view state. + vp->pushViewState(); + + // Set the display buffer's viewport to this viewport's pixel region, then re-bind + // to issue the actual glViewport call. OpenGL viewport origin is bottom-left, + // but our pixelY is from top. + int glY = globalState.bufferHeight - vp->pixelY - vp->pixelHeight; + displayBuf.setViewport(vp->pixelX, glY, vp->pixelWidth, vp->pixelHeight); + render::engine->bindDisplay(); + + render::engine->applyLightingTransform(vp->sceneColorFinal); } + + // Restore display buffer viewport to full window and global view state + displayBuf.setViewport(0, 0, globalState.bufferWidth, globalState.bufferHeight); + render::engine->bindDisplay(); + restoreViewState(globalState); } void purgeWidgets() { @@ -1066,7 +1267,20 @@ void draw(bool withUI, bool withContextCallback) { render::engine->ImGuiNewFrame(); processInputEvents(); - view::updateFlight(); + + // Update flight animations (per-viewport if multi-viewport) + if (state::globalContext.viewports.empty()) { + view::updateFlight(); + } else { + ViewStateSnapshot flightSaved = saveViewState(); + for (auto& vp : state::globalContext.viewports) { + vp->pushViewState(); + view::updateFlight(); + vp->pullViewState(); + } + restoreViewState(flightSaved); + } + showDelayedWarnings(); } @@ -1081,9 +1295,33 @@ void draw(bool withUI, bool withContextCallback) { if (options::buildGui) { if (options::buildDefaultGuiPanels) { + + // In multi-viewport mode, push the last-active viewport's camera state into globals + // so that the built-in polyscope View panel (Camera Style, Up Dir, FOV, etc.) reads/writes + // the correct viewport's settings. We preserve window/buffer dimensions so ImGui layout + // is not affected. + ViewStateSnapshot guiSavedState; + bool didPushForGui = false; + if (state::globalContext.lastActiveViewport) { + guiSavedState = saveViewState(); + state::globalContext.lastActiveViewport->pushViewState(); + // Restore full window dimensions so GUI layout is unaffected + view::bufferWidth = guiSavedState.bufferWidth; + view::bufferHeight = guiSavedState.bufferHeight; + view::windowWidth = guiSavedState.windowWidth; + view::windowHeight = guiSavedState.windowHeight; + didPushForGui = true; + } + buildPolyscopeGui(); buildStructureGui(); buildPickGui(); + + // Pull any GUI-modified state back into the viewport and restore globals + if (didPushForGui) { + state::globalContext.lastActiveViewport->pullViewState(); + restoreViewState(guiSavedState); + } } for (WeakHandle wHandle : state::widgets) { @@ -1103,7 +1341,24 @@ void draw(bool withUI, bool withContextCallback) { (contextStack.back().callback)(); } - processLazyProperties(); + // Process lazy properties (projection mode, transparency mode, etc.) + // In multi-viewport mode, push the last-active viewport's state so the lazy diff detects + // any GUI-driven changes (e.g. projection mode). Only one call is needed since the lazy + // shadow variables are global singletons. + if (state::globalContext.lastActiveViewport) { + ViewStateSnapshot lazySaved = saveViewState(); + state::globalContext.lastActiveViewport->pushViewState(); + processLazyProperties(); + state::globalContext.lastActiveViewport->pullViewState(); + restoreViewState(lazySaved); + } else { + processLazyProperties(); + } + + // Update viewport layouts (handles window resize) + if (!state::globalContext.viewports.empty()) { + updateViewportLayouts(); + } // Draw structures in the scene if (redrawNextFrame || options::alwaysRedraw) { @@ -1221,6 +1476,7 @@ void removeEverything() { state::filesDroppedCallback = nullptr; options::configureImGuiStyleCallback = configureImGuiStyle; // restore defaults options::prepareImGuiFontsCallback = loadBaseFonts; + setSingleViewport(); } void shutdown(bool allowMidFrameShutdown) { @@ -1611,4 +1867,86 @@ namespace state { glm::vec3 center() { return 0.5f * (std::get<0>(state::boundingBox) + std::get<1>(state::boundingBox)); } } // namespace state +// === Viewport management === + +void setViewportGridLayout(int rows, int cols) { + if (rows < 1 || cols < 1) { + exception("Viewport grid layout must have at least 1 row and 1 column"); + return; + } + + Context& ctx = state::globalContext; + ctx.viewportGridRows = rows; + ctx.viewportGridCols = cols; + + // Save the current global view state so new viewports can inherit it + ViewStateSnapshot currentState = saveViewState(); + + // Null raw pointers before clearing the vector to avoid any dangling pointer window + ctx.activeViewport = nullptr; + ctx.lastActiveViewport = nullptr; + ctx.viewports.clear(); + + if (rows == 1 && cols == 1) { + // Single viewport mode: don't create any viewport objects, use legacy path + return; + } + + // Ensure globals match the saved state so pullViewState reads consistent values + restoreViewState(currentState); + + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + std::string name = "viewport_" + std::to_string(r) + "_" + std::to_string(c); + std::unique_ptr vp(new Viewport(name, r, c)); + + // Initialize from current global state + vp->pullViewState(); + + ctx.viewports.push_back(std::move(vp)); + } + } + + // Default last-active to the first viewport so the built-in GUI works immediately + ctx.lastActiveViewport = ctx.viewports[0].get(); + + // Update layouts and allocate buffers + updateViewportLayouts(); + + // Reset each viewport's camera to home view + for (auto& vp : ctx.viewports) { + vp->resetCameraToHomeView(); + } + + requestRedraw(); +} + +int getViewportGridRows() { return state::globalContext.viewportGridRows; } +int getViewportGridCols() { return state::globalContext.viewportGridCols; } + +void setSingleViewport() { setViewportGridLayout(1, 1); } +void setVerticalSplitViewport() { setViewportGridLayout(1, 2); } +void setHorizontalSplitViewport() { setViewportGridLayout(2, 1); } +void setQuadViewport() { setViewportGridLayout(2, 2); } + +Viewport* getViewport(int row, int col) { + Context& ctx = state::globalContext; + for (auto& vp : ctx.viewports) { + if (vp->gridRow == row && vp->gridCol == col) return vp.get(); + } + return nullptr; +} + +Viewport* getActiveViewport() { return state::globalContext.activeViewport; } + +void updateViewportLayouts() { + Context& ctx = state::globalContext; + for (auto& vp : ctx.viewports) { + vp->updateLayout(view::bufferWidth, view::bufferHeight, view::windowWidth, view::windowHeight, ctx.viewportGridRows, + ctx.viewportGridCols); + vp->ensureBuffersAllocated(); + vp->resizeBuffers(); + } +} + } // namespace polyscope diff --git a/src/view.cpp b/src/view.cpp index 575db7c6..fb958bdf 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -1113,6 +1113,17 @@ void buildViewGui() { } if (ImGui::TreeNode("View")) { + // Show which viewport is being edited in multi-viewport mode + if (getViewportGridRows() > 1 || getViewportGridCols() > 1) { + Viewport* active = state::globalContext.lastActiveViewport; + if (active) { + ImGui::Text("Editing viewport [%d,%d]", active->gridRow, active->gridCol); + } else { + ImGui::TextUnformatted("Click a viewport to edit it"); + } + ImGui::Separator(); + } + // == Camera style std::string viewStyleName = enum_to_string(view::style); diff --git a/src/viewport.cpp b/src/viewport.cpp new file mode 100644 index 00000000..438929db --- /dev/null +++ b/src/viewport.cpp @@ -0,0 +1,261 @@ +// Copyright 2017-2023, Nicholas Sharp and the Polyscope contributors. https://polyscope.run + +#include "polyscope/viewport.h" + +#include "polyscope/polyscope.h" +#include "polyscope/render/engine.h" + +#include + +namespace polyscope { + +// === View state save/restore === + +ViewStateSnapshot saveViewState() { + ViewStateSnapshot s; + s.viewMat = view::viewMat; + s.fov = view::fov; + s.viewCenter = view::viewCenter; + s.navigateStyle = view::style; + s.upDir = view::upDir; + s.frontDir = view::frontDir; + s.projectionMode = view::projectionMode; + s.nearClip = view::nearClip; + s.farClip = view::farClip; + s.moveScale = view::moveScale; + s.viewRelativeMode = view::viewRelativeMode; + s.bgColor = view::bgColor; + s.bufferWidth = view::bufferWidth; + s.bufferHeight = view::bufferHeight; + s.windowWidth = view::windowWidth; + s.windowHeight = view::windowHeight; + s.midflight = view::midflight; + s.flightStartTime = view::flightStartTime; + s.flightEndTime = view::flightEndTime; + s.flightTargetViewR = view::flightTargetViewR; + s.flightInitialViewR = view::flightInitialViewR; + s.flightTargetViewT = view::flightTargetViewT; + s.flightInitialViewT = view::flightInitialViewT; + s.flightTargetFov = view::flightTargetFov; + s.flightInitialFov = view::flightInitialFov; + return s; +} + +void restoreViewState(const ViewStateSnapshot& s) { + view::viewMat = s.viewMat; + view::fov = s.fov; + view::viewCenter = s.viewCenter; + view::style = s.navigateStyle; + view::upDir = s.upDir; + view::frontDir = s.frontDir; + view::projectionMode = s.projectionMode; + view::nearClip = s.nearClip; + view::farClip = s.farClip; + view::moveScale = s.moveScale; + view::viewRelativeMode = s.viewRelativeMode; + view::bgColor = s.bgColor; + view::bufferWidth = s.bufferWidth; + view::bufferHeight = s.bufferHeight; + view::windowWidth = s.windowWidth; + view::windowHeight = s.windowHeight; + view::midflight = s.midflight; + view::flightStartTime = s.flightStartTime; + view::flightEndTime = s.flightEndTime; + view::flightTargetViewR = s.flightTargetViewR; + view::flightInitialViewR = s.flightInitialViewR; + view::flightTargetViewT = s.flightTargetViewT; + view::flightInitialViewT = s.flightInitialViewT; + view::flightTargetFov = s.flightTargetFov; + view::flightInitialFov = s.flightInitialFov; +} + + +// === Viewport implementation === + +Viewport::Viewport(std::string name_, int gridRow_, int gridCol_) + : name(name_), gridRow(gridRow_), gridCol(gridCol_), viewMat(std::numeric_limits::quiet_NaN()), + fov(view::defaultFov), viewCenter(0.f, 0.f, 0.f), navigateStyle(NavigateStyle::Turntable), upDir(UpDir::YUp), + frontDir(FrontDir::ZFront), projectionMode(ProjectionMode::Perspective), nearClip(view::defaultNearClipRatio), + farClip(view::defaultFarClipRatio), moveScale(1.0f), viewRelativeMode(ViewRelativeMode::CenterRelative), + bgColor{{1.0f, 1.0f, 1.0f, 0.0f}}, midflight(false), flightStartTime(-1), flightEndTime(-1), flightTargetFov(0), + flightInitialFov(0), pixelX(0), pixelY(0), pixelWidth(0), pixelHeight(0), windowX(0), windowY(0), windowW(0), + windowH(0) {} + +Viewport::~Viewport() {} + +void Viewport::pushViewState() { + view::viewMat = viewMat; + view::fov = fov; + view::viewCenter = viewCenter; + view::style = navigateStyle; + view::upDir = upDir; + view::frontDir = frontDir; + view::projectionMode = projectionMode; + view::nearClip = nearClip; + view::farClip = farClip; + view::moveScale = moveScale; + view::viewRelativeMode = viewRelativeMode; + view::bgColor = bgColor; + view::bufferWidth = pixelWidth; + view::bufferHeight = pixelHeight; + view::windowWidth = windowW; + view::windowHeight = windowH; + view::midflight = midflight; + view::flightStartTime = flightStartTime; + view::flightEndTime = flightEndTime; + view::flightTargetViewR = flightTargetViewR; + view::flightInitialViewR = flightInitialViewR; + view::flightTargetViewT = flightTargetViewT; + view::flightInitialViewT = flightInitialViewT; + view::flightTargetFov = flightTargetFov; + view::flightInitialFov = flightInitialFov; +} + +void Viewport::pullViewState() { + // Note: buffer/window dimensions are intentionally NOT pulled back here. + // They are layout-derived (pixelWidth/pixelHeight/windowW/windowH) and set by updateLayout(), + // not by the view:: globals. pushViewState() writes them into the globals so that rendering + // code sees the correct per-viewport size, but they should never flow back. + viewMat = view::viewMat; + fov = view::fov; + viewCenter = view::viewCenter; + navigateStyle = view::style; + upDir = view::upDir; + frontDir = view::frontDir; + projectionMode = view::projectionMode; + nearClip = view::nearClip; + farClip = view::farClip; + moveScale = view::moveScale; + viewRelativeMode = view::viewRelativeMode; + bgColor = view::bgColor; + midflight = view::midflight; + flightStartTime = view::flightStartTime; + flightEndTime = view::flightEndTime; + flightTargetViewR = view::flightTargetViewR; + flightInitialViewR = view::flightInitialViewR; + flightTargetViewT = view::flightTargetViewT; + flightInitialViewT = view::flightInitialViewT; + flightTargetFov = view::flightTargetFov; + flightInitialFov = view::flightInitialFov; +} + +void Viewport::updateLayout(int totalBufferW, int totalBufferH, int totalWindowW, int totalWindowH, int gridRows, + int gridCols) { + int cellBufW = totalBufferW / gridCols; + int cellBufH = totalBufferH / gridRows; + int cellWinW = totalWindowW / gridCols; + int cellWinH = totalWindowH / gridRows; + + // Last column/row absorbs any remainder from integer division + pixelX = gridCol * cellBufW; + pixelY = gridRow * cellBufH; + pixelWidth = (gridCol == gridCols - 1) ? (totalBufferW - pixelX) : cellBufW; + pixelHeight = (gridRow == gridRows - 1) ? (totalBufferH - pixelY) : cellBufH; + + windowX = gridCol * cellWinW; + windowY = gridRow * cellWinH; + windowW = (gridCol == gridCols - 1) ? (totalWindowW - windowX) : cellWinW; + windowH = (gridRow == gridRows - 1) ? (totalWindowH - windowY) : cellWinH; +} + +void Viewport::ensureBuffersAllocated() { + if (!render::engine) return; + if (pixelWidth <= 0 || pixelHeight <= 0) return; + + int ssaa = render::engine->getSSAAFactor(); + unsigned int w = static_cast(pixelWidth) * ssaa; + unsigned int h = static_cast(pixelHeight) * ssaa; + + if (!sceneBuffer) { + sceneColor = render::engine->generateTextureBuffer(TextureFormat::RGBA16F, w, h); + sceneDepth = render::engine->generateTextureBuffer(TextureFormat::DEPTH24, w, h); + sceneBuffer = render::engine->generateFrameBuffer(w, h); + sceneBuffer->addColorBuffer(sceneColor); + sceneBuffer->addDepthBuffer(sceneDepth); + sceneBuffer->setDrawBuffers(); + + sceneBuffer->clearColor = glm::vec3{1., 1., 1.}; + sceneBuffer->clearAlpha = 0.0; + + sceneColorFinal = render::engine->generateTextureBuffer(TextureFormat::RGBA16F, w, h); + sceneBufferFinal = render::engine->generateFrameBuffer(w, h); + sceneBufferFinal->addColorBuffer(sceneColorFinal); + sceneBufferFinal->setDrawBuffers(); + + sceneBufferFinal->clearColor = glm::vec3{1., 1., 1.}; + sceneBufferFinal->clearAlpha = 0.0; + + sceneDepthMin = render::engine->generateTextureBuffer(TextureFormat::DEPTH24, w, h); + sceneDepthMinFrame = render::engine->generateFrameBuffer(w, h); + sceneDepthMinFrame->addDepthBuffer(sceneDepthMin); + sceneDepthMinFrame->setDrawBuffers(); + sceneDepthMinFrame->clearDepth = 0.0; + + // Set viewports on all framebuffers (required by the GL backend before binding) + sceneBuffer->setViewport(0, 0, w, h); + sceneBufferFinal->setViewport(0, 0, w, h); + sceneDepthMinFrame->setViewport(0, 0, w, h); + + // Create per-viewport compositePeel shader for Pretty transparency + compositePeel = render::engine->requestShader("COMPOSITE_PEEL", {}, render::ShaderReplacementDefaults::Process); + compositePeel->setAttribute("a_position", render::engine->screenTrianglesCoords()); + compositePeel->setTextureFromBuffer("t_image", sceneColor.get()); + } +} + +void Viewport::resizeBuffers() { + if (!sceneBuffer) return; + if (pixelWidth <= 0 || pixelHeight <= 0) return; + + unsigned int w = static_cast(pixelWidth); + unsigned int h = static_cast(pixelHeight); + + int ssaa = render::engine->getSSAAFactor(); + unsigned int targetW = ssaa * w; + unsigned int targetH = ssaa * h; + + // Early-out if size hasn't changed (avoid GPU reallocation every frame) + if (sceneBuffer->getSizeX() == targetW && sceneBuffer->getSizeY() == targetH) return; + + sceneBuffer->resize(targetW, targetH); + sceneBufferFinal->resize(targetW, targetH); + sceneDepthMinFrame->resize(targetW, targetH); + + sceneBuffer->setViewport(0, 0, targetW, targetH); + sceneBufferFinal->setViewport(0, 0, targetW, targetH); + sceneDepthMinFrame->setViewport(0, 0, targetW, targetH); +} + +void Viewport::resetCameraToHomeView() { + // Push this viewport's state, compute home view, pull it back + ViewStateSnapshot saved = saveViewState(); + pushViewState(); + view::resetCameraToHomeView(); + pullViewState(); + restoreViewState(saved); +} + +void Viewport::setNavigateStyle(NavigateStyle newStyle, bool animateFlight) { + ViewStateSnapshot saved = saveViewState(); + pushViewState(); + view::setNavigateStyle(newStyle, animateFlight); + pullViewState(); + restoreViewState(saved); +} + +void Viewport::setUpDir(UpDir newUpDir, bool animateFlight) { + ViewStateSnapshot saved = saveViewState(); + pushViewState(); + view::setUpDir(newUpDir, animateFlight); + pullViewState(); + restoreViewState(saved); +} + +bool Viewport::containsScreenCoords(float x, float y) const { + // Screen coords: (0,0) at top-left, y increases downward + // windowY is in screen coords from top + return x >= static_cast(windowX) && x < static_cast(windowX + windowW) && + y >= static_cast(windowY) && y < static_cast(windowY + windowH); +} + +} // namespace polyscope diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0745f94d..6ac943a1 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -111,6 +111,7 @@ set(TEST_SRCS src/combo_test.cpp src/misc_test.cpp src/interop_and_serialization_test.cpp + src/viewport_test.cpp ) add_executable(polyscope-test "${TEST_SRCS}") diff --git a/test/src/viewport_test.cpp b/test/src/viewport_test.cpp new file mode 100644 index 00000000..9816f9c5 --- /dev/null +++ b/test/src/viewport_test.cpp @@ -0,0 +1,307 @@ +// Copyright 2017-2023, Nicholas Sharp and the Polyscope contributors. https://polyscope.run + +#include "polyscope_test.h" + +#include "polyscope/polyscope.h" +#include "polyscope/surface_mesh.h" +#include "polyscope/viewport.h" + +#include "gtest/gtest.h" + +#include +#include +#include +#include + +// ============================================================ +// =============== Viewport tests +// ============================================================ + +TEST_F(PolyscopeTest, ViewportDefaultIsSingle) { + EXPECT_EQ(polyscope::getViewportGridRows(), 1); + EXPECT_EQ(polyscope::getViewportGridCols(), 1); + EXPECT_EQ(polyscope::getActiveViewport(), nullptr); + EXPECT_EQ(polyscope::getViewport(0, 0), nullptr); // no viewport objects in single mode +} + +TEST_F(PolyscopeTest, ViewportSetGridLayout) { + polyscope::setViewportGridLayout(2, 2); + EXPECT_EQ(polyscope::getViewportGridRows(), 2); + EXPECT_EQ(polyscope::getViewportGridCols(), 2); + + // Should have 4 viewport objects + polyscope::Viewport* v00 = polyscope::getViewport(0, 0); + polyscope::Viewport* v01 = polyscope::getViewport(0, 1); + polyscope::Viewport* v10 = polyscope::getViewport(1, 0); + polyscope::Viewport* v11 = polyscope::getViewport(1, 1); + EXPECT_NE(v00, nullptr); + EXPECT_NE(v01, nullptr); + EXPECT_NE(v10, nullptr); + EXPECT_NE(v11, nullptr); + + // Out of range returns nullptr + EXPECT_EQ(polyscope::getViewport(2, 0), nullptr); + EXPECT_EQ(polyscope::getViewport(0, 2), nullptr); + + // Reset back + polyscope::setSingleViewport(); + EXPECT_EQ(polyscope::getViewportGridRows(), 1); + EXPECT_EQ(polyscope::getViewportGridCols(), 1); + EXPECT_EQ(polyscope::getViewport(0, 0), nullptr); +} + +TEST_F(PolyscopeTest, ViewportPresets) { + polyscope::setVerticalSplitViewport(); + EXPECT_EQ(polyscope::getViewportGridRows(), 1); + EXPECT_EQ(polyscope::getViewportGridCols(), 2); + EXPECT_NE(polyscope::getViewport(0, 0), nullptr); + EXPECT_NE(polyscope::getViewport(0, 1), nullptr); + + polyscope::setHorizontalSplitViewport(); + EXPECT_EQ(polyscope::getViewportGridRows(), 2); + EXPECT_EQ(polyscope::getViewportGridCols(), 1); + EXPECT_NE(polyscope::getViewport(0, 0), nullptr); + EXPECT_NE(polyscope::getViewport(1, 0), nullptr); + + polyscope::setQuadViewport(); + EXPECT_EQ(polyscope::getViewportGridRows(), 2); + EXPECT_EQ(polyscope::getViewportGridCols(), 2); + + polyscope::setSingleViewport(); +} + +TEST_F(PolyscopeTest, ViewportRenderSingle) { + // Rendering with a single viewport should work exactly as before + auto psMesh = registerTriangleMesh(); + polyscope::show(3); + polyscope::removeAllStructures(); +} + +TEST_F(PolyscopeTest, ViewportRenderVerticalSplit) { + auto psMesh = registerTriangleMesh(); + polyscope::setVerticalSplitViewport(); + polyscope::show(3); + polyscope::setSingleViewport(); + polyscope::removeAllStructures(); +} + +TEST_F(PolyscopeTest, ViewportRenderHorizontalSplit) { + auto psMesh = registerTriangleMesh(); + polyscope::setHorizontalSplitViewport(); + polyscope::show(3); + polyscope::setSingleViewport(); + polyscope::removeAllStructures(); +} + +TEST_F(PolyscopeTest, ViewportRenderQuad) { + auto psMesh = registerTriangleMesh(); + polyscope::setQuadViewport(); + polyscope::show(3); + polyscope::setSingleViewport(); + polyscope::removeAllStructures(); +} + +TEST_F(PolyscopeTest, ViewportIndependentCameraSettings) { + polyscope::setQuadViewport(); + + polyscope::Viewport* v00 = polyscope::getViewport(0, 0); + polyscope::Viewport* v01 = polyscope::getViewport(0, 1); + polyscope::Viewport* v10 = polyscope::getViewport(1, 0); + polyscope::Viewport* v11 = polyscope::getViewport(1, 1); + + // Set different navigation styles per viewport + v00->navigateStyle = polyscope::NavigateStyle::Turntable; + v01->navigateStyle = polyscope::NavigateStyle::Free; + v10->navigateStyle = polyscope::NavigateStyle::Planar; + v11->navigateStyle = polyscope::NavigateStyle::Arcball; + + EXPECT_EQ(v00->navigateStyle, polyscope::NavigateStyle::Turntable); + EXPECT_EQ(v01->navigateStyle, polyscope::NavigateStyle::Free); + EXPECT_EQ(v10->navigateStyle, polyscope::NavigateStyle::Planar); + EXPECT_EQ(v11->navigateStyle, polyscope::NavigateStyle::Arcball); + + // Set different up directions + v00->upDir = polyscope::UpDir::YUp; + v01->upDir = polyscope::UpDir::ZUp; + + EXPECT_EQ(v00->upDir, polyscope::UpDir::YUp); + EXPECT_EQ(v01->upDir, polyscope::UpDir::ZUp); + + // Set different FOV + v00->fov = 45.0f; + v01->fov = 90.0f; + + EXPECT_FLOAT_EQ(v00->fov, 45.0f); + EXPECT_FLOAT_EQ(v01->fov, 90.0f); + + // Set different bg colors + v00->bgColor = {{1.0f, 0.0f, 0.0f, 1.0f}}; + v01->bgColor = {{0.0f, 1.0f, 0.0f, 1.0f}}; + + polyscope::setSingleViewport(); +} + +TEST_F(PolyscopeTest, ViewportRenderWithIndependentSettings) { + auto psMesh = registerTriangleMesh(); + polyscope::setQuadViewport(); + + polyscope::Viewport* v00 = polyscope::getViewport(0, 0); + polyscope::Viewport* v10 = polyscope::getViewport(1, 0); + + // One viewport is 3D with turntable, the other is 2D planar + v00->navigateStyle = polyscope::NavigateStyle::Turntable; + v10->navigateStyle = polyscope::NavigateStyle::Planar; + + polyscope::show(3); + + polyscope::setSingleViewport(); + polyscope::removeAllStructures(); +} + +TEST_F(PolyscopeTest, ViewportNavigateStyleSurvivesFrames) { + auto psMesh = registerTriangleMesh(); + polyscope::setVerticalSplitViewport(); + + polyscope::Viewport* v0 = polyscope::getViewport(0, 0); + polyscope::Viewport* v1 = polyscope::getViewport(0, 1); + + // Use the setter (which calls view::setNavigateStyle internally) + v1->setNavigateStyle(polyscope::NavigateStyle::Planar); + EXPECT_EQ(v1->navigateStyle, polyscope::NavigateStyle::Planar); + EXPECT_EQ(v0->navigateStyle, polyscope::NavigateStyle::Turntable); + + // Render several frames and verify styles are preserved + for (int i = 0; i < 5; i++) { + polyscope::frameTick(); + EXPECT_EQ(v0->navigateStyle, polyscope::NavigateStyle::Turntable); + EXPECT_EQ(v1->navigateStyle, polyscope::NavigateStyle::Planar); + } + + polyscope::setSingleViewport(); + polyscope::removeAllStructures(); +} + +TEST_F(PolyscopeTest, ViewportFrameTick) { + auto psMesh = registerTriangleMesh(); + polyscope::setVerticalSplitViewport(); + + for (int i = 0; i < 5; i++) { + polyscope::frameTick(); + } + + polyscope::setSingleViewport(); + polyscope::removeAllStructures(); +} + +TEST_F(PolyscopeTest, ViewportPushPullState) { + polyscope::setVerticalSplitViewport(); + + polyscope::Viewport* v0 = polyscope::getViewport(0, 0); + polyscope::Viewport* v1 = polyscope::getViewport(0, 1); + + // Set different FOVs + v0->fov = 30.0f; + v1->fov = 90.0f; + + // Save global state + polyscope::ViewStateSnapshot saved = polyscope::saveViewState(); + + // Push v0's state + v0->pushViewState(); + EXPECT_FLOAT_EQ(polyscope::view::fov, 30.0f); + + // Restore global + polyscope::restoreViewState(saved); + + // Push v1's state + v1->pushViewState(); + EXPECT_FLOAT_EQ(polyscope::view::fov, 90.0f); + + // Restore global + polyscope::restoreViewState(saved); + + polyscope::setSingleViewport(); +} + +TEST_F(PolyscopeTest, ViewportContainsScreenCoords) { + polyscope::setVerticalSplitViewport(); + + polyscope::Viewport* vLeft = polyscope::getViewport(0, 0); + polyscope::Viewport* vRight = polyscope::getViewport(0, 1); + + // Left viewport should contain points on the left half of the window + EXPECT_TRUE(vLeft->containsScreenCoords(10.0f, 10.0f)); + + // Right viewport should contain points on the right half + float rightX = static_cast(polyscope::view::windowWidth) * 0.75f; + EXPECT_TRUE(vRight->containsScreenCoords(rightX, 10.0f)); + + // Check they don't overlap at the boundary + float midX = static_cast(polyscope::view::windowWidth) / 2.0f; + // Exactly one should contain midpoint + bool leftContains = vLeft->containsScreenCoords(midX, 10.0f); + bool rightContains = vRight->containsScreenCoords(midX, 10.0f); + EXPECT_TRUE(leftContains || rightContains); + + polyscope::setSingleViewport(); +} + +TEST_F(PolyscopeTest, ViewportLayoutUpdate) { + polyscope::setQuadViewport(); + + polyscope::Viewport* v00 = polyscope::getViewport(0, 0); + polyscope::Viewport* v11 = polyscope::getViewport(1, 1); + + // After layout update, viewports should have non-zero dimensions + polyscope::updateViewportLayouts(); + + EXPECT_GT(v00->pixelWidth, 0); + EXPECT_GT(v00->pixelHeight, 0); + EXPECT_GT(v11->pixelWidth, 0); + EXPECT_GT(v11->pixelHeight, 0); + + // The four viewports should tile the full window + EXPECT_EQ(v00->windowX, 0); + EXPECT_EQ(v00->windowY, 0); + EXPECT_EQ(v11->windowX + v11->windowW, polyscope::view::windowWidth); + EXPECT_EQ(v11->windowY + v11->windowH, polyscope::view::windowHeight); + + polyscope::setSingleViewport(); +} + +TEST_F(PolyscopeTest, ViewportRemoveEverythingResets) { + polyscope::setQuadViewport(); + EXPECT_EQ(polyscope::getViewportGridRows(), 2); + + polyscope::removeEverything(); + + EXPECT_EQ(polyscope::getViewportGridRows(), 1); + EXPECT_EQ(polyscope::getViewportGridCols(), 1); + EXPECT_EQ(polyscope::getViewport(0, 0), nullptr); +} + +TEST_F(PolyscopeTest, ViewportScreenshotWithSplit) { + auto psMesh = registerTriangleMesh(); + polyscope::setVerticalSplitViewport(); + + // Taking a screenshot with split viewports should not crash + polyscope::screenshot("test_viewport_screenshot.png"); + + polyscope::setSingleViewport(); + polyscope::removeAllStructures(); +} + +TEST_F(PolyscopeTest, ViewportSwitchLayoutWhileShowing) { + auto psMesh = registerTriangleMesh(); + + // Start single, switch to split, render some frames, switch back + polyscope::show(3); + polyscope::setVerticalSplitViewport(); + polyscope::show(3); + polyscope::setQuadViewport(); + polyscope::show(3); + polyscope::setSingleViewport(); + polyscope::show(3); + + polyscope::removeAllStructures(); +}