aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--docs/README.md2
-rw-r--r--docs/architecture.md63
-rw-r--r--src/core/application.cpp28
-rw-r--r--src/platform/opengl/shader.cpp1
-rw-r--r--src/platform/vulkan/vulkan_common.h1
-rw-r--r--src/platform/vulkan/vulkan_device.cpp29
-rw-r--r--src/rendering/view.h6
-rw-r--r--src/ui/ui_layer.cpp954
-rw-r--r--src/ui/ui_layer.h99
10 files changed, 869 insertions, 316 deletions
diff --git a/README.md b/README.md
index 4efc773..2c865f2 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ for each pixel, donut generates a ray from the camera using its position, orient
## documentation
for a deeper explanation of how donut works, see the [`docs/`](docs/) folder:
- [docs/physics.md](docs/physics.md) — the physics and mathematics: null geodesics and the equations of motion, the numerical integrator, the event horizon / photon sphere / isco, the novikov–thorne accretion disk, gravitational + doppler redshift and relativistic beaming, and the observable quantities the renderer can measure.
-- [docs/architecture.md](docs/architecture.md) — how the program is built: the portable opengl/vulkan rhi, the two renderers, the unified scene-and-simulation world, the rendering pipeline, the docking ui, and the physical-value export pipeline.
+- [docs/architecture.md](docs/architecture.md) — how the program is built: the portable opengl/vulkan rhi, the two renderers, the unified scene-and-simulation world, the rendering pipeline, the workspace tabs and docking ui, and the physical-value export pipeline.
## license
donut is released under the mit license.
diff --git a/docs/README.md b/docs/README.md
index 3ab9570..a10d307 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -16,7 +16,7 @@ go into how it actually works.
layering (application / scene / renderpath / uilayer), the portable rhi that lets
the same rendering run on opengl and vulkan, the frame loop, the unified
scene-and-simulation world, the rendering pipeline (progressive resolution,
- supersampling, environment lighting, tone-mapping), the docking ui, the export
+ supersampling, environment lighting, tone-mapping), the workspace tabs and docking ui, the export
pipeline, and the build system.
for each pixel, donut casts a ray from the camera and follows it backward through
diff --git a/docs/architecture.md b/docs/architecture.md
index 0868775..98f2b6f 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -48,7 +48,7 @@ flowchart TD
| --- | --- | --- |
| `Application` | [`src/core/application.cpp`](../src/core/application.cpp) | owns everything; runs the main loop; handles input, resize, vsync, fullscreen; exposes actions to the ui |
| `Scene` | [`src/scene/scene.h`](../src/scene/scene.h) | the world as plain data — placed objects, black-hole/disk parameters, the editor and simulation cameras, the hdri path |
-| `UILayer` | [`src/ui/ui_layer.cpp`](../src/ui/ui_layer.cpp) | the docking shell: menu bar, dockable panels, default layouts; returns which view is live and drives the scene through `AppActions` |
+| `UILayer` | [`src/ui/ui_layer.cpp`](../src/ui/ui_layer.cpp) | the docking shell: menu bar, workspace tabs, dockable panels with a layout per workspace; returns which view is live and drives the scene through `AppActions` |
| `RenderPath` | [`src/rendering/render_path.cpp`](../src/rendering/render_path.cpp) | turns the scene into pixels on whatever device is active; owns the two renderers and the environment cubemap |
| `RHI::Device` | [`src/rendering/rhi.h`](../src/rendering/rhi.h) | the portable gpu interface every backend implements |
@@ -219,14 +219,30 @@ fragment shader averages four sub-pixel samples in a rotated-grid ("4-rook") pat
before tone-mapping, which cleans up the near-horizontal lensed edges and the thin
photon ring.
-## the workspace: docking and panels
+## workspaces: tabs, docking and panels
-the interface is a docking shell. a main menu bar sits above a full-viewport dock
-space with a pass-through centre, so the live 3d render shows through the middle
-while dockable tool panels attach to the edges — drag, tab, hide, or restore them
-like a normal desktop app. the layout persists in `imgui.ini` between runs.
+the interface is a docking shell with three workspaces along the top —
+**simulation**, **scene editor** and **export** — each a tab with its own
+independent layout, the way blender's workspace tabs work. a main menu bar sits
+above the tab strip; below it is a full-viewport dock space with a pass-through
+centre, so the live 3d render shows through the middle while the workspace's tool
+panels attach to the edges — drag, tab, hide, or restore them like a normal
+desktop app. rearranging one workspace never touches the others, and all three
+layouts, the open workspace and each one's panel set persist in `imgui.ini`
+between runs.
-the panels are separate windows, each toggled from the **window** menu:
+each workspace fixes what the centre viewport shows — the `View` that
+`UILayer::draw` returns to the `RenderPath` — and has its own set of panels. the
+sphere panels are available in the black-hole workspaces too, since the lensed
+render draws the spheres, though the gizmo itself only works in the scene editor:
+
+| workspace | view | panels (open by default in bold) |
+| --- | --- | --- |
+| simulation | `BlackHole` — the lensed black hole | **black hole**, **diagnostics**, settings, outliner, properties |
+| scene editor | `Scene` — the world editor | **outliner**, **properties**, **diagnostics**, settings |
+| export | `BlackHole` | **export**, **black hole**, **diagnostics**, settings, outliner, properties |
+
+the panels themselves:
| panel | for |
| --- | --- |
@@ -235,26 +251,25 @@ the panels are separate windows, each toggled from the **window** menu:
| black hole | camera mode/fov, the accretion disk, and integration quality |
| settings | display & renderer: vsync, frame cap, resolution, fullscreen, ui scale, api, overlay, hdri |
| export | choose which observable channels, at what resolution and format, then render to disk |
-| stats | device, backend, fps and frame time |
+| diagnostics | device, backend, fps and a frame-time graph |
-two menus drive the rest. **view** picks what the centre viewport shows — `Scene`
-(the world editor) or `Simulation` (the lensed black hole) — which is the `View`
-`UILayer::draw` returns to the `RenderPath`. **layout** applies one of the default
-arrangements, each built programmatically with imgui's `DockBuilder` api and paired
-with a sensible view and panel set:
-
-| layout | view | panels shown |
-| --- | --- | --- |
-| simulation (default) | `Simulation` | black hole, stats |
-| scene editing | `Scene` | outliner, properties, stats |
-| export | `Simulation` | export, black hole, stats |
+the **workspace** menu (or cmd/ctrl + 1/2/3) switches tabs, the **window** menu
+toggles the open workspace's panels, and **layout → reset** rebuilds the open
+workspace's default arrangement, or all three.
-on first launch (no saved `imgui.ini`) the simulation layout is built by
-`DockBuilder`; after that the user's arrangement is restored, and *layout → reset*
-rebuilds the current preset. only panels are docked windows — the centre stays a
+underneath, each workspace is its own imgui dock space with a fixed id. only the
+open one is submitted for real each frame; the other two get a keep-alive ping,
+which freezes their trees in place instead of tearing them down — nothing is laid
+out or undocked while a workspace is off screen. an imgui window can only live in
+one dock tree, so every panel is instanced once per workspace under a stable
+`###workspace.panel` id (the same code draws all of them), which is also what lets
+imgui save and restore each tree on its own. a workspace with no saved tree is
+seeded on the first frame with the `DockBuilder` api. imgui doesn't remember which
+workspace was open or which panels were showing, so a small `[Donut][Workspaces]`
+section in `imgui.ini` does. only panels are docked windows — the centre stays a
pass-through hole onto the full-frame 3d render, so `Application::update_input`
-drives the camera whenever the cursor is over that centre (imgui reports it doesn't
-want the mouse) and yields to the panels otherwise.
+drives the camera whenever the cursor is over that centre (imgui reports it
+doesn't want the mouse) and yields to the panels otherwise.
## the export pipeline
diff --git a/src/core/application.cpp b/src/core/application.cpp
index 433f0e7..e53e420 100644
--- a/src/core/application.cpp
+++ b/src/core/application.cpp
@@ -12,6 +12,7 @@
#include <algorithm>
#include <thread>
#include <chrono>
+#include <cstdlib> // std::getenv (the DONUT_AUTOEXPORT dev hook)
namespace Donut
{
@@ -79,11 +80,9 @@ namespace Donut
m_ui = create_scope<UILayer>();
m_device->init_imgui();
- // load the UI font into the device-created ImGui context (backend-agnostic;
- // the backend rebuilds the atlas on the next frame).
- ImGuiIO& io = ImGui::GetIO();
- if (!io.Fonts->AddFontFromFileTTF("assets/fonts/inter/static/Inter_18pt-Regular.ttf", 16.0f))
- io.Fonts->AddFontDefault();
+ // the UI layer owns its typography and its imgui.ini section; both go into
+ // the device-created ImGui context before its first NewFrame reads the ini.
+ m_ui->init();
g_prev_scroll = glfwSetScrollCallback((GLFWwindow*)m_window->get_native_window(), donut_scroll_callback);
@@ -166,6 +165,25 @@ namespace Donut
m_scene->scene_camera.set_projection(45.0f, (float)w / (float)std::max(h, 1), 0.1f, 1000.0f);
View view = m_ui->draw(UIContext{ *m_scene, m_device->device_name(), *this });
+
+ // dev hook: DONUT_AUTOEXPORT=<frame> fires one export from this exact
+ // mid-frame spot (the Export button runs inside ui->draw, i.e. between
+ // begin_frame and render) at that frame (30 if it's not a number), then
+ // quits. lets the mid-frame export path be exercised headlessly — exit 0
+ // means no segfault.
+ if (const char* autoexport = std::getenv("DONUT_AUTOEXPORT"))
+ {
+ static int frame = 0;
+ const int at = std::atoi(autoexport) > 0 ? std::atoi(autoexport) : 30;
+ if (++frame == at)
+ {
+ ExportConfig cfg;
+ cfg.width = 1280; cfg.height = 720;
+ int written = export_frame(cfg);
+ DONUT_INFO("autoexport wrote {} file(s)", written);
+ close();
+ }
+ }
update_input(view);
m_render_path->sync_hdri(m_scene->hdri_path);
diff --git a/src/platform/opengl/shader.cpp b/src/platform/opengl/shader.cpp
index a56a1b7..ca63c14 100644
--- a/src/platform/opengl/shader.cpp
+++ b/src/platform/opengl/shader.cpp
@@ -5,6 +5,7 @@
#include <fstream>
#include <iostream>
+#include <vector>
namespace Donut
{
diff --git a/src/platform/vulkan/vulkan_common.h b/src/platform/vulkan/vulkan_common.h
index cb86d69..22d389c 100644
--- a/src/platform/vulkan/vulkan_common.h
+++ b/src/platform/vulkan/vulkan_common.h
@@ -343,6 +343,7 @@ namespace Donut::RHI
std::vector<VkDescriptorPool> m_frame_pools; // one per frame in flight
VkDescriptorPool m_imgui_pool = VK_NULL_HANDLE;
+ VkDescriptorPool m_offscreen_pool = VK_NULL_HANDLE; // run_offscreen's own; never a frame's
bool m_imgui = false;
VkCommandListR m_cmds;
diff --git a/src/platform/vulkan/vulkan_device.cpp b/src/platform/vulkan/vulkan_device.cpp
index a4f9ac7..f780f6f 100644
--- a/src/platform/vulkan/vulkan_device.cpp
+++ b/src/platform/vulkan/vulkan_device.cpp
@@ -119,8 +119,24 @@ namespace Donut::RHI
auto VulkanDevice::run_offscreen(const std::function<void(CommandList&)>& record) -> void
{
+ // this gets called mid-frame (the Export button fires from inside ui->draw
+ // while the frame's command buffer is still being recorded), so it has to be
+ // fully self-contained: its own command list AND its own descriptor pool. the
+ // old version reused m_cmds and then freed its buffer, so the in-flight frame
+ // carried on recording into a dangling handle. segfault.
vkDeviceWaitIdle(m_device);
- vkResetDescriptorPool(m_device, m_frame_pools[0], 0); // per-draw sets for this pass
+
+ if (m_offscreen_pool == VK_NULL_HANDLE)
+ {
+ VkDescriptorPoolSize sizes[2] = {
+ { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 32 },
+ { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 32 },
+ };
+ VkDescriptorPoolCreateInfo dpci{ VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO };
+ dpci.maxSets = 16; dpci.poolSizeCount = 2; dpci.pPoolSizes = sizes;
+ vkCreateDescriptorPool(m_device, &dpci, nullptr, &m_offscreen_pool);
+ }
+ vkResetDescriptorPool(m_device, m_offscreen_pool, 0); // per-draw sets for this pass
VkCommandBufferAllocateInfo cbai{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO };
cbai.commandPool = m_command_pool; cbai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; cbai.commandBufferCount = 1;
@@ -128,10 +144,12 @@ namespace Donut::RHI
VkCommandBufferBeginInfo bi{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO }; bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
vkBeginCommandBuffer(cmd, &bi);
- m_cmds.m_device = m_device; m_cmds.m_cmd = cmd;
- m_cmds.m_swapchain_rp = m_swapchain_rp; m_cmds.m_swapchain_fb = VK_NULL_HANDLE; m_cmds.m_extent = m_extent;
- m_cmds.m_frame_pool = m_frame_pools[0]; m_cmds.m_pipe = nullptr;
- record(m_cmds); // records its own off-screen render pass(es)
+ // a local command list, so the frame's m_cmds is never touched.
+ VkCommandListR ocmds;
+ ocmds.m_device = m_device; ocmds.m_cmd = cmd;
+ ocmds.m_swapchain_rp = m_swapchain_rp; ocmds.m_swapchain_fb = VK_NULL_HANDLE; ocmds.m_extent = m_extent;
+ ocmds.m_frame_pool = m_offscreen_pool; ocmds.m_pipe = nullptr;
+ record(ocmds); // records its own off-screen render pass(es)
vkEndCommandBuffer(cmd);
VkSubmitInfo si{ VK_STRUCTURE_TYPE_SUBMIT_INFO }; si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
@@ -264,6 +282,7 @@ namespace Donut::RHI
if (m_imgui_pool) vkDestroyDescriptorPool(m_device, m_imgui_pool, nullptr);
for (auto p : m_frame_pools) vkDestroyDescriptorPool(m_device, p, nullptr);
m_frame_pools.clear();
+ if (m_offscreen_pool) { vkDestroyDescriptorPool(m_device, m_offscreen_pool, nullptr); m_offscreen_pool = VK_NULL_HANDLE; }
for (auto s : m_render_finished) vkDestroySemaphore(m_device, s, nullptr);
for (auto s : m_image_available) vkDestroySemaphore(m_device, s, nullptr);
for (auto f : m_in_flight) vkDestroyFence(m_device, f, nullptr);
diff --git a/src/rendering/view.h b/src/rendering/view.h
index 1139676..19cc5af 100644
--- a/src/rendering/view.h
+++ b/src/rendering/view.h
@@ -2,12 +2,12 @@
namespace Donut
{
- // what the RenderPath draws in the viewport this frame. the UI's View menu
- // (or a layout preset) picks it; the RenderPath branches on the result.
+ // what the RenderPath draws in the viewport this frame. the open workspace
+ // tab fixes it; the RenderPath branches on the result.
enum class View
{
None, // empty viewport (no live 3D; not currently produced by the UI)
Scene, // world-builder: grid + placed objects + skybox
- BlackHole, // the ray-traced accretion-disk simulation (Simulation view only)
+ BlackHole, // the ray-traced accretion-disk simulation (Simulation and Export workspaces)
};
}
diff --git a/src/ui/ui_layer.cpp b/src/ui/ui_layer.cpp
index 1c014e6..90ddb7c 100644
--- a/src/ui/ui_layer.cpp
+++ b/src/ui/ui_layer.cpp
@@ -1,70 +1,329 @@
#include "ui_layer.h"
#include "scene/scene.h"
+#include "core/log.h"
#include "core/settings_manager.h"
#include "core/hdri_manager.h"
#include "rendering/render_api.h"
#include <imgui.h>
-#include <imgui_internal.h> // DockBuilder*
+#include <imgui_internal.h> // DockBuilder*, DockSpace keep-alive, BeginViewportSideBar, ImGuiSettingsHandler
#include <ImGuizmo.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <algorithm>
+#include <cfloat>
#include <cstdio>
+#include <cstring>
#include <string>
namespace Donut
{
- // panel window titles. these are the identity DockBuilder docks against, so
- // they must match the ImGui::Begin() titles exactly.
- static const char* kOutliner = "Outliner";
- static const char* kProperties = "Properties";
- static const char* kBlackHole = "Black Hole";
- static const char* kSettings = "Settings";
- static const char* kExport = "Export";
- static const char* kStats = "Stats";
+ // the workspaces and the panels, with stable keys. the keys salt the dock ids,
+ // suffix the window names ("Black Hole###scene.black_hole") and name things in
+ // imgui.ini, so a label can be reworded without losing anyone's saved layout.
+ struct TabDesc { const char* label; const char* key; View view; };
+ struct PanelDesc { const char* label; const char* key; };
- // one-time ImGui theme: rounded, roomy, dark with a warm accretion-disk accent.
+ static const TabDesc kTabs[kTabCount] = {
+ { "Simulation", "simulation", View::BlackHole },
+ { "Scene Editor", "scene", View::Scene },
+ { "Export", "export", View::BlackHole },
+ };
+ static const PanelDesc kPanels[kPanelCount] = {
+ { "Outliner", "outliner" },
+ { "Properties", "properties" },
+ { "Black Hole", "black_hole" },
+ { "Settings", "settings" },
+ { "Export", "export" },
+ { "Diagnostics", "stats" },
+ };
+
+ // which panels each workspace can show and which open by default. columns
+ // follow Panel. the sphere panels are allowed everywhere because the lensed
+ // render draws the spheres too, so placing one while watching it is a thing.
+ static const bool kAllowed[kTabCount][kPanelCount] = {
+ // outl prop bh set exp stats
+ { true, true, true, true, false, true }, // simulation
+ { true, true, false, true, false, true }, // scene editor
+ { true, true, true, true, true, true }, // export
+ };
+ static const bool kDefaults[kTabCount][kPanelCount] = {
+ { false, false, true, false, false, true },
+ { true, true, false, false, false, true },
+ { false, false, true, false, true, true },
+ };
+
+ // the type scale. two faces; ImGui 1.92 rasterises any size on demand at PushFont.
+ static constexpr float kBodySize = 15.0f;
+ static constexpr float kCaptionSize = 13.0f;
+ static constexpr float kHeadSize = 18.0f;
+ static constexpr float kBigSize = 30.0f;
+
+ // the palette: one warm accent (the accretion disk) on a cool near-black.
+ static const ImVec4 kAmber (0.98f, 0.62f, 0.20f, 1.00f);
+ static const ImVec4 kAmberHi (1.00f, 0.73f, 0.36f, 1.00f);
+ static const ImVec4 kInk (0.09f, 0.07f, 0.03f, 1.00f); // text sitting on amber
+ static const ImVec4 kGreen (0.55f, 0.85f, 0.45f, 1.00f);
+ static const ImVec4 kRed (0.95f, 0.45f, 0.35f, 1.00f);
+
+ // what ImGui doesn't save on its own: the open workspace and each one's panel
+ // set. lives outside UILayer so the ini handler can still write it during
+ // ImGui's shutdown save, which runs after the Application has already torn
+ // the UILayer down. the section reads
+ // [Donut][Workspaces]
+ // active=scene
+ // scene=outliner,properties,stats
+ struct Persisted
+ {
+ int active = 0;
+ bool show[kTabCount][kPanelCount] = {};
+ bool loaded = false; // an entry came in from imgui.ini
+ };
+ static Persisted s_persisted;
+
+ static auto settings_read_open(ImGuiContext*, ImGuiSettingsHandler*, const char* name) -> void*
+ {
+ return std::strcmp(name, "Workspaces") == 0 ? &s_persisted : nullptr;
+ }
+
+ static auto settings_read_line(ImGuiContext*, ImGuiSettingsHandler*, void*, const char* line) -> void
+ {
+ const char* eq = std::strchr(line, '=');
+ if (!eq) return;
+ const std::string lhs(line, eq);
+ const char* rhs = eq + 1;
+
+ if (lhs == "active")
+ {
+ for (int t = 0; t < kTabCount; ++t)
+ if (std::strcmp(rhs, kTabs[t].key) == 0) { s_persisted.active = t; s_persisted.loaded = true; }
+ return;
+ }
+ for (int t = 0; t < kTabCount; ++t)
+ {
+ if (lhs != kTabs[t].key) continue;
+ s_persisted.loaded = true;
+ for (bool& b : s_persisted.show[t]) b = false;
+ for (const char* tok = rhs; *tok; )
+ {
+ const char* end = std::strchr(tok, ',');
+ const size_t len = end ? (size_t)(end - tok) : std::strlen(tok);
+ for (int p = 0; p < kPanelCount; ++p)
+ if (std::strlen(kPanels[p].key) == len && std::strncmp(tok, kPanels[p].key, len) == 0)
+ s_persisted.show[t][p] = true;
+ tok = end ? end + 1 : tok + len;
+ }
+ }
+ }
+
+ static auto settings_write_all(ImGuiContext*, ImGuiSettingsHandler* handler, ImGuiTextBuffer* out) -> void
+ {
+ out->appendf("[%s][Workspaces]\n", handler->TypeName);
+ out->appendf("active=%s\n", kTabs[s_persisted.active].key);
+ for (int t = 0; t < kTabCount; ++t)
+ {
+ out->appendf("%s=", kTabs[t].key);
+ bool first = true;
+ for (int p = 0; p < kPanelCount; ++p)
+ {
+ if (!s_persisted.show[t][p]) continue;
+ if (!first) out->append(",");
+ out->append(kPanels[p].key);
+ first = false;
+ }
+ out->append("\n");
+ }
+ out->append("\n");
+ }
+
+ // the theme: soft-rounded cards on a near-black ground, 1px borders for edges,
+ // and the accent reserved for "this is live / selected" — slider grabs, checks,
+ // the active tab's underline, the one primary button per panel.
static auto apply_donut_style() -> void
{
ImGuiStyle& s = ImGui::GetStyle();
- s.WindowRounding = 7.0f; s.ChildRounding = 5.0f; s.FrameRounding = 4.0f;
- s.GrabRounding = 4.0f; s.PopupRounding = 4.0f; s.ScrollbarRounding = 5.0f; s.TabRounding = 4.0f;
- s.WindowPadding = ImVec2(12, 12); s.FramePadding = ImVec2(9, 5);
- s.ItemSpacing = ImVec2(9, 8); s.ItemInnerSpacing = ImVec2(7, 5);
- s.WindowBorderSize = 0.0f; s.FrameBorderSize = 0.0f; s.WindowTitleAlign = ImVec2(0.02f, 0.5f);
+ s.WindowRounding = 8.0f; s.ChildRounding = 6.0f; s.FrameRounding = 6.0f;
+ s.GrabRounding = 6.0f; s.PopupRounding = 8.0f; s.ScrollbarRounding = 8.0f; s.TabRounding = 6.0f;
+ s.WindowPadding = ImVec2(14.0f, 12.0f); s.FramePadding = ImVec2(10.0f, 6.0f);
+ s.ItemSpacing = ImVec2(8.0f, 7.0f); s.ItemInnerSpacing = ImVec2(8.0f, 5.0f);
+ s.IndentSpacing = 18.0f; s.ScrollbarSize = 10.0f; s.GrabMinSize = 10.0f;
+ s.WindowBorderSize = 1.0f; s.FrameBorderSize = 0.0f; s.PopupBorderSize = 1.0f; s.ChildBorderSize = 1.0f;
+ s.WindowTitleAlign = ImVec2(0.0f, 0.5f);
+ s.TabBarOverlineSize = 2.0f;
+ s.DockingSeparatorSize = 2.0f;
+
+ const ImVec4 bg0 (0.055f, 0.062f, 0.080f, 1.00f); // ground
+ const ImVec4 bg1 (0.095f, 0.105f, 0.130f, 1.00f); // frames / cards
+ const ImVec4 bg2 (0.140f, 0.155f, 0.190f, 1.00f); // hover
+ const ImVec4 bg3 (0.185f, 0.205f, 0.250f, 1.00f); // active
+ const ImVec4 line(0.170f, 0.190f, 0.230f, 1.00f); // borders / separators
+ const ImVec4 text(0.920f, 0.930f, 0.950f, 1.00f);
+ const ImVec4 dim (0.550f, 0.580f, 0.640f, 1.00f);
+ const ImVec4 amber_dim(0.98f, 0.62f, 0.20f, 0.35f);
- const ImVec4 amber = ImVec4(0.98f, 0.62f, 0.20f, 1.00f);
- const ImVec4 amberHi = ImVec4(1.00f, 0.73f, 0.36f, 1.00f);
ImVec4* c = s.Colors;
- c[ImGuiCol_WindowBg] = ImVec4(0.07f, 0.08f, 0.10f, 0.97f);
- c[ImGuiCol_ChildBg] = ImVec4(0.10f, 0.11f, 0.13f, 0.55f);
- c[ImGuiCol_PopupBg] = ImVec4(0.09f, 0.10f, 0.12f, 0.98f);
- c[ImGuiCol_MenuBarBg] = ImVec4(0.09f, 0.10f, 0.12f, 1.00f);
- c[ImGuiCol_TitleBg] = ImVec4(0.09f, 0.10f, 0.12f, 1.00f);
- c[ImGuiCol_TitleBgActive] = ImVec4(0.13f, 0.14f, 0.17f, 1.00f);
- c[ImGuiCol_Text] = ImVec4(0.90f, 0.91f, 0.93f, 1.00f);
- c[ImGuiCol_TextDisabled] = ImVec4(0.48f, 0.50f, 0.54f, 1.00f);
- c[ImGuiCol_FrameBg] = ImVec4(0.16f, 0.17f, 0.20f, 1.00f);
- c[ImGuiCol_FrameBgHovered] = ImVec4(0.22f, 0.24f, 0.28f, 1.00f);
- c[ImGuiCol_FrameBgActive] = ImVec4(0.26f, 0.28f, 0.33f, 1.00f);
- c[ImGuiCol_Header] = ImVec4(0.17f, 0.19f, 0.23f, 1.00f);
- c[ImGuiCol_HeaderHovered] = ImVec4(0.24f, 0.27f, 0.32f, 1.00f);
- c[ImGuiCol_HeaderActive] = ImVec4(0.28f, 0.31f, 0.37f, 1.00f);
- c[ImGuiCol_Button] = ImVec4(0.20f, 0.22f, 0.26f, 1.00f);
- c[ImGuiCol_ButtonHovered] = ImVec4(0.27f, 0.30f, 0.35f, 1.00f);
- c[ImGuiCol_ButtonActive] = amber;
- c[ImGuiCol_SliderGrab] = amber;
- c[ImGuiCol_SliderGrabActive]= amberHi;
- c[ImGuiCol_CheckMark] = amberHi;
- c[ImGuiCol_Tab] = ImVec4(0.13f, 0.14f, 0.17f, 1.00f);
- c[ImGuiCol_TabHovered] = ImVec4(0.28f, 0.31f, 0.37f, 1.00f);
- c[ImGuiCol_TabActive] = ImVec4(0.22f, 0.24f, 0.28f, 1.00f);
- c[ImGuiCol_Separator] = ImVec4(0.20f, 0.22f, 0.26f, 1.00f);
- c[ImGuiCol_ScrollbarGrab] = ImVec4(0.24f, 0.26f, 0.30f, 1.00f);
- c[ImGuiCol_DockingPreview] = ImVec4(0.98f, 0.62f, 0.20f, 0.55f);
- c[ImGuiCol_DockingEmptyBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); // pass-through
+ c[ImGuiCol_Text] = text;
+ c[ImGuiCol_TextDisabled] = dim;
+ c[ImGuiCol_WindowBg] = bg0;
+ c[ImGuiCol_ChildBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.60f);
+ c[ImGuiCol_PopupBg] = ImVec4(bg0.x, bg0.y, bg0.z, 0.98f);
+ c[ImGuiCol_Border] = line;
+ c[ImGuiCol_BorderShadow] = ImVec4(0.0f, 0.0f, 0.0f, 0.0f);
+ c[ImGuiCol_FrameBg] = bg1;
+ c[ImGuiCol_FrameBgHovered] = bg2;
+ c[ImGuiCol_FrameBgActive] = bg3;
+ c[ImGuiCol_TitleBg] = bg0;
+ c[ImGuiCol_TitleBgActive] = bg1;
+ c[ImGuiCol_TitleBgCollapsed] = bg0;
+ c[ImGuiCol_MenuBarBg] = bg0;
+ c[ImGuiCol_ScrollbarBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.0f);
+ c[ImGuiCol_ScrollbarGrab] = bg2;
+ c[ImGuiCol_ScrollbarGrabHovered] = bg3;
+ c[ImGuiCol_ScrollbarGrabActive] = amber_dim;
+ c[ImGuiCol_CheckMark] = kAmberHi;
+ c[ImGuiCol_SliderGrab] = kAmber;
+ c[ImGuiCol_SliderGrabActive] = kAmberHi;
+ c[ImGuiCol_Button] = bg2;
+ c[ImGuiCol_ButtonHovered] = bg3;
+ c[ImGuiCol_ButtonActive] = kAmber;
+ c[ImGuiCol_Header] = ImVec4(0.110f, 0.120f, 0.150f, 1.00f); // section bands
+ c[ImGuiCol_HeaderHovered] = bg2;
+ c[ImGuiCol_HeaderActive] = bg3;
+ c[ImGuiCol_Separator] = line;
+ c[ImGuiCol_SeparatorHovered] = kAmber;
+ c[ImGuiCol_SeparatorActive] = kAmberHi;
+ c[ImGuiCol_ResizeGrip] = ImVec4(0.0f, 0.0f, 0.0f, 0.0f);
+ c[ImGuiCol_ResizeGripHovered] = amber_dim;
+ c[ImGuiCol_ResizeGripActive] = kAmber;
+ c[ImGuiCol_Tab] = bg0;
+ c[ImGuiCol_TabHovered] = bg2;
+ c[ImGuiCol_TabSelected] = bg1;
+ c[ImGuiCol_TabSelectedOverline] = kAmber; // the accent underline on the live tab
+ c[ImGuiCol_TabDimmed] = bg0;
+ c[ImGuiCol_TabDimmedSelected] = bg1;
+ c[ImGuiCol_TabDimmedSelectedOverline] = amber_dim;
+ c[ImGuiCol_DockingPreview] = ImVec4(kAmber.x, kAmber.y, kAmber.z, 0.50f);
+ c[ImGuiCol_DockingEmptyBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.0f); // pass-through
+ c[ImGuiCol_PlotLines] = kAmber;
+ c[ImGuiCol_PlotLinesHovered] = kAmberHi;
+ c[ImGuiCol_PlotHistogram] = kAmber;
+ c[ImGuiCol_PlotHistogramHovered] = kAmberHi;
+ c[ImGuiCol_TableHeaderBg] = bg1;
+ c[ImGuiCol_TableBorderStrong] = line;
+ c[ImGuiCol_TableBorderLight] = ImVec4(line.x, line.y, line.z, 0.5f);
+ c[ImGuiCol_TableRowBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.0f);
+ c[ImGuiCol_TableRowBgAlt] = ImVec4(1.0f, 1.0f, 1.0f, 0.025f);
+ c[ImGuiCol_TextSelectedBg] = amber_dim;
+ c[ImGuiCol_DragDropTarget] = kAmberHi;
+ }
+
+ // inspector row: label in a fixed left column, the control fills the rest.
+ // pair with a "##hidden" widget label so ImGui doesn't draw its own on the right.
+ static auto prop(const char* label, float column = 116.0f) -> void
+ {
+ ImGui::AlignTextToFramePadding();
+ ImGui::TextUnformatted(label);
+ ImGui::SameLine(column);
+ ImGui::SetNextItemWidth(-FLT_MIN);
+ }
+
+ // a filled accent button: the one primary action in a panel.
+ static auto accent_button(const char* label, const ImVec2& size = ImVec2(0.0f, 0.0f)) -> bool
+ {
+ ImGui::PushStyleColor(ImGuiCol_Button, kAmber);
+ ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kAmberHi);
+ ImGui::PushStyleColor(ImGuiCol_ButtonActive, kAmberHi);
+ ImGui::PushStyleColor(ImGuiCol_Text, kInk);
+ const bool pressed = ImGui::Button(label, size);
+ ImGui::PopStyleColor(4);
+ return pressed;
+ }
+
+ UILayer::UILayer()
+ {
+ for (int t = 0; t < kTabCount; ++t)
+ {
+ TabState& tab = m_tabs[t];
+ tab.id = static_cast<Tab>(t);
+ tab.label = kTabs[t].label;
+ tab.key = kTabs[t].key;
+ tab.view = kTabs[t].view;
+ // ImHashStr is a plain CRC, no context needed. never derived from a window
+ // name, and ImGui's own split nodes get small sequential ids, so no clashes.
+ tab.dock_id = ImHashStr((std::string("donut.dock.") + tab.key).c_str());
+ for (int p = 0; p < kPanelCount; ++p)
+ {
+ tab.name[p] = std::string(kPanels[p].label) + "###" + tab.key + "." + kPanels[p].key;
+ tab.allowed[p] = kAllowed[t][p];
+ tab.defaults[p] = kDefaults[t][p];
+ tab.show[p] = kDefaults[t][p];
+ }
+ }
+ }
+
+ auto UILayer::init() -> void
+ {
+ ImGuiIO& io = ImGui::GetIO();
+ m_font_body = io.Fonts->AddFontFromFileTTF("assets/fonts/inter/static/Inter_18pt-Regular.ttf", kBodySize);
+ m_font_heading = io.Fonts->AddFontFromFileTTF("assets/fonts/inter/static/Inter_18pt-SemiBold.ttf", kHeadSize);
+ if (!m_font_body) m_font_body = io.Fonts->AddFontDefault(); // ship without the asset, not without a font
+ if (!m_font_heading) m_font_heading = m_font_body;
+ io.FontDefault = m_font_body;
+
+ ImGuiSettingsHandler handler;
+ handler.TypeName = "Donut";
+ handler.TypeHash = ImHashStr("Donut");
+ handler.ReadOpenFn = settings_read_open;
+ handler.ReadLineFn = settings_read_line;
+ handler.WriteAllFn = settings_write_all;
+ ImGui::AddSettingsHandler(&handler);
+ }
+
+ // a collapsible heading in the heading face, sitting on a soft band.
+ auto UILayer::section(const char* title, bool default_open) -> bool
+ {
+ ImGui::PushFont(m_font_heading, kHeadSize);
+ ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(8.0f, 6.0f));
+ const bool open = ImGui::CollapsingHeader(title, default_open ? ImGuiTreeNodeFlags_DefaultOpen : 0);
+ ImGui::PopStyleVar();
+ ImGui::PopFont();
+ if (open) ImGui::Spacing();
+ return open;
+ }
+
+ // muted helper text, one step smaller than body.
+ auto UILayer::caption(const char* text) -> void
+ {
+ ImGui::PushFont(m_font_body, kCaptionSize);
+ ImGui::PushTextWrapPos(0.0f);
+ ImGui::TextDisabled("%s", text);
+ ImGui::PopTextWrapPos();
+ ImGui::PopFont();
+ }
+
+ // a row of mutually-exclusive buttons; the active one wears the accent.
+ auto UILayer::segmented(const char* id, const char* const* labels, int count, int& value) -> bool
+ {
+ bool changed = false;
+ ImGui::PushID(id);
+ const float gap = ImGui::GetStyle().ItemInnerSpacing.x;
+ const float width = (ImGui::GetContentRegionAvail().x - gap * (float)(count - 1)) / (float)count;
+ for (int i = 0; i < count; ++i)
+ {
+ if (i > 0) ImGui::SameLine(0.0f, gap);
+ const bool active = (value == i);
+ if (active)
+ {
+ ImGui::PushStyleColor(ImGuiCol_Button, kAmber);
+ ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kAmberHi);
+ ImGui::PushStyleColor(ImGuiCol_Text, kInk);
+ }
+ if (ImGui::Button(labels[i], ImVec2(width, 0.0f)) && !active) { value = i; changed = true; }
+ if (active) ImGui::PopStyleColor(3);
+ }
+ ImGui::PopID();
+ return changed;
}
auto UILayer::draw(const UIContext& ctx) -> View
@@ -74,192 +333,328 @@ namespace Donut
ImGuizmo::BeginFrame(); // the Properties panel draws the scene gizmo
- draw_menu_bar(ctx);
-
- // full-viewport dock space with a pass-through centre: panels dock to the
- // edges while the render shows through the middle. sits under the menu bar.
- // ImGui owns the id (stable per viewport) and persists the layout in imgui.ini.
- const ImGuiID dockspace_id = ImGui::DockSpaceOverViewport(
- 0, ImGui::GetMainViewport(), ImGuiDockNodeFlags_PassthruCentralNode);
-
- // on the first frame, seed a default layout unless imgui.ini restored one
- // (a restored dock tree has splits/windows, so the root node isn't empty).
+ // first frame: pick up what imgui.ini restored (ImGui applied it in NewFrame
+ // and pruned any dock tree no saved window references) and seed the
+ // workspaces that were never built. has to run before any DockSpace() call
+ // this frame, since even a keep-alive ping creates the node and would hide
+ // a fresh tab from the check.
if (m_first_frame)
{
m_first_frame = false;
- ImGuiDockNode* node = ImGui::DockBuilderGetNode(dockspace_id);
- if (node == nullptr || node->IsEmpty())
- m_rebuild_layout = true;
- }
- if (m_rebuild_layout)
- {
- build_layout(dockspace_id);
- m_rebuild_layout = false;
+ if (s_persisted.loaded)
+ {
+ m_active_tab = std::clamp(s_persisted.active, 0, kTabCount - 1);
+ for (int t = 0; t < kTabCount; ++t)
+ for (int p = 0; p < kPanelCount; ++p)
+ m_tabs[t].show[p] = m_tabs[t].allowed[p] && s_persisted.show[t][p];
+ }
+ for (TabState& tab : m_tabs)
+ {
+ if (ImGui::DockBuilderGetNode(tab.dock_id) != nullptr) continue;
+ // no tree. fresh install or a new workspace: seed it. but if the ini
+ // already knows this workspace's windows, the user floated every one
+ // of them and ImGui pruned the empty tree: keep what they left.
+ bool known = false;
+ for (int p = 0; p < kPanelCount; ++p)
+ if (tab.allowed[p] && ImGui::FindWindowSettingsByID(ImHashStr(tab.name[p].c_str()))) known = true;
+ tab.reset = !known;
+ }
+ m_tab_request = m_active_tab; // the strip defaults to its first tab otherwise
+ m_tabs[m_active_tab].appearing = true;
}
- if (m_show_outliner) panel_outliner(ctx);
- if (m_show_properties) panel_properties(ctx);
- if (m_show_black_hole) panel_black_hole(ctx);
- if (m_show_settings) panel_settings(ctx);
- if (m_show_export) panel_export(ctx);
- if (m_show_stats) panel_stats(ctx);
+ // seeding goes before the live dockspace so a fresh tree gets its host
+ // window this frame, instead of its panels floating around for one.
+ for (TabState& tab : m_tabs)
+ if (tab.reset) { build_layout(tab); tab.reset = false; }
+
+ draw_menu_bar(ctx);
+ draw_tab_strip(); // the bars reserve their strips in call order, top to bottom
+ draw_status_bar(ctx);
+
+ // only the open workspace's tree is really submitted. the other two got a
+ // keep-alive ping from the strip, which freezes them: ImGui neither lays
+ // them out nor evicts their windows, and imgui.ini still gets every tree.
+ TabState& tab = m_tabs[m_active_tab];
+ ImGui::DockSpaceOverViewport(tab.dock_id, ImGui::GetMainViewport(), ImGuiDockNodeFlags_PassthruCentralNode);
- return m_mode == Mode::Scene ? View::Scene : View::BlackHole;
+ for (int p = 0; p < kPanelCount; ++p)
+ if (tab.allowed[p] && tab.show[p]) draw_panel(ctx, tab, static_cast<Panel>(p));
+ tab.appearing = false;
+
+ sync_persisted();
+ return tab.view;
}
auto UILayer::draw_menu_bar(const UIContext& /*ctx*/) -> void
{
if (!ImGui::BeginMainMenuBar()) return;
- if (ImGui::BeginMenu("View"))
+ // a small brand mark on the left.
+ ImGui::PushFont(m_font_heading, kBodySize);
+ ImGui::TextColored(kAmber, "Donut");
+ ImGui::PopFont();
+ ImGui::Separator();
+
+ TabState& tab = m_tabs[m_active_tab];
+
+ if (ImGui::BeginMenu("Workspace"))
{
- ImGui::TextDisabled("Centre viewport");
- ImGui::Separator();
- if (ImGui::MenuItem("Scene", nullptr, m_mode == Mode::Scene)) m_mode = Mode::Scene;
- if (ImGui::MenuItem("Simulation", nullptr, m_mode == Mode::Simulation)) m_mode = Mode::Simulation;
+ const char* mod = ImGui::GetIO().ConfigMacOSXBehaviors ? "Cmd" : "Ctrl";
+ for (int i = 0; i < kTabCount; ++i)
+ {
+ char shortcut[16];
+ std::snprintf(shortcut, sizeof(shortcut), "%s+%d", mod, i + 1);
+ if (ImGui::MenuItem(m_tabs[i].label, shortcut, m_active_tab == i)) request_tab(i);
+ }
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Window"))
{
- ImGui::MenuItem(kOutliner, nullptr, &m_show_outliner);
- ImGui::MenuItem(kProperties, nullptr, &m_show_properties);
- ImGui::MenuItem(kBlackHole, nullptr, &m_show_black_hole);
- ImGui::MenuItem(kSettings, nullptr, &m_show_settings);
- ImGui::MenuItem(kExport, nullptr, &m_show_export);
- ImGui::MenuItem(kStats, nullptr, &m_show_stats);
+ ImGui::TextDisabled("%s panels", tab.label);
+ ImGui::Separator();
+ for (int p = 0; p < kPanelCount; ++p)
+ if (tab.allowed[p]) ImGui::MenuItem(kPanels[p].label, nullptr, &tab.show[p]);
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Layout"))
{
- ImGui::TextDisabled("Default layouts");
- ImGui::Separator();
- if (ImGui::MenuItem("Simulation", nullptr, m_layout == Layout::Simulation)) apply_layout(Layout::Simulation);
- if (ImGui::MenuItem("Scene editing", nullptr, m_layout == Layout::SceneEditing)) apply_layout(Layout::SceneEditing);
- if (ImGui::MenuItem("Export", nullptr, m_layout == Layout::Export)) apply_layout(Layout::Export);
- ImGui::Separator();
- if (ImGui::MenuItem("Reset current layout")) m_rebuild_layout = true;
+ if (ImGui::MenuItem("Reset this workspace")) tab.reset = true;
+ if (ImGui::MenuItem("Reset all workspaces")) for (TabState& t : m_tabs) t.reset = true;
ImGui::EndMenu();
}
- // right-aligned mode + FPS readout.
- char status[64];
- std::snprintf(status, sizeof(status), "%s %.0f FPS",
- m_mode == Mode::Scene ? "Scene" : "Simulation", ImGui::GetIO().Framerate);
- float tw = ImGui::CalcTextSize(status).x;
- ImGui::SameLine(ImGui::GetWindowWidth() - tw - 16.0f);
- ImGui::TextDisabled("%s", status);
-
ImGui::EndMainMenuBar();
}
- // rebuild the dock tree for the current layout. windows named but not currently
- // visible still get a dock home here, so toggling them on lands them sensibly.
- auto UILayer::build_layout(unsigned int dockspace_id) -> void
+ // the workspace tabs on their own strip under the main menu bar. the strip is
+ // also where the two closed workspaces get their keep-alive ping.
+ auto UILayer::draw_tab_strip() -> void
{
- ImGui::DockBuilderRemoveNode(dockspace_id);
+ // Cmd+1/2/3 on macOS (ImGuiMod_Ctrl means Cmd there), Ctrl+1/2/3 elsewhere.
+ for (int i = 0; i < kTabCount; ++i)
+ if (ImGui::Shortcut(ImGuiMod_Ctrl | (ImGuiKey_1 + i), ImGuiInputFlags_RouteGlobal)) request_tab(i);
+
+ ImGuiViewport* vp = ImGui::GetMainViewport();
+ ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(18.0f, 6.0f)); // roomier tabs
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 2.0f));
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
+ const float height = ImGui::GetFrameHeight() + 4.0f; // the tab bar plus the window padding
+ const ImGuiWindowFlags flags = ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse
+ | ImGuiWindowFlags_NoSavedSettings;
+ if (ImGui::BeginViewportSideBar("##donut_tabs", vp, ImGuiDir_Up, height, flags))
+ {
+ ImGui::PushFont(m_font_heading, kBodySize);
+ if (ImGui::BeginTabBar("##workspaces", ImGuiTabBarFlags_NoTooltip | ImGuiTabBarFlags_DrawSelectedOverline
+ | ImGuiTabBarFlags_NoTabListScrollingButtons))
+ {
+ // don't swap trees under a live window drag: the dragged window would
+ // just stop being submitted mid-gesture.
+ const bool can_switch = ImGui::GetCurrentContext()->MovingWindow == nullptr && !ImGui::IsDragDropActive();
+ for (int i = 0; i < kTabCount; ++i)
+ {
+ const ImGuiTabItemFlags item_flags = ImGuiTabItemFlags_NoReorder | ImGuiTabItemFlags_NoCloseWithMiddleMouseButton
+ | (m_tab_request == i ? ImGuiTabItemFlags_SetSelected : 0);
+ if (ImGui::BeginTabItem(m_tabs[i].label, nullptr, item_flags))
+ {
+ // on a request frame the strip's answer is stale (SetSelected lands
+ // next frame), and on its very first frame the tab bar just shows
+ // tab 0, so only trust a click when nothing programmatic is pending.
+ if (i != m_active_tab && can_switch && m_tab_request < 0) { m_active_tab = i; m_tabs[i].appearing = true; }
+ ImGui::EndTabItem();
+ }
+ }
+ ImGui::EndTabBar();
+ }
+ ImGui::PopFont();
+ }
+ // park the closed workspaces' trees. with KeepAliveOnly, DockSpace just
+ // stamps the node's LastFrameAlive and returns: no host window, no layout,
+ // and nothing docked in there gets undocked for being off screen.
+ for (int i = 0; i < kTabCount; ++i)
+ if (i != m_active_tab)
+ ImGui::DockSpace(m_tabs[i].dock_id, ImVec2(0.0f, 0.0f), ImGuiDockNodeFlags_KeepAliveOnly);
+ ImGui::End();
+ ImGui::PopStyleVar(3);
+ m_tab_request = -1;
+ }
+
+ // a thin bottom bar with the live facts. BeginViewportSideBar reserves its
+ // strip in the viewport work area, so the dock space above shrinks to fit —
+ // the same mechanism the main menu bar uses.
+ auto UILayer::draw_status_bar(const UIContext& ctx) -> void
+ {
+ ImGuiViewport* vp = ImGui::GetMainViewport();
+ const float height = ImGui::GetFrameHeight();
+ const ImGuiWindowFlags flags = ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings
+ | ImGuiWindowFlags_MenuBar;
+ if (ImGui::BeginViewportSideBar("##donut_status", vp, ImGuiDir_Down, height, flags))
+ {
+ if (ImGui::BeginMenuBar())
+ {
+ ImGuiIO& io = ImGui::GetIO();
+ ImGui::PushFont(m_font_body, kCaptionSize);
+
+ ImGui::TextDisabled("%s", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str());
+ ImGui::Separator();
+ ImGui::TextColored(kAmber, "%s", m_tabs[m_active_tab].label);
+ ImGui::Separator();
+ // DisplaySize is the window in points; the swapchain is points * scale (2x on retina)
+ ImGui::TextDisabled("%.0f x %.0f", io.DisplaySize.x * io.DisplayFramebufferScale.x,
+ io.DisplaySize.y * io.DisplayFramebufferScale.y);
+
+ // the Settings > Overlay toggles decide whether the timings show here.
+ if (SettingsManager::get_show_fps())
+ {
+ ImGui::Separator();
+ ImGui::Text("%.0f fps", io.Framerate);
+ }
+ if (SettingsManager::get_show_performance_metrics())
+ {
+ ImGui::Separator();
+ ImGui::TextDisabled("%.2f ms", io.Framerate > 0.0f ? 1000.0f / io.Framerate : 0.0f);
+ }
+
+ // last export result, right-aligned.
+ if (m_exp_reported)
+ {
+ const char* msg = m_exp_written > 0 ? "export written" : "export failed";
+ const float tw = ImGui::CalcTextSize(msg).x;
+ ImGui::SameLine(ImGui::GetWindowWidth() - tw - 12.0f);
+ ImGui::TextColored(m_exp_written > 0 ? kGreen : kRed, "%s", msg);
+ }
+
+ ImGui::PopFont();
+ ImGui::EndMenuBar();
+ }
+ }
+ ImGui::End();
+ }
+
+ // build one workspace's default dock tree from scratch. every panel the
+ // workspace owns gets a home here, shown or not, so toggling one on later
+ // lands it somewhere sensible. a reset also restores the default panel set.
+ auto UILayer::build_layout(TabState& tab) -> void
+ {
+ DONUT_INFO("seeding the {} workspace layout", tab.label);
+ const ImGuiID root = tab.dock_id;
+ ImGui::DockBuilderRemoveNode(root);
// DockSpace is a private flag and PassthruCentralNode a public one, and
// C++20 whines about OR-ing two different enum types, so cast to int and move on.
- ImGui::DockBuilderAddNode(dockspace_id,
- (int)ImGuiDockNodeFlags_DockSpace | (int)ImGuiDockNodeFlags_PassthruCentralNode);
- ImGui::DockBuilderSetNodeSize(dockspace_id, ImGui::GetMainViewport()->WorkSize);
+ ImGui::DockBuilderAddNode(root, (int)ImGuiDockNodeFlags_DockSpace | (int)ImGuiDockNodeFlags_PassthruCentralNode);
+ // splits need a size to ratio against. the three bars can eat the whole work
+ // area in a very short window and the builder asserts on zero, and the live
+ // DockSpace resizes the root from the real work area next frame anyway.
+ const ImGuiViewport* vp = ImGui::GetMainViewport();
+ const ImVec2 seed_size = (vp->WorkSize.x > 0.0f && vp->WorkSize.y > 0.0f) ? vp->WorkSize : vp->Size;
+ ImGui::DockBuilderSetNodeSize(root, seed_size);
- ImGuiID centre = dockspace_id;
+ auto dock = [&](Panel p, ImGuiID node) { ImGui::DockBuilderDockWindow(tab.name[(int)p].c_str(), node); };
+ ImGuiID centre = root;
- switch (m_layout)
+ switch (tab.id)
{
- case Layout::SceneEditing:
+ case Tab::SceneEditor:
{
ImGuiID left = 0, left_bottom = 0, right = 0;
ImGui::DockBuilderSplitNode(centre, ImGuiDir_Left, 0.24f, &left, &centre);
ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.50f, &left_bottom, &left);
ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.24f, &right, &centre);
- ImGui::DockBuilderDockWindow(kOutliner, left);
- ImGui::DockBuilderDockWindow(kProperties, left_bottom);
- ImGui::DockBuilderDockWindow(kBlackHole, right);
- ImGui::DockBuilderDockWindow(kSettings, right);
- ImGui::DockBuilderDockWindow(kExport, right);
- ImGui::DockBuilderDockWindow(kStats, right);
+ dock(Panel::Outliner, left);
+ dock(Panel::Properties, left_bottom);
+ dock(Panel::Stats, right);
+ dock(Panel::Settings, right);
break;
}
- case Layout::Export:
+ case Tab::Export:
{
ImGuiID right = 0, right_bottom = 0;
ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.28f, &right, &centre);
ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.50f, &right_bottom, &right);
- ImGui::DockBuilderDockWindow(kExport, right);
- ImGui::DockBuilderDockWindow(kOutliner, right);
- ImGui::DockBuilderDockWindow(kProperties, right);
- ImGui::DockBuilderDockWindow(kBlackHole, right_bottom);
- ImGui::DockBuilderDockWindow(kSettings, right_bottom);
- ImGui::DockBuilderDockWindow(kStats, right_bottom);
+ dock(Panel::Export, right);
+ dock(Panel::Settings, right);
+ dock(Panel::BlackHole, right_bottom);
+ dock(Panel::Stats, right_bottom);
+ dock(Panel::Outliner, right_bottom);
+ dock(Panel::Properties, right_bottom);
break;
}
- case Layout::Simulation:
- default:
+ case Tab::Simulation:
{
ImGuiID right = 0, right_bottom = 0;
ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.24f, &right, &centre);
- ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.30f, &right_bottom, &right);
- ImGui::DockBuilderDockWindow(kBlackHole, right);
- ImGui::DockBuilderDockWindow(kSettings, right);
- ImGui::DockBuilderDockWindow(kExport, right);
- ImGui::DockBuilderDockWindow(kOutliner, right);
- ImGui::DockBuilderDockWindow(kProperties, right);
- ImGui::DockBuilderDockWindow(kStats, right_bottom);
+ ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.34f, &right_bottom, &right);
+ dock(Panel::BlackHole, right);
+ dock(Panel::Settings, right);
+ dock(Panel::Outliner, right);
+ dock(Panel::Properties, right);
+ dock(Panel::Stats, right_bottom);
break;
}
}
- ImGui::DockBuilderFinish(dockspace_id);
+ ImGui::DockBuilderFinish(root);
+ std::copy(std::begin(tab.defaults), std::end(tab.defaults), tab.show);
}
- // apply a preset: set the centre view, choose which panels are visible, and
- // request a dock-tree rebuild next frame.
- auto UILayer::apply_layout(Layout layout) -> void
+ // push the live workspace state into the ini snapshot and let ImGui know it's
+ // worth a save, when anything actually changed.
+ auto UILayer::sync_persisted() -> void
{
- m_layout = layout;
- m_rebuild_layout = true;
- switch (layout)
+ bool changed = !s_persisted.loaded || s_persisted.active != m_active_tab;
+ for (int t = 0; t < kTabCount; ++t)
+ for (int p = 0; p < kPanelCount; ++p)
+ changed |= s_persisted.show[t][p] != m_tabs[t].show[p];
+ if (!changed) return;
+
+ s_persisted.active = m_active_tab;
+ for (int t = 0; t < kTabCount; ++t)
+ for (int p = 0; p < kPanelCount; ++p)
+ s_persisted.show[t][p] = m_tabs[t].show[p];
+ s_persisted.loaded = true;
+ ImGui::MarkIniSettingsDirty();
+ }
+
+ // the one Begin() for panels, so the name DockBuilder docked against and the
+ // name the window opens under can't drift apart.
+ auto UILayer::begin_panel(TabState& tab, Panel p) -> bool
+ {
+ // the first frame back must not focus every re-appearing panel, or the last
+ // Begin() in a node holding several would steal that node's selected tab.
+ const ImGuiWindowFlags flags = tab.appearing ? ImGuiWindowFlags_NoFocusOnAppearing : 0;
+ return ImGui::Begin(tab.name[(int)p].c_str(), &tab.show[(int)p], flags);
+ }
+
+ auto UILayer::draw_panel(const UIContext& ctx, TabState& tab, Panel p) -> void
+ {
+ switch (p)
{
- case Layout::SceneEditing:
- m_mode = Mode::Scene;
- m_show_outliner = true; m_show_properties = true;
- m_show_black_hole = false; m_show_settings = false; m_show_export = false;
- m_show_stats = true;
- break;
- case Layout::Export:
- m_mode = Mode::Simulation;
- m_show_export = true; m_show_black_hole = true;
- m_show_outliner = false; m_show_properties = false; m_show_settings = false;
- m_show_stats = true;
- break;
- case Layout::Simulation:
- default:
- m_mode = Mode::Simulation;
- m_show_black_hole = true;
- m_show_outliner = false; m_show_properties = false;
- m_show_settings = false; m_show_export = false;
- m_show_stats = true;
- break;
+ case Panel::Outliner: panel_outliner(ctx, tab); break;
+ case Panel::Properties: panel_properties(ctx, tab); break;
+ case Panel::BlackHole: panel_black_hole(ctx, tab); break;
+ case Panel::Settings: panel_settings(ctx, tab); break;
+ case Panel::Export: panel_export(ctx, tab); break;
+ case Panel::Stats: panel_stats(ctx, tab); break;
}
}
- auto UILayer::panel_outliner(const UIContext& ctx) -> void
+ auto UILayer::panel_outliner(const UIContext& ctx, TabState& tab) -> void
{
- if (ImGui::Begin(kOutliner, &m_show_outliner))
+ if (begin_panel(tab, Panel::Outliner))
{
Scene& sc = ctx.scene;
auto& objs = sc.objects;
if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1;
- if (ImGui::Button("Add sphere") && objs.size() < 64)
+ if (accent_button("+ Sphere") && objs.size() < 64)
{
SceneObject o;
- o.position = glm::vec3(((int)objs.size() % 5) * 5.0f - 10.0f, 2.0f, ((int)objs.size() / 5) * 5.0f);
+ const int n = (int)objs.size(); // spread newcomers along a 5-wide grid
+ o.position = glm::vec3((float)(n % 5) * 5.0f - 10.0f, 2.0f, (float)(n / 5) * 5.0f);
o.radius = 1.5f;
- o.color = glm::vec3(0.35f + 0.12f * (objs.size() % 5), 0.55f, 0.9f - 0.12f * (objs.size() % 4));
+ o.color = glm::vec3(0.35f + 0.12f * (float)(n % 5), 0.55f, 0.9f - 0.12f * (float)(n % 4));
objs.push_back(o);
sc.selected_object = (int)objs.size() - 1;
}
@@ -271,48 +666,66 @@ namespace Donut
if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1;
}
ImGui::EndDisabled();
-
ImGui::SameLine();
+ ImGui::PushFont(m_font_body, kCaptionSize);
ImGui::TextDisabled("%d / 64", (int)objs.size());
- ImGui::Separator();
+ ImGui::PopFont();
+ ImGui::Spacing();
ImGui::BeginChild("obj_list");
+ // each row carries a swatch of its colour, so the list reads like the scene.
+ const float row_h = ImGui::GetTextLineHeight();
+ const float swatch = row_h * 0.72f;
for (int i = 0; i < (int)objs.size(); ++i)
{
- std::string label = "Sphere " + std::to_string(i);
- if (ImGui::Selectable(label.c_str(), sc.selected_object == i)) sc.selected_object = i;
+ ImGui::PushID(i);
+ const ImVec2 p = ImGui::GetCursorScreenPos();
+ char label[32];
+ std::snprintf(label, sizeof(label), " Sphere %d", i); // leading room for the swatch
+ if (ImGui::Selectable(label, sc.selected_object == i)) sc.selected_object = i;
+ const glm::vec3& col = objs[(size_t)i].color;
+ const ImVec2 a(p.x + 6.0f, p.y + (row_h - swatch) * 0.5f);
+ ImGui::GetWindowDrawList()->AddRectFilled(a, ImVec2(a.x + swatch, a.y + swatch),
+ ImGui::GetColorU32(ImVec4(col.r, col.g, col.b, 1.0f)), 3.0f);
+ ImGui::PopID();
}
- if (objs.empty()) ImGui::TextDisabled("No spheres. Add one above.");
+ if (objs.empty()) caption("Nothing here yet. Add a sphere above.");
ImGui::EndChild();
}
ImGui::End();
}
- auto UILayer::panel_properties(const UIContext& ctx) -> void
+ auto UILayer::panel_properties(const UIContext& ctx, TabState& tab) -> void
{
- if (ImGui::Begin(kProperties, &m_show_properties))
+ if (begin_panel(tab, Panel::Properties))
{
Scene& sc = ctx.scene;
auto& objs = sc.objects;
if (objs.empty() || sc.selected_object < 0 || sc.selected_object >= (int)objs.size())
{
- ImGui::TextDisabled("Select a sphere in the Outliner.");
+ caption("Select a sphere in the Outliner to edit it.");
}
else
{
- SceneObject& o = objs[sc.selected_object];
+ SceneObject& o = objs[(size_t)sc.selected_object];
+ ImGui::PushFont(m_font_heading, kHeadSize);
ImGui::Text("Sphere %d", sc.selected_object);
+ ImGui::PopFont();
ImGui::Separator();
- ImGui::DragFloat3("Position", &o.position.x, 0.1f);
- ImGui::DragFloat("Radius", &o.radius, 0.05f, 0.1f, 20.0f);
- ImGui::ColorEdit3("Color", &o.color.x);
+ ImGui::Spacing();
+
+ prop("Position"); ImGui::DragFloat3("##pos", &o.position.x, 0.1f);
+ prop("Radius"); ImGui::DragFloat("##rad", &o.radius, 0.05f, 0.1f, 20.0f);
+ prop("Colour"); ImGui::ColorEdit3("##col", &o.color.x);
ImGui::Spacing();
- if (ImGui::RadioButton("Move", m_gizmo_op == 0)) m_gizmo_op = 0;
- ImGui::SameLine();
- if (ImGui::RadioButton("Scale", m_gizmo_op == 1)) m_gizmo_op = 1;
+ ImGui::PushFont(m_font_body, kCaptionSize);
+ ImGui::TextDisabled("GIZMO");
+ ImGui::PopFont();
+ static const char* const ops[] = { "Move", "Scale" };
+ segmented("gizmo_op", ops, 2, m_gizmo_op);
- if (m_mode == Mode::Scene)
+ if (tab.view == View::Scene)
{
// viewport gizmo for the selected sphere, using the scene camera
// (its projection was set for this frame by the Application). drawn
@@ -333,64 +746,62 @@ namespace Donut
o.position = glm::vec3(t[0], t[1], t[2]);
o.radius = std::max(0.05f, (s[0] + s[1] + s[2]) / 3.0f);
}
- ImGui::TextDisabled("Drag the handles in the viewport.");
+ caption("Drag the handles in the viewport.");
}
else
{
- ImGui::TextDisabled("Switch View to Scene to drag the gizmo.");
+ caption("The gizmo lives in the Scene Editor's viewport; the numbers work anywhere.");
}
}
}
ImGui::End();
}
- auto UILayer::panel_black_hole(const UIContext& ctx) -> void
+ auto UILayer::panel_black_hole(const UIContext& ctx, TabState& tab) -> void
{
- if (ImGui::Begin(kBlackHole, &m_show_black_hole))
+ if (begin_panel(tab, Panel::BlackHole))
{
Scene& sc = ctx.scene;
BlackHoleParams& bh = sc.black_hole;
- if (ImGui::CollapsingHeader("Camera", ImGuiTreeNodeFlags_DefaultOpen))
+ if (section("Camera"))
{
- bool ff = sc.is_free_fly();
- if (ImGui::RadioButton("Orbital", !ff)) sc.set_free_fly(false);
- ImGui::SameLine();
- if (ImGui::RadioButton("Free-fly", ff)) sc.set_free_fly(true);
- ImGui::SameLine();
- if (ImGui::Button("Reset view")) sc.reset_sim_camera();
- ImGui::SliderFloat("FOV", &bh.fov_degrees, 20.0f, 90.0f, "%.0f\xc2\xb0");
- ImGui::TextDisabled(ff ? "WASD move | Q/E down-up | Shift boost | drag look"
- : "Drag to orbit | scroll to zoom");
+ static const char* const modes[] = { "Orbital", "Free-fly" };
+ int mode = sc.is_free_fly() ? 1 : 0;
+ if (segmented("cam_mode", modes, 2, mode)) sc.set_free_fly(mode == 1);
+ if (ImGui::Button("Reset view", ImVec2(-FLT_MIN, 0.0f))) sc.reset_sim_camera();
+ prop("Field of view"); ImGui::SliderFloat("##fov", &bh.fov_degrees, 20.0f, 90.0f, "%.0f\xc2\xb0");
+ caption(sc.is_free_fly() ? "WASD to move \xc2\xb7 Q/E down and up \xc2\xb7 Shift to boost \xc2\xb7 drag to look"
+ : "Drag to orbit \xc2\xb7 scroll to zoom");
}
- if (ImGui::CollapsingHeader("Accretion disk", ImGuiTreeNodeFlags_DefaultOpen))
+ if (section("Accretion disk"))
{
- ImGui::SliderFloat("Temperature", &bh.temperature, 2000.0f, 15000.0f, "%.0f K");
- ImGui::SliderFloat("Brightness", &bh.brightness, 0.0f, 3.0f, "%.2f");
- ImGui::SliderFloat("Inner radius", &bh.disk_inner_rs, 3.0f, 12.0f, "%.1f r_s");
+ prop("Temperature"); ImGui::SliderFloat("##temp", &bh.temperature, 2000.0f, 15000.0f, "%.0f K");
+ prop("Brightness"); ImGui::SliderFloat("##bright", &bh.brightness, 0.0f, 3.0f, "%.2f");
+ prop("Inner radius"); ImGui::SliderFloat("##rin", &bh.disk_inner_rs, 3.0f, 12.0f, "%.1f r_s");
if (bh.disk_outer_rs < bh.disk_inner_rs + 0.5f) bh.disk_outer_rs = bh.disk_inner_rs + 0.5f;
- ImGui::SliderFloat("Outer radius", &bh.disk_outer_rs, bh.disk_inner_rs + 0.5f, 25.0f, "%.1f r_s");
- ImGui::SliderFloat("Turbulence", &bh.turbulence, 0.0f, 2.0f, "%.2f");
- ImGui::TextDisabled("Inner edge is the ISCO (3 r_s). Novikov-Thorne profile.");
+ prop("Outer radius"); ImGui::SliderFloat("##rout", &bh.disk_outer_rs, bh.disk_inner_rs + 0.5f, 25.0f, "%.1f r_s");
+ prop("Turbulence"); ImGui::SliderFloat("##turb", &bh.turbulence, 0.0f, 2.0f, "%.2f");
+ caption("The inner edge sits at the ISCO (3 r_s). Novikov-Thorne temperature profile.");
}
- if (ImGui::CollapsingHeader("Quality"))
+ if (section("Quality", false))
{
- ImGui::SliderInt("Integration steps", &bh.quality_steps, 2000, 15000);
- ImGui::TextDisabled("Deeper lensing at higher cost. Settled frame is 4x supersampled.");
+ prop("Steps"); ImGui::SliderInt("##steps", &bh.quality_steps, 2000, 15000);
+ caption("More steps resolve the disk at steep angles, at a cost. Settled frames are 4x supersampled.");
}
}
ImGui::End();
}
- auto UILayer::panel_settings(const UIContext& ctx) -> void
+ auto UILayer::panel_settings(const UIContext& ctx, TabState& tab) -> void
{
- if (ImGui::Begin(kSettings, &m_show_settings))
+ if (begin_panel(tab, Panel::Settings))
{
auto& s = SettingsManager::get_settings();
- if (ImGui::CollapsingHeader("Display", ImGuiTreeNodeFlags_DefaultOpen))
+ if (section("Display"))
{
const bool fs = ctx.actions.is_fullscreen();
int cw = 0, ch = 0; ctx.actions.get_window_size(cw, ch);
@@ -402,7 +813,8 @@ namespace Donut
};
char cur_label[32]; std::snprintf(cur_label, sizeof(cur_label), "%d x %d", cw, ch);
ImGui::BeginDisabled(fs);
- if (ImGui::BeginCombo("Resolution", cur_label))
+ prop("Resolution");
+ if (ImGui::BeginCombo("##res", cur_label))
{
for (const auto& r : kRes)
if (ImGui::Selectable(r.label, cw == r.w && ch == r.h))
@@ -412,54 +824,63 @@ namespace Donut
ImGui::EndDisabled();
bool fs_v = fs;
- if (ImGui::Checkbox("Fullscreen", &fs_v)) ctx.actions.set_fullscreen(fs_v);
+ prop("Fullscreen"); if (ImGui::Checkbox("##fs", &fs_v)) ctx.actions.set_fullscreen(fs_v);
- if (ImGui::Checkbox("V-Sync", &s.graphics.v_sync_enabled))
+ prop("V-Sync");
+ if (ImGui::Checkbox("##vsync", &s.graphics.v_sync_enabled))
{
ctx.actions.set_vsync(s.graphics.v_sync_enabled);
SettingsManager::save_settings();
}
ImGui::BeginDisabled(s.graphics.v_sync_enabled);
- if (ImGui::SliderInt("Frame cap", &s.simulation.target_fps, 30, 240, "%d FPS"))
+ prop("Frame cap");
+ if (ImGui::SliderInt("##cap", &s.simulation.target_fps, 30, 240, "%d fps"))
SettingsManager::save_settings();
ImGui::EndDisabled();
- if (s.graphics.v_sync_enabled)
- ImGui::TextDisabled("Frame cap applies only with V-Sync off.");
+ if (s.graphics.v_sync_enabled) caption("The frame cap only applies with V-Sync off.");
- if (ImGui::SliderFloat("UI scale", &m_ui_scale, 0.8f, 1.6f, "%.2fx"))
+ prop("UI scale");
+ if (ImGui::SliderFloat("##scale", &m_ui_scale, 0.8f, 1.6f, "%.2fx"))
ctx.actions.set_ui_scale(m_ui_scale);
}
- if (ImGui::CollapsingHeader("Renderer", ImGuiTreeNodeFlags_DefaultOpen))
+ if (section("Renderer"))
{
const char* apis[] = { "OpenGL", "Vulkan" };
int cur = (s.graphics.render_api == "Vulkan") ? 1 : 0;
- if (ImGui::Combo("Graphics API", &cur, apis, 2))
+ prop("Graphics API");
+ if (ImGui::Combo("##api", &cur, apis, 2))
{
s.graphics.render_api = apis[cur];
SettingsManager::save_settings();
}
const char* running = (RendererAPI::get_api() == RendererAPI::API::Vulkan) ? "Vulkan" : "OpenGL";
if (s.graphics.render_api != running)
- ImGui::TextColored(ImVec4(0.98f, 0.62f, 0.20f, 1.0f), "Restart to apply (%s running)", running);
+ ImGui::TextColored(kAmber, "Restart to apply (%s is running)", running);
else
- ImGui::TextDisabled("Active backend: %s (%s)", running, ctx.device_name.c_str());
+ {
+ char note[160];
+ std::snprintf(note, sizeof(note), "Running %s on %s.", running, ctx.device_name.c_str());
+ caption(note);
+ }
}
- if (ImGui::CollapsingHeader("Overlay", ImGuiTreeNodeFlags_DefaultOpen))
+ if (section("Overlay"))
{
bool dirty = false;
- dirty |= ImGui::Checkbox("Show FPS", &s.graphics.show_fps);
- dirty |= ImGui::Checkbox("Show frame time", &s.graphics.show_performance_metrics);
+ prop("Show fps"); dirty |= ImGui::Checkbox("##showfps", &s.graphics.show_fps);
+ prop("Show frame time"); dirty |= ImGui::Checkbox("##showms", &s.graphics.show_performance_metrics);
if (dirty) SettingsManager::save_settings();
+ caption("Shown in the status bar along the bottom.");
}
- if (ImGui::CollapsingHeader("Environment", ImGuiTreeNodeFlags_DefaultOpen))
+ if (section("Environment"))
{
auto& hdri = HDRIManager::get();
std::string preview = hdri.get_hdri_name(ctx.scene.hdri_path);
- if (ImGui::BeginCombo("Starfield", preview.c_str()))
+ prop("Starfield");
+ if (ImGui::BeginCombo("##hdri", preview.c_str()))
{
for (const auto& path : hdri.get_available_hdri())
{
@@ -470,38 +891,40 @@ namespace Donut
}
ImGui::EndCombo();
}
- ImGui::TextDisabled("Switching rebuilds the cubemap (brief pause).");
+ caption("Switching rebuilds the cubemap (a brief pause).");
}
}
ImGui::End();
}
- auto UILayer::panel_export(const UIContext& ctx) -> void
+ auto UILayer::panel_export(const UIContext& ctx, TabState& tab) -> void
{
- if (ImGui::Begin(kExport, &m_show_export))
+ if (begin_panel(tab, Panel::Export))
{
- ImGui::TextDisabled("Render the black hole + observables to disk.");
+ caption("Render the black hole and its observables to disk.");
ImGui::Spacing();
- if (ImGui::CollapsingHeader("Channels", ImGuiTreeNodeFlags_DefaultOpen))
+
+ if (section("Channels"))
{
- ImGui::Checkbox("Colour (tone-mapped)", &m_exp_color);
- ImGui::Checkbox("Redshift (g)", &m_exp_redshift);
- ImGui::Checkbox("Emission temperature", &m_exp_temperature);
- ImGui::Checkbox("Impact parameter", &m_exp_impact);
- ImGui::TextDisabled("Redshift & temperature are disk-only; impact is whole-frame.");
+ ImGui::Checkbox("Colour (tone-mapped)", &m_exp_color);
+ ImGui::Checkbox("Redshift g", &m_exp_redshift);
+ ImGui::Checkbox("Emission temperature", &m_exp_temperature);
+ ImGui::Checkbox("Impact parameter", &m_exp_impact);
+ caption("Redshift and temperature are disk-only; impact covers the whole frame.");
}
- if (ImGui::CollapsingHeader("Output", ImGuiTreeNodeFlags_DefaultOpen))
+
+ if (section("Output"))
{
- ImGui::Combo("Resolution", &m_exp_resolution, "1280 x 720\0" "1920 x 1080\0" "2560 x 1440\0" "3840 x 2160\0");
- ImGui::Combo("Format", &m_exp_format, "Display image (PNG)\0" "Raw float image (PFM)\0" "Raw values (CSV)\0");
- if (m_exp_format == 0) ImGui::TextDisabled("Tone-mapped colour / false-coloured observables.");
- else ImGui::TextDisabled("Actual physical values: g, temperature (K), impact (r_s).");
+ prop("Resolution"); ImGui::Combo("##res", &m_exp_resolution, "1280 x 720\0" "1920 x 1080\0" "2560 x 1440\0" "3840 x 2160\0");
+ prop("Format"); ImGui::Combo("##fmt", &m_exp_format, "Display image (PNG)\0" "Raw float image (PFM)\0" "Raw values (CSV)\0");
+ caption(m_exp_format == 0 ? "Tone-mapped colour, or false-coloured observables."
+ : "Actual physical values: g, temperature (K), impact (r_s).");
}
ImGui::Spacing();
const bool any = m_exp_color || m_exp_redshift || m_exp_temperature || m_exp_impact;
ImGui::BeginDisabled(!any);
- if (ImGui::Button("Export frame", ImVec2(-1.0f, 0.0f)))
+ if (accent_button("Export frame", ImVec2(-FLT_MIN, 0.0f)))
{
static const int dims[][2] = { {1280,720}, {1920,1080}, {2560,1440}, {3840,2160} };
ExportConfig cfg;
@@ -516,31 +939,68 @@ namespace Donut
if (m_exp_reported)
{
+ ImGui::Spacing();
if (m_exp_written > 0)
- ImGui::TextColored(ImVec4(0.55f, 0.85f, 0.45f, 1.0f), "Wrote %d file(s) to exports/", m_exp_written);
+ ImGui::TextColored(kGreen, "Wrote %d file(s) to exports/", m_exp_written);
else
- ImGui::TextColored(ImVec4(0.95f, 0.45f, 0.35f, 1.0f), "Export failed (see log)");
+ ImGui::TextColored(kRed, "Export failed (see log)");
}
}
ImGui::End();
}
- auto UILayer::panel_stats(const UIContext& ctx) -> void
+ auto UILayer::panel_stats(const UIContext& ctx, TabState& tab) -> void
{
- if (ImGui::Begin(kStats, &m_show_stats))
+ if (begin_panel(tab, Panel::Stats))
{
ImGuiIO& io = ImGui::GetIO();
- ImGui::TextDisabled("%s", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str());
- const bool show_fps = SettingsManager::get_show_fps();
- const bool show_ms = SettingsManager::get_show_performance_metrics();
- if (show_fps) ImGui::Text("%.0f FPS", io.Framerate);
- if (show_ms)
+ const float ms = io.DeltaTime * 1000.0f;
+ m_frame_hist[m_frame_hist_i] = ms;
+ m_frame_hist_i = (m_frame_hist_i + 1) % kFrameHist;
+
+ // the headline number.
+ ImGui::PushFont(m_font_heading, kBigSize);
+ ImGui::Text("%.0f", io.Framerate);
+ ImGui::PopFont();
+ ImGui::SameLine(0.0f, 8.0f);
+ ImGui::PushFont(m_font_body, kCaptionSize);
+ ImGui::TextDisabled("fps \xc2\xb7 %.2f ms", ms);
+ ImGui::PopFont();
+
+ // frame-time graph, autoscaled to the worst recent frame.
+ float worst = 0.0f;
+ for (float v : m_frame_hist) worst = std::max(worst, v);
+ ImGui::PlotLines("##frametime", m_frame_hist, kFrameHist, m_frame_hist_i, nullptr,
+ 0.0f, std::max(worst * 1.15f, 4.0f), ImVec2(-FLT_MIN, 64.0f));
+ caption("Frame time over the last 120 frames.");
+ ImGui::Spacing();
+
+ // the facts, as an aligned two-column table: a fixed label column and a
+ // stretch value column. (SizingStretchProp would derive the weights from
+ // measured content widths, which on the first frame is 0/0 = NaN and
+ // poisons the window's content size — UBSan caught it.)
+ if (ImGui::BeginTable("facts", 2, ImGuiTableFlags_RowBg))
{
- if (show_fps) ImGui::SameLine();
- ImGui::TextDisabled("%.1f ms/frame", io.Framerate > 0 ? 1000.0f / io.Framerate : 0.0f);
+ ImGui::TableSetupColumn("key", ImGuiTableColumnFlags_WidthFixed, 118.0f);
+ ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch);
+ auto row = [](const char* key, const char* value)
+ {
+ ImGui::TableNextRow();
+ ImGui::TableSetColumnIndex(0); ImGui::TextDisabled("%s", key);
+ ImGui::TableSetColumnIndex(1); ImGui::TextUnformatted(value);
+ };
+ char buf[64];
+ row("Device", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str());
+ row("API", RendererAPI::get_api() == RendererAPI::API::Vulkan ? "Vulkan (MoltenVK)" : "OpenGL 4.1");
+ row("Workspace", tab.label);
+ std::snprintf(buf, sizeof(buf), "%.0f x %.0f", io.DisplaySize.x * io.DisplayFramebufferScale.x,
+ io.DisplaySize.y * io.DisplayFramebufferScale.y);
+ row("Framebuffer", buf);
+ std::snprintf(buf, sizeof(buf), "%d", ctx.scene.black_hole.quality_steps); row("Integration steps", buf);
+ std::snprintf(buf, sizeof(buf), "%.0f K", ctx.scene.black_hole.temperature); row("Disk temperature", buf);
+ std::snprintf(buf, sizeof(buf), "%d", (int)ctx.scene.objects.size()); row("Scene objects", buf);
+ ImGui::EndTable();
}
- if (!show_fps && !show_ms)
- ImGui::TextDisabled("Enable FPS / frame time in Settings > Overlay.");
}
ImGui::End();
}
diff --git a/src/ui/ui_layer.h b/src/ui/ui_layer.h
index b913bc4..4479844 100644
--- a/src/ui/ui_layer.h
+++ b/src/ui/ui_layer.h
@@ -3,49 +3,79 @@
#include "workspace.h" // AppActions, UIContext
#include "rendering/view.h"
+#include <string>
+
+struct ImFont;
+
namespace Donut
{
- // the application's docking shell. draws a main menu bar and a full-viewport
- // dock space with a pass-through centre (the live 3D render shows through the
- // middle), then a set of dockable tool panels. ships a few default layouts
- // built with the DockBuilder API and lets the user rearrange, hide, or restore
- // panels like a normal desktop app. draw() returns the View the RenderPath
- // should draw this frame.
+ // the workspaces and the panels they're built from.
+ enum class Tab { Simulation, SceneEditor, Export };
+ enum class Panel { Outliner, Properties, BlackHole, Settings, Export, Stats };
+ inline constexpr int kTabCount = 3;
+ inline constexpr int kPanelCount = 6;
+
+ // the application's docking shell: a main menu bar, a workspace tab strip
+ // ([ Simulation ] [ Scene Editor ] [ Export ]), a bottom status bar, and one
+ // full-viewport dock space per workspace with a pass-through centre so the
+ // live 3D render shows through. every workspace keeps its own dock tree and
+ // its own panel instances, so rearranging one never touches the others, and
+ // ImGui persists all of them in imgui.ini. draw() returns the View the
+ // RenderPath should draw this frame.
class UILayer
{
public:
- UILayer() = default;
+ UILayer();
+
+ // loads the Inter faces and registers the workspace ini section on the
+ // live ImGui context. call once after the device's init_imgui() and before
+ // the first draw(): ImGui reads imgui.ini on its first NewFrame.
+ auto init() -> void;
auto draw(const UIContext& ctx) -> View;
private:
- // which 3D view fills the pass-through centre.
- enum class Mode { Scene, Simulation };
- // a named default arrangement of the panels.
- enum class Layout { Simulation, SceneEditing, Export };
+ // one workspace: a fixed centre view, its own dock tree, its own window
+ // instances of the panels it owns.
+ struct TabState
+ {
+ Tab id = Tab::Simulation;
+ const char* label = nullptr; // "Scene Editor"
+ const char* key = nullptr; // "scene": the ### suffix and dock-id salt
+ View view = View::BlackHole;
+ unsigned int dock_id = 0; // ImHashStr("donut.dock.<key>")
+ std::string name[kPanelCount]; // "Black Hole###scene.black_hole"
+ bool allowed[kPanelCount] = {}; // belongs to this workspace
+ bool defaults[kPanelCount] = {}; // open after a seed / reset
+ bool show[kPanelCount] = {}; // open right now
+ bool reset = false; // rebuild the tree before it's next submitted
+ bool appearing = false; // first frame back: panels mustn't grab focus
+ };
auto draw_menu_bar(const UIContext& ctx) -> void;
- auto build_layout(unsigned int dockspace_id) -> void; // DockBuilder tree
- auto apply_layout(Layout layout) -> void; // preset: view + panels
+ auto draw_tab_strip() -> void;
+ auto draw_status_bar(const UIContext& ctx) -> void;
+ auto build_layout(TabState& tab) -> void; // DockBuilder seed for one workspace
+ auto request_tab(int index) -> void { m_tab_request = index; }
+ auto sync_persisted() -> void;
- auto panel_outliner(const UIContext& ctx) -> void;
- auto panel_properties(const UIContext& ctx) -> void;
- auto panel_black_hole(const UIContext& ctx) -> void;
- auto panel_settings(const UIContext& ctx) -> void;
- auto panel_export(const UIContext& ctx) -> void;
- auto panel_stats(const UIContext& ctx) -> void;
+ auto begin_panel(TabState& tab, Panel p) -> bool;
+ auto draw_panel(const UIContext& ctx, TabState& tab, Panel p) -> void;
+ auto panel_outliner(const UIContext& ctx, TabState& tab) -> void;
+ auto panel_properties(const UIContext& ctx, TabState& tab) -> void;
+ auto panel_black_hole(const UIContext& ctx, TabState& tab) -> void;
+ auto panel_settings(const UIContext& ctx, TabState& tab) -> void;
+ auto panel_export(const UIContext& ctx, TabState& tab) -> void;
+ auto panel_stats(const UIContext& ctx, TabState& tab) -> void;
- Mode m_mode = Mode::Simulation;
- Layout m_layout = Layout::Simulation;
- bool m_rebuild_layout = false; // rebuild the dock tree next frame
- bool m_first_frame = true; // seed the default layout once, if no saved one
+ // styled building blocks the panels share (they need the fonts, hence members)
+ auto section(const char* title, bool default_open = true) -> bool; // collapsible heading
+ auto caption(const char* text) -> void; // muted helper text
+ auto segmented(const char* id, const char* const* labels, int count, int& value) -> bool;
- // panel visibility (defaults match the Simulation layout the app opens in).
- bool m_show_outliner = false;
- bool m_show_properties = false;
- bool m_show_black_hole = true;
- bool m_show_settings = false;
- bool m_show_export = false;
- bool m_show_stats = true;
+ TabState m_tabs[kTabCount];
+ int m_active_tab = 0; // index into m_tabs; the source of truth
+ int m_tab_request = -1; // one-frame programmatic strip selection
+ bool m_first_frame = true;
// properties-panel gizmo op (0 = translate, 1 = scale); cast in the .cpp.
int m_gizmo_op = 0;
@@ -57,5 +87,14 @@ namespace Donut
bool m_exp_color = true, m_exp_redshift = false, m_exp_temperature = false, m_exp_impact = false;
int m_exp_resolution = 1, m_exp_format = 0;
int m_exp_written = 0; bool m_exp_reported = false;
+
+ // typography: two faces, sized at PushFont time (ImGui 1.92 rasterises on demand).
+ ImFont* m_font_body = nullptr; // Inter Regular
+ ImFont* m_font_heading = nullptr; // Inter SemiBold
+
+ // rolling frame-time history (ms) for the diagnostics graph.
+ static constexpr int kFrameHist = 120;
+ float m_frame_hist[kFrameHist] = {};
+ int m_frame_hist_i = 0;
};
}