#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 #include // DockBuilder*, DockSpace keep-alive, BeginViewportSideBar, ImGuiSettingsHandler #include #include #include #include #include #include #include #include namespace Donut { // 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; }; 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 = 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); ImVec4* c = s.Colors; 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(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 { static bool styled = false; if (!styled) { apply_donut_style(); styled = true; } ImGuizmo::BeginFrame(); // the Properties panel draws the scene gizmo // 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; 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; } // 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); for (int p = 0; p < kPanelCount; ++p) if (tab.allowed[p] && tab.show[p]) draw_panel(ctx, tab, static_cast(p)); tab.appearing = false; sync_persisted(); return tab.view; } auto UILayer::draw_menu_bar(const UIContext& /*ctx*/) -> void { if (!ImGui::BeginMainMenuBar()) return; // 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")) { 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::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")) { 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(); } ImGui::EndMainMenuBar(); } // 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 { // 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(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); auto dock = [&](Panel p, ImGuiID node) { ImGui::DockBuilderDockWindow(tab.name[(int)p].c_str(), node); }; ImGuiID centre = root; switch (tab.id) { case Tab::SceneEditor: { ImGuiID left = 0, left_bottom = 0, right = 0; ImGui::DockBuilderSplitNode(centre, ImGuiDir_Left, 0.24f, &left, ¢re); ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.50f, &left_bottom, &left); ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.24f, &right, ¢re); dock(Panel::Outliner, left); dock(Panel::Properties, left_bottom); dock(Panel::Stats, right); dock(Panel::Settings, right); break; } case Tab::Export: { ImGuiID right = 0, right_bottom = 0; ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.28f, &right, ¢re); ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.50f, &right_bottom, &right); 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 Tab::Simulation: { ImGuiID right = 0, right_bottom = 0; ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.24f, &right, ¢re); 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(root); std::copy(std::begin(tab.defaults), std::end(tab.defaults), tab.show); } // 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 { 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 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, TabState& tab) -> void { 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 (accent_button("+ Sphere") && objs.size() < 64) { SceneObject o; 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 * (float)(n % 5), 0.55f, 0.9f - 0.12f * (float)(n % 4)); objs.push_back(o); sc.selected_object = (int)objs.size() - 1; } ImGui::SameLine(); ImGui::BeginDisabled(objs.empty()); if (ImGui::Button("Delete") && !objs.empty()) { objs.erase(objs.begin() + sc.selected_object); 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::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) { 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()) caption("Nothing here yet. Add a sphere above."); ImGui::EndChild(); } ImGui::End(); } auto UILayer::panel_properties(const UIContext& ctx, TabState& tab) -> void { 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()) { caption("Select a sphere in the Outliner to edit it."); } else { 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::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(); 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 (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 // into the background list so it sits over the render, under panels. ImGuizmo::SetOrthographic(false); ImGuizmo::SetDrawlist(ImGui::GetBackgroundDrawList()); ImGuiIO& io = ImGui::GetIO(); ImGuizmo::SetRect(0.0f, 0.0f, io.DisplaySize.x, io.DisplaySize.y); glm::mat4 view = sc.scene_camera.get_view_matrix(); glm::mat4 proj = sc.scene_camera.get_projection_matrix(); glm::mat4 xform = glm::translate(glm::mat4(1.0f), o.position) * glm::scale(glm::mat4(1.0f), glm::vec3(o.radius)); ImGuizmo::OPERATION op = (m_gizmo_op == 1) ? ImGuizmo::SCALE : ImGuizmo::TRANSLATE; if (ImGuizmo::Manipulate(&view[0][0], &proj[0][0], op, ImGuizmo::WORLD, &xform[0][0])) { float t[3], r[3], s[3]; ImGuizmo::DecomposeMatrixToComponents(&xform[0][0], t, r, s); o.position = glm::vec3(t[0], t[1], t[2]); o.radius = std::max(0.05f, (s[0] + s[1] + s[2]) / 3.0f); } caption("Drag the handles in the viewport."); } else { caption("The gizmo lives in the Scene Editor's viewport; the numbers work anywhere."); } } } ImGui::End(); } auto UILayer::panel_black_hole(const UIContext& ctx, TabState& tab) -> void { if (begin_panel(tab, Panel::BlackHole)) { Scene& sc = ctx.scene; BlackHoleParams& bh = sc.black_hole; if (section("Camera")) { 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 (section("Accretion disk")) { 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; 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 (section("Quality", false)) { 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, TabState& tab) -> void { if (begin_panel(tab, Panel::Settings)) { auto& s = SettingsManager::get_settings(); if (section("Display")) { const bool fs = ctx.actions.is_fullscreen(); int cw = 0, ch = 0; ctx.actions.get_window_size(cw, ch); struct Res { const char* label; int w, h; }; static const Res kRes[] = { { "1280 x 720", 1280, 720 }, { "1600 x 900", 1600, 900 }, { "1920 x 1080", 1920, 1080 }, { "2560 x 1440", 2560, 1440 }, }; char cur_label[32]; std::snprintf(cur_label, sizeof(cur_label), "%d x %d", cw, ch); ImGui::BeginDisabled(fs); prop("Resolution"); if (ImGui::BeginCombo("##res", cur_label)) { for (const auto& r : kRes) if (ImGui::Selectable(r.label, cw == r.w && ch == r.h)) ctx.actions.set_resolution(r.w, r.h); ImGui::EndCombo(); } ImGui::EndDisabled(); bool fs_v = fs; prop("Fullscreen"); if (ImGui::Checkbox("##fs", &fs_v)) ctx.actions.set_fullscreen(fs_v); 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); 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) caption("The frame cap only applies with V-Sync off."); prop("UI scale"); if (ImGui::SliderFloat("##scale", &m_ui_scale, 0.8f, 1.6f, "%.2fx")) ctx.actions.set_ui_scale(m_ui_scale); } if (section("Renderer")) { const char* apis[] = { "OpenGL", "Vulkan" }; int cur = (s.graphics.render_api == "Vulkan") ? 1 : 0; 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(kAmber, "Restart to apply (%s is running)", running); else { char note[160]; std::snprintf(note, sizeof(note), "Running %s on %s.", running, ctx.device_name.c_str()); caption(note); } } if (section("Overlay")) { bool dirty = false; 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 (section("Environment")) { auto& hdri = HDRIManager::get(); std::string preview = hdri.get_hdri_name(ctx.scene.hdri_path); prop("Starfield"); if (ImGui::BeginCombo("##hdri", preview.c_str())) { for (const auto& path : hdri.get_available_hdri()) { bool selected = (path == ctx.scene.hdri_path); if (ImGui::Selectable(hdri.get_hdri_name(path).c_str(), selected)) ctx.scene.hdri_path = path; // RenderPath rebuilds the cubemap next frame if (selected) ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); } caption("Switching rebuilds the cubemap (a brief pause)."); } } ImGui::End(); } auto UILayer::panel_export(const UIContext& ctx, TabState& tab) -> void { if (begin_panel(tab, Panel::Export)) { caption("Render the black hole and its observables to disk."); ImGui::Spacing(); 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); caption("Redshift and temperature are disk-only; impact covers the whole frame."); } if (section("Output")) { 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 (accent_button("Export frame", ImVec2(-FLT_MIN, 0.0f))) { static const int dims[][2] = { {1280,720}, {1920,1080}, {2560,1440}, {3840,2160} }; ExportConfig cfg; cfg.color = m_exp_color; cfg.redshift = m_exp_redshift; cfg.temperature = m_exp_temperature; cfg.impact = m_exp_impact; cfg.width = dims[m_exp_resolution][0]; cfg.height = dims[m_exp_resolution][1]; cfg.format = static_cast(m_exp_format); m_exp_written = ctx.actions.export_frame(cfg); m_exp_reported = true; } ImGui::EndDisabled(); if (m_exp_reported) { ImGui::Spacing(); if (m_exp_written > 0) ImGui::TextColored(kGreen, "Wrote %d file(s) to exports/", m_exp_written); else ImGui::TextColored(kRed, "Export failed (see log)"); } } ImGui::End(); } auto UILayer::panel_stats(const UIContext& ctx, TabState& tab) -> void { if (begin_panel(tab, Panel::Stats)) { ImGuiIO& io = ImGui::GetIO(); 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)) { 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(); } } ImGui::End(); } }