From cdbd98f04b234e16182d12c058108fecb30b23a4 Mon Sep 17 00:00:00 2001 From: Toastconcern Date: Wed, 21 Oct 2026 12:18:39 -0500 Subject: [PATCH] Studio: editor-dock layout - the window is laid out as an editor dock: a tool strip (drawn icons) on the left edge, the Explorer, the 3D view, and a tabbed Inspector on the right (Selected, World, Environment, Sound, Headset); the timeline and the status bar run along the bottom - the old one-piece properties panel is split into one function per tab; selecting something shows the Selected tab - the Frame menu is now Headset (and opens the Headset panel); the tuning tooltip describes the paced walk Co-Authored-By: Claude Opus 5.5 --- src/switcher.cpp | 2 +- studio/studio.cpp | 454 ++++++++++++++++++++++++++++++---------------- 2 files changed, 298 insertions(+), 158 deletions(-) diff --git a/src/switcher.cpp b/src/switcher.cpp index 2e98eda..a013fcb 100644 --- a/src/switcher.cpp +++ b/src/switcher.cpp @@ -1203,7 +1203,7 @@ struct Panel { sc[0].content = envContent(); if (envs.empty()) { const float cy = kTop + (H - kTop) * 0.42f; - const std::string a = "No environments yet", b = "Send one from Frame Studio: Frame > Install on Steam Frame."; + const std::string a = "No environments yet", b = "Send one from Frame Studio: Headset > Install on Steam Frame."; head.draw(cv, (W - head.width(a)) * 0.5f, cy - 40, a, kText); small.draw(cv, (W - small.width(b)) * 0.5f, cy + 8, b, kMuted); return; diff --git a/studio/studio.cpp b/studio/studio.cpp index 4bb82d3..63ceacb 100644 --- a/studio/studio.cpp +++ b/studio/studio.cpp @@ -656,7 +656,7 @@ struct Studio { std::string frameStatus = "Not sent yet"; char frameHost[128] = {}; // ssh target for the headset (frame-host.txt; default kFrameHost) - // Frame > Install on Steam Frame: Frame Home + environments, as the headset's environment + // Headset > Install on Steam Frame: Frame Home + environments, as the headset's environment bool showInstall = false; std::thread installer; std::atomic installing{false}; @@ -674,8 +674,7 @@ struct Studio { ImVec2 clickPos; bool clickArmed = false; bool gizmoWasUsing = false; - float outlinerW = 0, propertiesW = 0; - float explorerFrac = 0.58f; // share of the right-hand column the explorer takes (the rest: properties) + float outlinerW = 0, propertiesW = 0; // the Explorer (left) and the Inspector (right), dragged by their splitters std::vector clip; // selection wire: clip-space vertices of the selected mesh char filter[128] = {}; std::set collapsed; // outliner groups the user folded @@ -2107,7 +2106,7 @@ struct Studio { }); } - // Frame > Send to Frame: the environment as it is now (saved or not), as one file, becomes the headset's current one. + // Headset > Send to Frame: the environment as it is now (saved or not), as one file, becomes the headset's current one. void sendToFrame() { if (!scene || sending || uploading) return; sendAsOneFile(); @@ -4093,10 +4092,11 @@ struct Studio { ImGui::TextDisabled("%s", walkSaved() ? "Walking: the walkable area saved with the home" : "Walking: the walkable area Frame Home works out at load"); ImGui::EndMenu(); } - if (ImGui::BeginMenu("Frame")) { + if (ImGui::BeginMenu("Headset")) { if (ImGui::MenuItem("Send to Frame", nullptr, false, scene != nullptr && !sending)) sendToFrame(); ImGui::Separator(); if (ImGui::MenuItem("Install on Steam Frame\u2026")) openInstall(); + if (ImGui::MenuItem("Headset Panel")) showInspectorTab(InspectorTab::Headset); ImGui::EndMenu(); } if (ImGui::BeginMenu("Debug")) { @@ -4173,10 +4173,134 @@ struct Studio { } // Play / pause, the scrubber over the home's animation loop, the time, and the ambience mute. - void timeline(ImDrawList* dl, const ImVec2& end, float h) { - const ImVec2 a(viewPos.x, end.y - h); - dl->AddRectFilled(a, end, col(kBar, 0.85f)); - dl->AddLine(a, ImVec2(end.x, a.y), col(0xffffff, 0.06f), 1.0f); + // ── The tool strip (left edge): the transform tools, Draw Floor, then how the gizmo and the view behave ── + enum class ToolIcon { Move, Rotate, Scale, DrawFloor, Global, Local, PlayerView, FrameAll }; + + // A small line icon in a square of side `size` centred on `c`. + void drawToolIcon(ImDrawList* dl, ToolIcon icon, ImVec2 c, float size, ImU32 color) { + const float r = size * 0.5f, t = std::max(1.0f, 1.6f * scale); + auto arrowHead = [&](ImVec2 tip, ImVec2 dir) { // dir: unit vector the arrow points along + const float l = r * 0.32f; + const ImVec2 back(tip.x - dir.x * l, tip.y - dir.y * l), side(-dir.y * l * 0.7f, dir.x * l * 0.7f); + dl->AddTriangleFilled(tip, ImVec2(back.x + side.x, back.y + side.y), ImVec2(back.x - side.x, back.y - side.y), color); + }; + switch (icon) { + case ToolIcon::Move: { + dl->AddLine(ImVec2(c.x - r * 0.8f, c.y), ImVec2(c.x + r * 0.8f, c.y), color, t); + dl->AddLine(ImVec2(c.x, c.y - r * 0.8f), ImVec2(c.x, c.y + r * 0.8f), color, t); + arrowHead(ImVec2(c.x + r, c.y), ImVec2(1, 0)); arrowHead(ImVec2(c.x - r, c.y), ImVec2(-1, 0)); + arrowHead(ImVec2(c.x, c.y - r), ImVec2(0, -1)); arrowHead(ImVec2(c.x, c.y + r), ImVec2(0, 1)); + break; + } + case ToolIcon::Rotate: { + dl->PathArcTo(c, r * 0.72f, -1.2f, 3.9f, 24); + dl->PathStroke(color, 0, t); + arrowHead(ImVec2(c.x + std::cos(-1.2f) * r * 0.72f, c.y + std::sin(-1.2f) * r * 0.72f), ImVec2(0.93f, 0.36f)); + break; + } + case ToolIcon::Scale: { + dl->AddRect(ImVec2(c.x - r * 0.8f, c.y - r * 0.1f), ImVec2(c.x + r * 0.1f, c.y + r * 0.8f), color, 1.5f * scale, 0, t); + dl->AddLine(ImVec2(c.x - r * 0.1f, c.y + r * 0.1f), ImVec2(c.x + r * 0.7f, c.y - r * 0.7f), color, t); + arrowHead(ImVec2(c.x + r * 0.85f, c.y - r * 0.85f), ImVec2(0.707f, -0.707f)); + break; + } + case ToolIcon::DrawFloor: { + dl->AddLine(ImVec2(c.x + r * 0.75f, c.y - r * 0.75f), ImVec2(c.x - r * 0.15f, c.y + r * 0.15f), color, t * 1.6f); + dl->AddCircleFilled(ImVec2(c.x - r * 0.4f, c.y + r * 0.4f), r * 0.32f, color); + dl->AddLine(ImVec2(c.x - r * 0.95f, c.y + r * 0.9f), ImVec2(c.x + r * 0.2f, c.y + r * 0.9f), color, t); + break; + } + case ToolIcon::Global: { + dl->AddCircle(c, r * 0.8f, color, 24, t); + dl->AddEllipse(c, ImVec2(r * 0.35f, r * 0.8f), color, 0, 20, t); + dl->AddLine(ImVec2(c.x - r * 0.8f, c.y), ImVec2(c.x + r * 0.8f, c.y), color, t); + break; + } + case ToolIcon::Local: { // a little cube: its own axes + const float a = r * 0.55f, d = r * 0.3f; + dl->AddRect(ImVec2(c.x - a, c.y - a + d), ImVec2(c.x + a - d, c.y + a), color, 0, 0, t); + dl->AddLine(ImVec2(c.x - a, c.y - a + d), ImVec2(c.x - a + d, c.y - a), color, t); + dl->AddLine(ImVec2(c.x + a - d, c.y - a + d), ImVec2(c.x + a, c.y - a), color, t); + dl->AddLine(ImVec2(c.x + a - d, c.y + a), ImVec2(c.x + a, c.y + a - d), color, t); + dl->AddLine(ImVec2(c.x - a + d, c.y - a), ImVec2(c.x + a, c.y - a), color, t); + dl->AddLine(ImVec2(c.x + a, c.y - a), ImVec2(c.x + a, c.y + a - d), color, t); + break; + } + case ToolIcon::PlayerView: { // a head and shoulders + dl->AddCircle(ImVec2(c.x, c.y - r * 0.35f), r * 0.3f, color, 16, t); + dl->PathArcTo(ImVec2(c.x, c.y + r * 0.85f), r * 0.65f, 3.4f, 6.02f, 16); + dl->PathStroke(color, 0, t); + break; + } + case ToolIcon::FrameAll: { // four corners + const float a = r * 0.8f, l = r * 0.35f; + for (int sx = -1; sx <= 1; sx += 2) + for (int sy = -1; sy <= 1; sy += 2) { + const ImVec2 k(c.x + sx * a, c.y + sy * a); + dl->AddLine(k, ImVec2(k.x - sx * l, k.y), color, t); + dl->AddLine(k, ImVec2(k.x, k.y - sy * l), color, t); + } + break; + } + } + } + + // One square button of the strip; `on`: drawn as the active tool. True when clicked. + bool toolStripButton(const char* id, ToolIcon icon, bool on, const char* tip, uint32_t onColor = kBlue) { + const float side = 34 * scale; + const ImVec2 p = ImGui::GetCursorScreenPos(); + ImGui::SetCursorScreenPos(ImVec2(p.x + 5 * scale, p.y)); + const bool clicked = ImGui::InvisibleButton(id, ImVec2(side, side)); + const bool hot = ImGui::IsItemHovered(); + const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + if (on || hot) dl->AddRectFilled(a, b, col(on ? onColor : kToolHover), 6 * scale); + drawToolIcon(dl, icon, ImVec2((a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f), side * 0.5f, col(on ? 0xffffff : hot ? kText : 0xb4b8be)); + if (hot) ImGui::SetTooltip("%s", tip); + ImGui::SetCursorScreenPos(ImVec2(p.x, b.y + 4 * scale)); + return clicked; + } + + void toolStrip(float width, float height) { + ImGui::PushStyleColor(ImGuiCol_ChildBg, hex(kBar)); + ImGui::BeginChild("toolStrip", ImVec2(width, height), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleColor(); + ImGui::SetCursorPosY(8 * scale); + ImGui::BeginDisabled(!scene); + if (toolStripButton("##tMove", ToolIcon::Move, tool == Tool::Move, "Move (G) \u00b7 hold Ctrl to snap")) tool = Tool::Move; + if (toolStripButton("##tRotate", ToolIcon::Rotate, tool == Tool::Rotate, "Rotate (R) \u00b7 hold Ctrl to snap 15\u00b0")) tool = Tool::Rotate; + if (toolStripButton("##tScale", ToolIcon::Scale, tool == Tool::Scale, "Scale (S) \u00b7 hold Ctrl to snap")) tool = Tool::Scale; + if (toolStripButton("##tFloor", ToolIcon::DrawFloor, tool == Tool::DrawFloor, + "Draw Floor (B) \u00b7 select an object, then drag over it to draw walkable floor on it\n" + "Shift+drag erases \u00b7 [ and ] size the brush", kGo)) + tool = Tool::DrawFloor; + { // a hairline between the tools and the view options + const ImVec2 p = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddLine(ImVec2(p.x + 10 * scale, p.y + 2 * scale), ImVec2(p.x + width - 10 * scale, p.y + 2 * scale), col(kLine), 1.0f); + ImGui::SetCursorScreenPos(ImVec2(p.x, p.y + 8 * scale)); + } + if (toolStripButton("##tSpace", localSpace ? ToolIcon::Local : ToolIcon::Global, false, + localSpace ? "Gizmo: the object's own axes (click: the world's)" : "Gizmo: the world's axes (click: the object's own)")) + localSpace = !localSpace; + if (toolStripButton("##tPlayer", ToolIcon::PlayerView, false, "Player view (P or Numpad 0) \u00b7 from the middle of the home, at head height")) + cam.playerView(); + if (toolStripButton("##tFrameAll", ToolIcon::FrameAll, false, "Frame everything (Home)")) frameAll(); + ImGui::EndDisabled(); + ImGui::EndChild(); + } + + // The timeline row under the 3D view: play / pause, the scrubber over the home's animation loop, the sound switch. + void timelineRow(const ImVec2& at, const ImVec2& size) { + ImGui::SetCursorScreenPos(at); + ImGui::BeginChild("timeline", size, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + timeline(ImGui::GetWindowDrawList(), at, ImVec2(at.x + size.x, at.y + size.y)); + ImGui::EndChild(); + } + + void timeline(ImDrawList* dl, const ImVec2& a, const ImVec2& end) { + const float h = end.y - a.y; + dl->AddRectFilled(a, end, col(kBar)); + dl->AddLine(a, ImVec2(end.x, a.y), col(kSplit), 1.0f); const float pad = 8 * scale, bw = h - 2 * pad + 18 * scale; ImGui::SetCursorScreenPos(ImVec2(a.x + pad, a.y + pad * 0.6f)); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6 * scale, 3 * scale)); @@ -4852,16 +4976,60 @@ struct Studio { ImGui::PopFont(); } - void properties(float height) { - ImGui::BeginChild("properties", ImVec2(0, height), ImGuiChildFlags_AlwaysUseWindowPadding); - const float labelW = 78 * scale; - auto label = [&](const char* t) { - ImGui::AlignTextToFramePadding(); - ImGui::TextDisabled("%s", t); - ImGui::SameLine(labelW); - ImGui::SetNextItemWidth(-1); - }; + // ── The Inspector (right): the selection's and the environment's settings, one tab each ── + enum class InspectorTab { Selection, World, Environment, Sound, Headset }; + InspectorTab inspectorTab = InspectorTab::Selection; + int lastSelected = -1, lastWorldKind = -1, lastWorldIndex = -1; // selecting something new shows its tab + // A field's label, left of it, every field starting at the same column. + void fieldLabel(const char* text) { + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", text); + ImGui::SameLine(78 * scale); + ImGui::SetNextItemWidth(-1); + } + + void inspector(float height) { + if (selected != lastSelected || wKind != lastWorldKind || wIndex != lastWorldIndex) { + if (selected >= 0 || wKind >= 0) inspectorTab = InspectorTab::Selection; + lastSelected = selected; lastWorldKind = wKind; lastWorldIndex = wIndex; + } + static const struct { InspectorTab tab; const char* name; const char* tip; } kTabs[] = { + {InspectorTab::Selection, "Selected", "The object or world item you selected"}, + {InspectorTab::World, "World", "Where you can walk: the walkable area and its settings"}, + {InspectorTab::Environment, "Environment", "Its name, picture, what it's made of, and its headset settings"}, + {InspectorTab::Sound, "Sound", "Its ambience, and a background sound of your own"}, + {InspectorTab::Headset, "Headset", "Send it to the Steam Frame, or install Frame Home there"}, + }; + ImGui::BeginChild("inspector", ImVec2(0, height), ImGuiChildFlags_AlwaysUseWindowPadding); + if (ImGui::BeginTabBar("##inspectorTabs", ImGuiTabBarFlags_FittingPolicyResizeDown)) { + for (const auto& t : kTabs) { + const bool force = inspectorTab == t.tab && forceInspectorTab; + if (ImGui::BeginTabItem(t.name, nullptr, force ? ImGuiTabItemFlags_SetSelected : 0)) { + if (!forceInspectorTab) inspectorTab = t.tab; + ImGui::EndTabItem(); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t.tip); + } + ImGui::EndTabBar(); + } + forceInspectorTab = false; + ImGui::BeginChild("inspectorBody", ImVec2(0, 0), ImGuiChildFlags_None); + switch (inspectorTab) { + case InspectorTab::Selection: selectionTab(); break; + case InspectorTab::World: worldTab(); break; + case InspectorTab::Environment: environmentTab(); break; + case InspectorTab::Sound: soundTab(); break; + case InspectorTab::Headset: headsetTab(); break; + } + ImGui::EndChild(); + ImGui::EndChild(); + } + bool forceInspectorTab = false; // inspectorTab was set in code: the tab bar shows it + void showInspectorTab(InspectorTab t) { inspectorTab = t; forceInspectorTab = true; } + + // Selected: the world item or object picked in the view or the Explorer. + void selectionTab() { if (scene && wKind >= 0 && wIndex >= 0 && wIndex < worldCount(wKind)) { section("WORLD ITEM"); ImGui::TextUnformatted(worldName(wKind, wIndex).c_str()); @@ -4883,20 +5051,20 @@ struct Studio { if (WorldPoint* pt = worldPoint(wKind, wIndex)) { char nm[128]; std::snprintf(nm, sizeof nm, "%s", pt->name.c_str()); - label("Name"); + fieldLabel("Name"); if (ImGui::InputText("##wname", nm, sizeof nm)) { pt->name = nm; changed = true; } track(); - label("Position"); + fieldLabel("Position"); ImGui::DragFloat3("##wpos", pt->pos, 0.01f, 0, 0, "%.2f m"); track(); float deg = pt->yaw * 180.0f / kPi; - label("Facing"); + fieldLabel("Facing"); if (ImGui::DragFloat("##wyaw", °, 1.0f, -360, 360, "%.0f\u00b0")) pt->yaw = deg * kPi / 180.0f; track(); - if (wKind == kSeat) { label("Stand up at"); ImGui::DragFloat3("##wexit", pt->exit, 0.01f, 0, 0, "%.2f m"); track(); } + if (wKind == kSeat) { fieldLabel("Stand up at"); ImGui::DragFloat3("##wexit", pt->exit, 0.01f, 0, 0, "%.2f m"); track(); } } else if (wKind == kCollider) { WorldCollider& c = scene->world.colliders[wIndex]; static const char* kinds[] = {"Box", "Sphere", "Capsule", "Mesh"}; - label("Role"); + fieldLabel("Role"); static const char* roles[4] = {"Surface", "Collider", "Walk through", "Drawn floor"}; static const int roleOf[4] = {WorldCollider::Solid, WorldCollider::Object, WorldCollider::Trigger, WorldCollider::Walkable}; int role = c.role == WorldCollider::Trigger ? 2 : c.role == WorldCollider::Object ? 1 : c.role == WorldCollider::Walkable ? 3 : 0; @@ -4908,7 +5076,7 @@ struct Studio { ImGui::PushTextWrapPos(0); ImGui::TextDisabled("%s", kRoleHelp[std::clamp(c.role, 0, 5)]); ImGui::PopTextWrapPos(); - label("Shape"); + fieldLabel("Shape"); ImGui::Text("%s%s", kinds[std::clamp(c.kind, 0, 3)], c.kind == WorldCollider::Mesh ? (" \u00b7 " + std::to_string(c.indices.size() / 3) + " triangles").c_str() : ""); if (!c.mesh.empty()) { @@ -4921,38 +5089,38 @@ struct Studio { float t[3], r[3], sc[3]; ImGuizmo::DecomposeMatrixToComponents(c.world, t, r, sc); bool moved = false; - label("Location"); moved |= ImGui::DragFloat3("##wloc", t, 0.01f, 0, 0, "%.2f m"); track(); - label("Rotation"); moved |= ImGui::DragFloat3("##wrot", r, 0.5f, 0, 0, "%.0f\u00b0"); track(); - if (c.kind == WorldCollider::Mesh) { label("Scale"); moved |= ImGui::DragFloat3("##wscl", sc, 0.01f, 0.01f, 1000.0f, "%.2f"); track(); } + fieldLabel("Location"); moved |= ImGui::DragFloat3("##wloc", t, 0.01f, 0, 0, "%.2f m"); track(); + fieldLabel("Rotation"); moved |= ImGui::DragFloat3("##wrot", r, 0.5f, 0, 0, "%.0f\u00b0"); track(); + if (c.kind == WorldCollider::Mesh) { fieldLabel("Scale"); moved |= ImGui::DragFloat3("##wscl", sc, 0.01f, 0.01f, 1000.0f, "%.2f"); track(); } if (moved) ImGuizmo::RecomposeMatrixFromComponents(t, r, sc, c.world); if (c.kind == WorldCollider::Box) { float size[3] = {c.half[0] * 2, c.half[1] * 2, c.half[2] * 2}; - label("Size"); + fieldLabel("Size"); if (ImGui::DragFloat3("##wsize", size, 0.01f, 0.02f, 200.0f, "%.2f m")) for (int k = 0; k < 3; ++k) c.half[k] = std::max(0.01f, size[k] * 0.5f); track(); } - if (c.kind == WorldCollider::Sphere || c.kind == WorldCollider::Capsule) { label("Radius"); ImGui::DragFloat("##wrad", &c.radius, 0.01f, 0.01f, 50.0f, "%.2f m"); track(); } - if (c.kind == WorldCollider::Capsule) { label("Length"); ImGui::DragFloat("##wlen", &c.length, 0.01f, 0.0f, 50.0f, "%.2f m"); track(); } + if (c.kind == WorldCollider::Sphere || c.kind == WorldCollider::Capsule) { fieldLabel("Radius"); ImGui::DragFloat("##wrad", &c.radius, 0.01f, 0.01f, 50.0f, "%.2f m"); track(); } + if (c.kind == WorldCollider::Capsule) { fieldLabel("Length"); ImGui::DragFloat("##wlen", &c.length, 0.01f, 0.0f, 50.0f, "%.2f m"); track(); } } } else if (wKind == kWallSpot) { WorldWallSpot& ws = scene->world.wallSpots[wIndex]; float t[3], r[3], sc[3]; ImGuizmo::DecomposeMatrixToComponents(ws.world, t, r, sc); bool moved = false; - label("Location"); moved |= ImGui::DragFloat3("##wsloc", t, 0.01f, 0, 0, "%.2f m"); track(); - label("Rotation"); moved |= ImGui::DragFloat3("##wsrot", r, 0.5f, 0, 0, "%.0f\u00b0"); track(); + fieldLabel("Location"); moved |= ImGui::DragFloat3("##wsloc", t, 0.01f, 0, 0, "%.2f m"); track(); + fieldLabel("Rotation"); moved |= ImGui::DragFloat3("##wsrot", r, 0.5f, 0, 0, "%.0f\u00b0"); track(); if (moved) { sc[0] = sc[1] = sc[2] = 1; ImGuizmo::RecomposeMatrixFromComponents(t, r, sc, ws.world); } - label("Width"); ImGui::DragFloat("##wwid", &ws.width, 0.01f, 0.1f, 20.0f, "%.2f m"); track(); - label("Height"); ImGui::DragFloat("##whei", &ws.height, 0.01f, 0.1f, 20.0f, "%.2f m"); track(); + fieldLabel("Width"); ImGui::DragFloat("##wwid", &ws.width, 0.01f, 0.1f, 20.0f, "%.2f m"); track(); + fieldLabel("Height"); ImGui::DragFloat("##whei", &ws.height, 0.01f, 0.1f, 20.0f, "%.2f m"); track(); } if (wKind == kGravity) { WorldGravity& g = scene->world.gravity[wIndex]; char nm[128]; std::snprintf(nm, sizeof nm, "%s", g.name.c_str()); - label("Name"); + fieldLabel("Name"); if (ImGui::InputText("##gname", nm, sizeof nm)) { g.name = nm; changed = true; } track(); - label("Kind"); + fieldLabel("Kind"); int kind = std::clamp(g.kind, 0, 2); if (ImGui::Combo("##gkind", &kind, kGravityNames, 3) && kind != g.kind) { before = std::make_shared(scene->world); g.kind = kind; changed = true; } ImGui::PushTextWrapPos(0); @@ -4961,17 +5129,17 @@ struct Studio { float t[3], r[3], sc[3]; ImGuizmo::DecomposeMatrixToComponents(g.world, t, r, sc); bool moved = false; - label(g.kind == WorldGravity::Planet ? "Centre" : "Location"); moved |= ImGui::DragFloat3("##gloc", t, 0.01f, 0, 0, "%.2f m"); track(); - if (g.kind != WorldGravity::Planet) { label("Rotation"); moved |= ImGui::DragFloat3("##grot", r, 0.5f, 0, 0, "%.0f\u00b0"); track(); } + fieldLabel(g.kind == WorldGravity::Planet ? "Centre" : "Location"); moved |= ImGui::DragFloat3("##gloc", t, 0.01f, 0, 0, "%.2f m"); track(); + if (g.kind != WorldGravity::Planet) { fieldLabel("Rotation"); moved |= ImGui::DragFloat3("##grot", r, 0.5f, 0, 0, "%.0f\u00b0"); track(); } if (moved) { sc[0] = sc[1] = sc[2] = 1; ImGuizmo::RecomposeMatrixFromComponents(t, r, sc, g.world); } - if (g.kind == WorldGravity::Planet) { label("Pull radius"); ImGui::DragFloat("##grad", &g.radius, 0.1f, 0.5f, 5000.0f, "%.1f m"); track(); } + if (g.kind == WorldGravity::Planet) { fieldLabel("Pull radius"); ImGui::DragFloat("##grad", &g.radius, 0.1f, 0.5f, 5000.0f, "%.1f m"); track(); } else { float size[3] = {g.half[0] * 2, g.half[1] * 2, g.half[2] * 2}; - label("Size"); + fieldLabel("Size"); if (ImGui::DragFloat3("##gsize", size, 0.05f, 0.2f, 5000.0f, "%.1f m")) for (int k = 0; k < 3; ++k) g.half[k] = std::max(0.1f, size[k] * 0.5f); track(); } - label("Priority"); ImGui::DragInt("##gpri", &g.priority, 0.1f, -100, 100); track(); + fieldLabel("Priority"); ImGui::DragInt("##gpri", &g.priority, 0.1f, -100, 100); track(); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Where zones overlap, the higher priority one decides down."); } if (bool* hid = worldHidden(wKind, wIndex)) { @@ -5008,15 +5176,15 @@ struct Studio { ImGuizmo::DecomposeMatrixToComponents(o.pivot, t, r, s); bool changed = false; bool done = false; // a field let go: its surfaces split again if it turned - label("Location"); + fieldLabel("Location"); changed |= ImGui::DragFloat3("##loc", t, 0.01f, 0, 0, "%.3f m"); if (ImGui::IsItemActivated()) pushUndo(selected); done |= ImGui::IsItemDeactivatedAfterEdit(); - label("Rotation"); + fieldLabel("Rotation"); changed |= ImGui::DragFloat3("##rot", r, 0.5f, 0, 0, "%.1f\u00b0"); if (ImGui::IsItemActivated()) pushUndo(selected); done |= ImGui::IsItemDeactivatedAfterEdit(); - label("Scale"); + fieldLabel("Scale"); changed |= ImGui::DragFloat3("##scl", s, 0.01f, 0.001f, 1000.0f, "%.3f"); if (ImGui::IsItemActivated()) pushUndo(selected); done |= ImGui::IsItemDeactivatedAfterEdit(); @@ -5108,22 +5276,30 @@ struct Studio { if (ImGui::Button("Delete")) deleteSelected(); ImGui::PopStyleColor(3); } else { - ImGui::TextDisabled(scene ? "Click an object in the viewport\nor the outliner." : "Open a home to start."); + ImGui::TextDisabled(scene ? "Nothing selected.\nClick an object in the 3D view or the Explorer." : "Open a home to start."); } + } + + // World: the walkable area, as Frame Home walks it. + void worldTab() { section("WALKABLE AREA"); walkPanel(); + } + + // Environment: its name and picture, what it's made of, saving, and its own headset settings. + void environmentTab() { section("ENVIRONMENT"); if (scene) { - label("Name"); + fieldLabel("Name"); { char nb[128]; std::snprintf(nb, sizeof nb, "%s", envTitle.c_str()); if (ImGui::InputTextWithHint("##envname", homeName.c_str(), nb, sizeof nb)) { envTitle = nb; dirty = true; updateTitle(); } if (ImGui::IsItemHovered()) ImGui::SetTooltip("What it's called on the headset (Environments panel) and in a saved .fenv"); } - label("Picture"); + fieldLabel("Picture"); if (ImGui::Button(envImage.empty() ? "Choose\u2026##envimg" : "Change\u2026##envimg")) chooseImage(); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Its picture on the headset (Environments panel); without one, a view of it is taken when it's saved"); if (!envImage.empty()) { @@ -5152,31 +5328,16 @@ struct Studio { for (const Object& o : objects) { if (o.deleted) ++deleted; else if (o.hidden) ++hidden; else ++live; } for (const MeshData& md : scene->meshes) lightmapped += md.hasLightmap; const bool clay = scene->animate != nullptr; - label("Objects"); + fieldLabel("Objects"); ImGui::Text("%zu shown, %zu hidden, %zu deleted", live, hidden, deleted); - label("Shaders"); + fieldLabel("Shaders"); ImGui::Text(clay ? "built-in unlit (Clay)" : "%zu of the home's own", scene->programs.size()); - label("Lighting"); + fieldLabel("Lighting"); ImGui::Text("%s", lightmapped ? (std::to_string(lightmapped) + " baked lightmaps").c_str() : clay ? "baked into textures" : scene->hasEnvAmbient ? "light probes" : "estimated ambient"); - label("Animation"); + fieldLabel("Animation"); if (clay) ImGui::Text("%zu animated objects, %.0f s loop", scene->animatedMeshes.size(), scene->animDuration); else ImGui::Text("%zu rigs + shader motion", scene->anims.size()); - label("Sound"); - ImGui::Text("%s", scene->ambientSource.empty() ? "none of its own" : scene->ambientSource == "FMOD bank" ? "FMOD bank (ambience bed)" : "Ogg loop"); - soundPanel(); - { // the sounds it uses, to keep: the home's own ambience (an Ogg file) and the one added to it - const bool own = scene && !scene->ambientOgg.empty(); - ImGui::BeginDisabled(!own); - if (ImGui::Button("Save Its Sound\u2026")) saveSoundAs(scene->ambientOgg, (scene->ambientName.empty() ? shownName() + " ambience" : scene->ambientName) + ".ogg"); - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - ImGui::SetTooltip("%s", own ? "The home's own ambience as it plays (Ogg Vorbis)" : "This home has no sound of its own"); - if (sound.bytes) { - ImGui::SameLine(); - if (ImGui::Button("Save the Added Sound\u2026")) saveSoundAs(*sound.bytes, sound.name.empty() ? sound.s.file : sound.name); - } - } ImGui::Spacing(); ImGui::BeginDisabled(!dirty); if (ImGui::Button(envFile.empty() ? "Save to My Environments" : "Save")) saveToLibrary(); @@ -5198,7 +5359,7 @@ struct Studio { ImGui::PushTextWrapPos(0); ImGui::TextDisabled("On the headset it uses %s", worldSettings.describe().c_str()); ImGui::PopTextWrapPos(); - label("Resolution"); + fieldLabel("Resolution"); { static const float kRes[] = {0, 0.8f, 0.9f, 1.0f, 1.1f, 1.2f, 1.3f, 1.4f, 1.5f}; char cur[32]; @@ -5212,7 +5373,7 @@ struct Studio { ImGui::EndCombo(); } } - label("Foveation"); + fieldLabel("Foveation"); { bool own = worldSettings.foveation > 0; if (ImGui::Checkbox("##wsfovown", &own)) worldSettings.foveation = own ? 1.0f : 0.0f; @@ -5226,7 +5387,7 @@ struct Studio { if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("1 gentle .. 3 strong: where you look stays sharp, the edges of view get coarser (cheaper)"); } - label("MSAA"); + fieldLabel("MSAA"); { static const int kMsaa[] = {0, 1, 2, 4}; static const char* kMsaaNames[] = {"The headset's", "Off", "2x", "4x"}; @@ -5235,7 +5396,7 @@ struct Studio { if (ImGui::Combo("##wsmsaa", &at, kMsaaNames, 4)) worldSettings.msaa = kMsaa[at]; if (ImGui::IsItemHovered()) ImGui::SetTooltip("Different from the headset's: Frame Home restarts itself to show this world"); } - label("Frame rate"); + fieldLabel("Frame rate"); { static const char* kRates[] = {"The headset's", "Full", "Half"}; int at = worldSettings.frameRate + 1; @@ -5247,9 +5408,9 @@ struct Studio { if (ImGui::Button(tuning ? "Calculating\u2026" : "Calculate Best Settings")) calculateBestSettings(); ImGui::EndDisabled(); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - ImGui::SetTooltip("On the headset: walks through the world at a few settings, counts what each frame costs the GPU, and keeps\n" - "the sharpest that leaves room (for heat and the views the walk missed). Your resolution and MSAA stay;\n" - "foveation moves first. Takes a few minutes; the headset cools between tries."); + ImGui::SetTooltip("On the headset: walks through the world at 120 Hz at a few settings and keeps the sharpest one that holds\n" + "its frame rate (at most 1%% of frames late). Your MSAA stays; foveation moves first, then resolution.\n" + "Takes a few minutes; the headset cools between tries."); ImGui::SameLine(); ImGui::BeginDisabled(worldSettings.empty() && worldSettings.tuned.empty()); if (ImGui::Button("Use Headset Settings")) { worldSettings = WorldSettings(); setTuneStatus(""); dirty = true; updateTitle(); } @@ -5265,6 +5426,31 @@ struct Studio { } } + } + + // Sound: the home's own ambience and a background sound added to it. + void soundTab() { + if (!scene) { ImGui::TextDisabled("Open a home to start."); return; } + section("SOUND"); + fieldLabel("Sound"); + ImGui::Text("%s", scene->ambientSource.empty() ? "none of its own" : scene->ambientSource == "FMOD bank" ? "FMOD bank (ambience bed)" : "Ogg loop"); + soundPanel(); + { // the sounds it uses, to keep: the home's own ambience (an Ogg file) and the one added to it + const bool own = scene && !scene->ambientOgg.empty(); + ImGui::BeginDisabled(!own); + if (ImGui::Button("Save Its Sound\u2026")) saveSoundAs(scene->ambientOgg, (scene->ambientName.empty() ? shownName() + " ambience" : scene->ambientName) + ".ogg"); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", own ? "The home's own ambience as it plays (Ogg Vorbis)" : "This home has no sound of its own"); + if (sound.bytes) { + ImGui::SameLine(); + if (ImGui::Button("Save the Added Sound\u2026")) saveSoundAs(*sound.bytes, sound.name.empty() ? sound.s.file : sound.name); + } + } + } + + // Headset: send the environment to the Steam Frame, or install Frame Home there. + void headsetTab() { section("STEAM FRAME"); ImGui::TextDisabled("ssh %s \u2192 ~/%s", frameHost, kFrameEnvDir); ImGui::Spacing(); @@ -5277,9 +5463,9 @@ struct Studio { ImGui::TextDisabled("%s", status().c_str()); ImGui::PopTextWrapPos(); if (ImGui::Button("Install on Steam Frame\u2026", ImVec2(-1, 0))) openInstall(); - ImGui::EndChild(); } + // Vertical splitter between panels; dragging changes `width` (sign: +1 grows with the mouse, -1 shrinks). void splitter(const char* id, float& width, float sign, float height) { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); @@ -5787,7 +5973,7 @@ struct Studio { ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); overView = ImGui::IsItemHovered(); } - const float tlH = 34 * scale; // the timeline strip along the bottom edge + const float tlH = 0; // (the timeline has its own row under the view) const bool overTimeline = scene && io.MousePos.y >= viewPos.y + viewSize.y - tlH && io.MousePos.y <= viewPos.y + viewSize.y && io.MousePos.x >= viewPos.x && io.MousePos.x <= viewPos.x + viewSize.x; const bool hovered = overView && !overTimeline; @@ -5865,7 +6051,6 @@ struct Studio { } if (scene) drawWorld(dl, viewProj); if (scene) drawAxes(dl, view); - if (scene) timeline(dl, end, tlH); if (scene && isolating) { // say why everything else vanished, and how to get it back const char* msg = "Showing only the selection \u00b7 I or Esc shows everything"; const ImVec2 ts = ImGui::CalcTextSize(msg); @@ -6048,68 +6233,17 @@ struct Studio { } } - // Tool strip (top-left), Blender's toolbar position: the tools as one segmented group on a dark backing. + // Draw Floor's brush size, over the view's top-left corner while that tool is on. + if (scene && tool == Tool::DrawFloor) { + const ImVec2 at(viewPos.x + 10 * scale, viewPos.y + 10 * scale); + const float w = 180 * scale; + dl->AddRectFilled(at, ImVec2(at.x + w + 8 * scale, at.y + ImGui::GetFrameHeight() + 8 * scale), col(kBar, 0.85f), 6 * scale); + ImGui::SetCursorScreenPos(ImVec2(at.x + 4 * scale, at.y + 4 * scale)); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##brush", &floorBrush, 0.05f, 3.0f, "brush %.2f m", ImGuiSliderFlags_Logarithmic); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Brush size \u00b7 [ and ] change it \u00b7 Shift+drag erases"); + } if (scene) { - const ImVec2 stripAt(viewPos.x + 10 * scale, viewPos.y + 10 * scale), stripPad(4 * scale, 4 * scale); - ImGui::SetCursorScreenPos(ImVec2(stripAt.x + stripPad.x, stripAt.y + stripPad.y)); - dl->ChannelsSplit(2); // the buttons on top, the backing (sized once they're laid out) under them - dl->ChannelsSetCurrent(1); - ImVec2 stripEnd = stripAt; - auto grow = [&]() { stripEnd.x = std::max(stripEnd.x, ImGui::GetItemRectMax().x); stripEnd.y = std::max(stripEnd.y, ImGui::GetItemRectMax().y); }; - auto toolButton = [&](const char* label, Tool t, const char* tip) { - const bool on = tool == t; - ImGui::PushStyleColor(ImGuiCol_Button, on ? hex(kBlue) : hex(kToolBg, 0.92f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, on ? hex(kBlueHover) : hex(kToolHover, 0.95f)); - if (ImGui::Button(label, ImVec2(64 * scale, 0))) tool = t; - ImGui::PopStyleColor(2); - grow(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); - }; - toolButton("Move", Tool::Move, "G \u00b7 hold Ctrl to snap"); - ImGui::SameLine(0, 2 * scale); - toolButton("Rotate", Tool::Rotate, "R \u00b7 hold Ctrl to snap 15\u00b0"); - ImGui::SameLine(0, 2 * scale); - toolButton("Scale", Tool::Scale, "S \u00b7 hold Ctrl to snap"); - ImGui::SameLine(0, 2 * scale); - ImGui::PushID("drawfloor"); - { - const bool on = tool == Tool::DrawFloor; - ImGui::PushStyleColor(ImGuiCol_Button, on ? hex(kGo) : hex(kToolBg, 0.92f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, on ? hex(kGoHover) : hex(kToolHover, 0.95f)); - if (ImGui::Button("Draw Floor", ImVec2(84 * scale, 0))) tool = Tool::DrawFloor; - ImGui::PopStyleColor(2); - grow(); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("B \u00b7 select an object, then drag over it to draw walkable floor on it\nShift+drag erases \u00b7 [ and ] size the brush"); - if (on) { - ImGui::SameLine(0, 4 * scale); - ImGui::SetNextItemWidth(110 * scale); - ImGui::SliderFloat("##brush", &floorBrush, 0.05f, 3.0f, "brush %.2f m", ImGuiSliderFlags_Logarithmic); - grow(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Brush size \u00b7 [ and ] change it"); - } - } - ImGui::PopID(); - ImGui::SameLine(0, 8 * scale); - { // a hairline between the tools and the view options - const ImVec2 c = ImGui::GetCursorScreenPos(); - dl->AddLine(ImVec2(c.x - 4 * scale, c.y + 3 * scale), ImVec2(c.x - 4 * scale, c.y + ImGui::GetFrameHeight() - 3 * scale), col(kLine), 1.0f); - } - ImGui::PushStyleColor(ImGuiCol_Button, hex(kToolBg, 0.92f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hex(kToolHover, 0.95f)); - if (ImGui::Button(localSpace ? "Local" : "Global", ImVec2(64 * scale, 0))) localSpace = !localSpace; - grow(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip(localSpace ? "The gizmo follows the object's own axes (click: the world's)" - : "The gizmo follows the world's axes (click: the object's own)"); - ImGui::SameLine(0, 2 * scale); - if (ImGui::Button("Player View")) cam.playerView(); - grow(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("P or Numpad 0 \u00b7 the view from the middle of the home, at head height"); - ImGui::PopStyleColor(2); - dl->ChannelsSetCurrent(0); - dl->AddRectFilled(stripAt, ImVec2(stripEnd.x + stripPad.x, stripEnd.y + stripPad.y), col(kBar, 0.82f), 8 * scale); - dl->AddRect(stripAt, ImVec2(stripEnd.x + stripPad.x, stripEnd.y + stripPad.y), col(0xffffff, 0.06f), 8 * scale, 0, 1.0f); - dl->ChannelsMerge(); if (flying) { char b[64]; std::snprintf(b, sizeof b, "Fly \u00b7 %.1f m/s (wheel)", flySpeed); @@ -6214,15 +6348,18 @@ struct Studio { ImGui::PopStyleVar(3); } + // The window, as an editor dock: the menu bar; the tool strip, the Explorer, the 3D view and the Inspector side by + // side; the timeline and the status bar along the bottom. void ui() { - if (outlinerW == 0) { outlinerW = 270 * scale; propertiesW = 340 * scale; } + if (outlinerW == 0) { outlinerW = 270 * scale; propertiesW = 360 * scale; } shortcuts(); menuBar(); ImGuiIO& io = ImGui::GetIO(); const float top = ImGui::GetFrameHeight(), barH = ImGui::GetFrameHeight(); - const float h = io.DisplaySize.y - top - barH; + const float timelineH = scene ? 34 * scale : 0.0f; + const float h = io.DisplaySize.y - top - barH - timelineH; ImGui::SetNextWindowPos(ImVec2(0, top)); - ImGui::SetNextWindowSize(ImVec2(io.DisplaySize.x, h)); + ImGui::SetNextWindowSize(ImVec2(io.DisplaySize.x, h + timelineH)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0); @@ -6230,31 +6367,34 @@ struct Studio { ImGui::Begin("##main", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoNav); ImGui::PopStyleVar(4); - const float split = 4 * scale; - // Viewport on the left; the right-hand column stacks the explorer over the selected object's properties. - const float viewW = std::max(64.0f, io.DisplaySize.x - propertiesW - split); + const float split = 4 * scale, stripW = 44 * scale; + // never narrower than this, whatever the splitters say + outlinerW = std::clamp(outlinerW, 180 * scale, std::max(180 * scale, io.DisplaySize.x * 0.35f)); + propertiesW = std::clamp(propertiesW, 260 * scale, std::max(260 * scale, io.DisplaySize.x * 0.4f)); + const float viewW = std::max(64.0f, io.DisplaySize.x - stripW - outlinerW - propertiesW - 2 * split); + toolStrip(stripW, h); + ImGui::SameLine(0, 0); + ImGui::BeginChild("explorerColumn", ImVec2(outlinerW, h), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8 * scale, 8 * scale)); + outliner(h); + ImGui::PopStyleVar(); + ImGui::EndChild(); + ImGui::SameLine(0, 0); + splitter("##s1", outlinerW, +1, h); + ImGui::SameLine(0, 0); viewport(viewW, h); ImGui::SameLine(0, 0); splitter("##s2", propertiesW, -1, h); ImGui::SameLine(0, 0); - ImGui::BeginChild("side", ImVec2(0, h), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - const float explorerH = std::clamp(h * explorerFrac, 120 * scale, h - 160 * scale); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8 * scale, 8 * scale)); - outliner(explorerH); - ImGui::PopStyleVar(); - { // horizontal splitter: drag to give the explorer or the properties more room - ImGui::InvisibleButton("##hs", ImVec2(-1, split)); - if (ImGui::IsItemHovered() || ImGui::IsItemActive()) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); - if (ImGui::IsItemActive()) explorerFrac = std::clamp(explorerFrac + ImGui::GetIO().MouseDelta.y / h, 0.15f, 0.85f); - ImGui::GetWindowDrawList()->AddRectFilled(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), col(ImGui::IsItemActive() ? kBlue : kSplit)); - } + ImGui::BeginChild("inspectorColumn", ImVec2(0, h), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10 * scale, 8 * scale)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(6 * scale, 5 * scale)); - properties(h - explorerH - split); + inspector(h); ImGui::PopStyleVar(2); ImGui::EndChild(); + if (scene) timelineRow(ImVec2(0, top + h), ImVec2(io.DisplaySize.x, timelineH)); ImGui::End(); - statusBar(top + h, barH); + statusBar(top + h + timelineH, barH); controlsWindow(); installWindow(); settingsWindow(); @@ -6265,7 +6405,7 @@ struct Studio { autoSummaryWindow(); } - // ── Frame > Install on Steam Frame ── + // ── Headset > Install on Steam Frame ── // Puts Frame Home on the headset as its environment: SteamVR's home-app slot, which SteamVR itself runs whenever // no game is running (in place of the default environment, not as a game or an extra app), plus the chosen // environments. The headset side is frame\install.sh beside this exe, with Frame Home's arm64 binaries.