Files
vr-poser/src/ImGuiLayer.cpp
2026-03-16 03:49:43 -04:00

701 lines
28 KiB
C++

#include "ImGuiLayer.h"
#include "MorphManager.h"
#include "AppConfig.h"
#include "PoseManager.h"
#include "BoneSelector.h"
#include <iostream>
#include <algorithm>
#include <string>
#include <cstdio>
#include <cmath>
#include <imgui/imgui.h>
#include <imgui/imgui_impl_opengl3.h>
#include <osg/RenderInfo>
#include <osg/GraphicsContext>
#include <osg/Viewport>
#include <osgViewer/Viewer>
#include <osgGA/GUIEventAdapter>
// ── Draw callback ─────────────────────────────────────────────────────────────
struct ImGuiDrawCallback : public osg::Camera::DrawCallback {
ImGuiLayer* layer;
mutable bool glInitialized = false;
explicit ImGuiDrawCallback(ImGuiLayer* l) : layer(l) {}
void operator()(osg::RenderInfo& ri) const override {
osg::GraphicsContext* gc = ri.getCurrentCamera()->getGraphicsContext();
osg::Viewport* vp = ri.getCurrentCamera()->getViewport();
float w = vp ? static_cast<float>(vp->width())
: (gc && gc->getTraits() ? static_cast<float>(gc->getTraits()->width) : 0.f);
float h = vp ? static_cast<float>(vp->height())
: (gc && gc->getTraits() ? static_cast<float>(gc->getTraits()->height) : 0.f);
if (w < 1.f || h < 1.f) return;
if (!glInitialized) {
bool ok = ImGui_ImplOpenGL3_Init(nullptr);
if (!ok) ok = ImGui_ImplOpenGL3_Init("#version 130");
if (!ok) { std::cerr << "[imgui] GL init failed\n"; return; }
layer->markGLInitialized();
glInitialized = true;
std::cout << "[imgui] OpenGL backend initialized.\n";
}
ImGuiIO& io = ImGui::GetIO();
io.DisplaySize = ImVec2(w, h);
ImGui_ImplOpenGL3_NewFrame();
ImGui::NewFrame();
layer->renderPanel();
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
}
};
// ─────────────────────────────────────────────────────────────────────────────
ImGuiLayer::ImGuiLayer(MorphManager* morphMgr, const AppConfig* cfg)
: m_morphMgr(morphMgr), m_cfg(cfg)
{}
ImGuiLayer::~ImGuiLayer() {
if (m_glInitialized) ImGui_ImplOpenGL3_Shutdown();
if (m_contextCreated) ImGui::DestroyContext();
}
void ImGuiLayer::markGLInitialized() { m_glInitialized = true; }
// ─────────────────────────────────────────────────────────────────────────────
void ImGuiLayer::init(osgViewer::Viewer* viewer) {
IMGUI_CHECKVERSION();
ImGui::CreateContext();
m_contextCreated = true;
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
// ── Font ─────────────────────────────────────────────────────────────────
static const ImWchar glyphRanges[] = {
0x0020, 0x00FF,
0x3000, 0x30FF, // Hiragana + Katakana
0x4E00, 0x9FFF, // CJK common kanji
0xFF00, 0xFFEF,
0,
};
std::string fontPath = m_cfg ? m_cfg->getString("ui.font_path") : std::string();
float fontSize = m_cfg ? m_cfg->getFloat("ui.font_size", 14.f) : 14.f;
m_panelWidth = m_cfg ? m_cfg->getFloat("ui.panel_width", 380.f) : 380.f;
bool fontLoaded = false;
if (!fontPath.empty()) {
ImFont* f = io.Fonts->AddFontFromFileTTF(
fontPath.c_str(), fontSize, nullptr, glyphRanges);
if (f) {
std::cout << "[imgui] Font: " << fontPath << "\n";
fontLoaded = true;
} else {
std::cerr << "[imgui] Failed to load font: " << fontPath
<< "\n Check ui.font_path in assets/config.ini\n";
}
}
if (!fontLoaded) {
std::cout << "[imgui] Using built-in font (ASCII only).\n";
io.Fonts->AddFontDefault();
}
// ── Style ────────────────────────────────────────────────────────────────
ImGui::StyleColorsDark();
ImGuiStyle& style = ImGui::GetStyle();
style.WindowRounding = 8.f;
style.FrameRounding = 4.f;
style.TabRounding = 4.f;
style.ScrollbarRounding = 4.f;
style.GrabRounding = 4.f;
style.WindowPadding = ImVec2(10, 10);
style.ItemSpacing = ImVec2(8, 5);
style.WindowMinSize = ImVec2(220.f, 200.f);
auto& c = style.Colors;
c[ImGuiCol_TitleBg] = ImVec4(0.18f, 0.12f, 0.28f, 1.f);
c[ImGuiCol_TitleBgActive] = ImVec4(0.28f, 0.18f, 0.45f, 1.f);
c[ImGuiCol_Tab] = ImVec4(0.18f, 0.12f, 0.28f, 1.f);
c[ImGuiCol_TabHovered] = ImVec4(0.45f, 0.30f, 0.70f, 1.f);
c[ImGuiCol_TabActive] = ImVec4(0.35f, 0.22f, 0.56f, 1.f);
c[ImGuiCol_TabUnfocused] = ImVec4(0.14f, 0.09f, 0.22f, 1.f);
c[ImGuiCol_TabUnfocusedActive]= ImVec4(0.28f, 0.18f, 0.45f, 1.f);
c[ImGuiCol_ResizeGrip] = ImVec4(0.45f, 0.30f, 0.70f, 0.5f);
c[ImGuiCol_ResizeGripHovered] = ImVec4(0.65f, 0.45f, 0.90f, 0.8f);
c[ImGuiCol_ResizeGripActive] = ImVec4(0.80f, 0.60f, 1.00f, 1.0f);
c[ImGuiCol_Header] = ImVec4(0.28f, 0.18f, 0.45f, 0.6f);
c[ImGuiCol_HeaderHovered] = ImVec4(0.38f, 0.25f, 0.60f, 0.8f);
c[ImGuiCol_SliderGrab] = ImVec4(0.65f, 0.45f, 0.90f, 1.f);
c[ImGuiCol_SliderGrabActive] = ImVec4(0.80f, 0.60f, 1.00f, 1.f);
c[ImGuiCol_FrameBg] = ImVec4(0.12f, 0.08f, 0.18f, 1.f);
c[ImGuiCol_FrameBgHovered] = ImVec4(0.22f, 0.15f, 0.32f, 1.f);
c[ImGuiCol_Button] = ImVec4(0.28f, 0.18f, 0.45f, 1.f);
c[ImGuiCol_ButtonHovered] = ImVec4(0.40f, 0.27f, 0.62f, 1.f);
c[ImGuiCol_CheckMark] = ImVec4(0.80f, 0.60f, 1.00f, 1.f);
c[ImGuiCol_ScrollbarGrab] = ImVec4(0.45f, 0.30f, 0.70f, 1.f);
// ── Camera ───────────────────────────────────────────────────────────────
osgViewer::Viewer::Windows windows;
viewer->getWindows(windows);
if (windows.empty()) { std::cerr << "[imgui] No windows found!\n"; return; }
osg::GraphicsContext* gc = windows[0];
const auto* traits = gc ? gc->getTraits() : nullptr;
int winW = traits ? traits->width : 1280;
int winH = traits ? traits->height : 720;
m_camera = new osg::Camera;
m_camera->setName("ImGuiCamera");
m_camera->setRenderOrder(osg::Camera::POST_RENDER, 100);
m_camera->setClearMask(0);
m_camera->setAllowEventFocus(false);
m_camera->setReferenceFrame(osg::Transform::ABSOLUTE_RF);
m_camera->setViewMatrix(osg::Matrix::identity());
m_camera->setProjectionMatrix(osg::Matrix::ortho2D(0, winW, 0, winH));
m_camera->setViewport(0, 0, winW, winH);
m_camera->getOrCreateStateSet()->setMode(GL_DEPTH_TEST, osg::StateAttribute::OFF);
m_camera->setGraphicsContext(gc);
m_camera->setPostDrawCallback(new ImGuiDrawCallback(this));
viewer->addSlave(m_camera, false);
m_viewer = viewer;
std::cout << "[imgui] Overlay camera attached (" << winW << "x" << winH << ").\n";
}
// ─────────────────────────────────────────────────────────────────────────────
bool ImGuiLayer::handleEvent(const osgGA::GUIEventAdapter& ea) {
if (!m_contextCreated) return false;
ImGuiIO& io = ImGui::GetIO();
switch (ea.getEventType()) {
case osgGA::GUIEventAdapter::MOVE:
case osgGA::GUIEventAdapter::DRAG:
io.AddMousePosEvent(ea.getX(), io.DisplaySize.y - ea.getY());
break;
case osgGA::GUIEventAdapter::PUSH:
case osgGA::GUIEventAdapter::RELEASE: {
bool down = ea.getEventType() == osgGA::GUIEventAdapter::PUSH;
if (ea.getButton() == osgGA::GUIEventAdapter::LEFT_MOUSE_BUTTON)
io.AddMouseButtonEvent(0, down);
else if (ea.getButton() == osgGA::GUIEventAdapter::RIGHT_MOUSE_BUTTON)
io.AddMouseButtonEvent(1, down);
else if (ea.getButton() == osgGA::GUIEventAdapter::MIDDLE_MOUSE_BUTTON)
io.AddMouseButtonEvent(2, down);
break;
}
case osgGA::GUIEventAdapter::SCROLL:
io.AddMouseWheelEvent(0.f,
ea.getScrollingMotion() == osgGA::GUIEventAdapter::SCROLL_UP
? 1.f : -1.f);
break;
case osgGA::GUIEventAdapter::KEYDOWN:
case osgGA::GUIEventAdapter::KEYUP: {
bool down = ea.getEventType() == osgGA::GUIEventAdapter::KEYDOWN;
int key = ea.getKey();
if (key >= 32 && key < 127 && down)
io.AddInputCharacter(static_cast<unsigned int>(key));
if (key == osgGA::GUIEventAdapter::KEY_BackSpace)
io.AddKeyEvent(ImGuiKey_Backspace, down);
if (key == osgGA::GUIEventAdapter::KEY_Delete)
io.AddKeyEvent(ImGuiKey_Delete, down);
if (key == osgGA::GUIEventAdapter::KEY_Return)
io.AddKeyEvent(ImGuiKey_Enter, down);
if (key == osgGA::GUIEventAdapter::KEY_Escape)
io.AddKeyEvent(ImGuiKey_Escape, down);
break;
}
case osgGA::GUIEventAdapter::RESIZE: {
int w = ea.getWindowWidth(), h = ea.getWindowHeight();
io.DisplaySize = ImVec2(static_cast<float>(w), static_cast<float>(h));
if (m_camera) {
m_camera->setViewport(0, 0, w, h);
m_camera->setProjectionMatrix(osg::Matrix::ortho2D(0, w, 0, h));
}
break;
}
default: break;
}
return io.WantCaptureMouse &&
(ea.getEventType() == osgGA::GUIEventAdapter::PUSH ||
ea.getEventType() == osgGA::GUIEventAdapter::RELEASE ||
ea.getEventType() == osgGA::GUIEventAdapter::MOVE ||
ea.getEventType() == osgGA::GUIEventAdapter::DRAG ||
ea.getEventType() == osgGA::GUIEventAdapter::SCROLL);
}
// ── Main panel with tabs ──────────────────────────────────────────────────────
void ImGuiLayer::renderPanel() {
ImGuiIO& io = ImGui::GetIO();
ImGui::SetNextWindowPos(
ImVec2(io.DisplaySize.x - m_panelWidth - 10.f, 10.f), ImGuiCond_Once);
ImGui::SetNextWindowSize(
ImVec2(m_panelWidth, io.DisplaySize.y - 20.f), ImGuiCond_Once);
ImGui::SetNextWindowBgAlpha(0.88f);
ImGui::SetNextWindowSizeConstraints(
ImVec2(220.f, 200.f),
ImVec2(io.DisplaySize.x * 0.9f, io.DisplaySize.y));
if (!ImGui::Begin("Model Controls", nullptr, ImGuiWindowFlags_NoCollapse)) {
ImGui::End(); return;
}
m_panelWidth = ImGui::GetWindowWidth();
if (ImGui::BeginTabBar("##tabs")) {
if (ImGui::BeginTabItem("Morphs")) {
renderMorphTab();
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Shaders")) {
renderShaderTab();
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Transform")) {
renderTransformTab();
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Pose")) {
renderPoseTab();
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
ImGui::End();
}
// ── Morphs tab ────────────────────────────────────────────────────────────────
void ImGuiLayer::renderMorphTab() {
if (!m_morphMgr) { ImGui::TextDisabled("No model loaded."); return; }
const auto& names = m_morphMgr->morphNames();
if (names.empty()) { ImGui::TextDisabled("No morphs found."); return; }
// Toolbar
ImGui::SetNextItemWidth(-90.f);
ImGui::InputText("Search##morph", m_searchBuf, sizeof(m_searchBuf));
ImGui::SameLine();
if (ImGui::Button("Reset All")) {
m_morphMgr->resetAll();
m_searchBuf[0] = '\0';
}
ImGui::Checkbox("Active only", &m_showOnlyActive);
ImGui::Separator();
std::string filter(m_searchBuf);
std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower);
// Two-column layout: slider | name
// Slider column is fixed at 120px; name gets all the remaining space
const float sliderW = 120.f;
const float btnW = 22.f;
const float nameW = ImGui::GetContentRegionAvail().x - sliderW - btnW
- ImGui::GetStyle().ItemSpacing.x * 3.f;
int visible = 0;
ImGui::BeginChild("##morphlist", ImVec2(0, 0), false);
for (const auto& name : names) {
float w = m_morphMgr->getWeight(name);
if (m_showOnlyActive && w < 1e-4f) continue;
if (!filter.empty()) {
std::string lname = name;
std::transform(lname.begin(), lname.end(), lname.begin(), ::tolower);
if (lname.find(filter) == std::string::npos) continue;
}
++visible;
bool isActive = (w > 1e-4f);
// Slider
ImGui::SetNextItemWidth(sliderW);
std::string sliderID = "##s" + name;
if (isActive)
ImGui::PushStyleColor(ImGuiCol_SliderGrab,
ImVec4(1.0f, 0.75f, 0.3f, 1.f));
if (ImGui::SliderFloat(sliderID.c_str(), &w, 0.f, 1.f))
m_morphMgr->setWeight(name, w);
if (isActive) ImGui::PopStyleColor();
// Reset button
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.4f, 0.1f, 0.1f, 1.f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.f));
std::string btnID = "x##" + name;
if (ImGui::SmallButton(btnID.c_str()))
m_morphMgr->setWeight(name, 0.f);
ImGui::PopStyleColor(2);
// Name — clipped to available width
ImGui::SameLine();
if (isActive)
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.4f, 1.f));
ImGui::SetNextItemWidth(nameW);
// PushTextWrapPos clips long names cleanly
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + nameW);
ImGui::TextUnformatted(name.c_str());
ImGui::PopTextWrapPos();
if (isActive) ImGui::PopStyleColor();
}
if (visible == 0)
ImGui::TextDisabled("No morphs match filter.");
ImGui::EndChild();
}
// ── Shaders tab ───────────────────────────────────────────────────────────────
void ImGuiLayer::renderShaderTab() {
ImGui::Spacing();
ImGui::TextDisabled("Select a shading mode:");
ImGui::Spacing();
struct ShaderOption {
const char* id;
const char* label;
const char* desc;
};
static const ShaderOption options[] = {
{ "flat", "Flat / Unlit",
"Raw texture colours, no lighting.\nUseful for checking UV maps." },
{ "cel", "Cel Shading",
"Quantised diffuse bands.\nClean anime look without outlines." },
{ "toon", "Toon Shading",
"Cel bands + specular highlight\n+ rim light. Full anime style." },
};
for (auto& opt : options) {
bool selected = (m_currentShader == opt.id);
if (selected)
ImGui::PushStyleColor(ImGuiCol_Button,
ImVec4(0.45f, 0.28f, 0.72f, 1.f));
float bw = ImGui::GetContentRegionAvail().x;
if (ImGui::Button(opt.label, ImVec2(bw, 36.f))) {
m_currentShader = opt.id;
if (onShaderChange) onShaderChange(opt.id);
}
if (selected) ImGui::PopStyleColor();
// Description text, indented
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.6f, 0.6f, 0.7f, 1.f));
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 8.f);
ImGui::TextUnformatted(opt.desc);
ImGui::PopStyleColor();
ImGui::Spacing();
}
ImGui::Separator();
ImGui::Spacing();
float bw = ImGui::GetContentRegionAvail().x;
if (ImGui::Button("Reload Shaders from Disk", ImVec2(bw, 30.f))) {
if (onShaderReload) onShaderReload();
}
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.5f, 0.5f, 0.6f, 1.f));
ImGui::TextWrapped("Reloads GLSL files without recompiling.\n"
"Edit assets/shaders/*.vert / *.frag\nthen click.");
ImGui::PopStyleColor();
}
// ── Transform tab ─────────────────────────────────────────────────────────────
void ImGuiLayer::renderTransformTab() {
ImGui::TextDisabled("Model scale:");
ImGui::Spacing();
// Show current scale value
ImGui::Text("Current: %.4f", m_scale);
ImGui::Spacing();
// Text input for scale
// Pre-fill the buffer with the current value if it's empty
if (m_scaleBuf[0] == '\0')
snprintf(m_scaleBuf, sizeof(m_scaleBuf), "%.4f", m_scale);
ImGui::SetNextItemWidth(-1.f);
bool entered = ImGui::InputText("##scale", m_scaleBuf, sizeof(m_scaleBuf),
ImGuiInputTextFlags_EnterReturnsTrue |
ImGuiInputTextFlags_CharsDecimal);
ImGui::Spacing();
float bw = ImGui::GetContentRegionAvail().x;
bool clicked = ImGui::Button("Apply Scale", ImVec2(bw, 30.f));
if (entered || clicked) {
try {
float parsed = std::stof(std::string(m_scaleBuf));
if (parsed > 0.f) {
m_scale = parsed;
if (onScaleChange) onScaleChange(m_scale);
} else {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1,0.3f,0.3f,1));
ImGui::TextUnformatted("Scale must be > 0");
ImGui::PopStyleColor();
}
} catch (...) {
// non-numeric input — just ignore
}
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::TextDisabled("Quick presets:");
ImGui::Spacing();
// Common scale presets in a 3-column grid
const std::pair<const char*, float> presets[] = {
{"0.01x", 0.01f}, {"0.1x", 0.1f}, {"0.5x", 0.5f},
{"1x", 1.0f}, {"2x", 2.0f}, {"10x", 10.0f},
};
int col = 0;
for (auto& [label, val] : presets) {
if (col > 0) ImGui::SameLine();
float colW = (ImGui::GetContentRegionAvail().x
+ ImGui::GetStyle().ItemSpacing.x * (2 - col)) / (3 - col);
bool active = std::abs(m_scale - val) < 1e-4f;
if (active)
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.28f, 0.72f, 1.f));
if (ImGui::Button(label, ImVec2(colW, 28.f))) {
m_scale = val;
snprintf(m_scaleBuf, sizeof(m_scaleBuf), "%.4f", m_scale);
if (onScaleChange) onScaleChange(m_scale);
}
if (active) ImGui::PopStyleColor();
col = (col + 1) % 3;
}
}
// ── Pose tab ──────────────────────────────────────────────────────────────────
void ImGuiLayer::renderPoseTab() {
ImGui::Spacing();
// ── Enable toggle ─────────────────────────────────────────────────────────
if (ImGui::Checkbox("Enable Pose Mode", &m_poseEnabled)) {
if (onPoseModeToggle) onPoseModeToggle(m_poseEnabled);
}
ImGui::SameLine();
ImGui::TextDisabled("(?)");
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Click bones in viewport to select.\n"
"Adjust rotation/translation below.");
if (!m_poseEnabled) {
ImGui::Spacing();
ImGui::TextDisabled("Enable pose mode to edit bones.");
return;
}
if (!m_poseMgr || !m_poseMgr->isInitialized()) {
ImGui::TextDisabled("No skeleton loaded.");
return;
}
// ── Skeleton visibility ───────────────────────────────────────────────────
if (ImGui::Checkbox("Show Skeleton", &m_bonesVisible)) {
if (onBoneVisToggle) onBoneVisToggle(m_bonesVisible);
}
float bw = ImGui::GetContentRegionAvail().x;
if (ImGui::Button("Reset All Bones", ImVec2(bw, 28.f)))
m_poseMgr->resetAll();
ImGui::Separator();
ImGui::Spacing();
// ── Selected bone gizmo ───────────────────────────────────────────────────
if (!m_selectedBone.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 0.85f, 0.3f, 1.f));
ImGui::Text("Selected: %s", m_selectedBone.c_str());
ImGui::PopStyleColor();
// pose fetched per-control below
// Sync euler display from current pose quat
// Convert quat → euler (degrees) on first selection or change
// (changed flag removed — pose applied immediately via callback)
ImGui::Spacing();
ImGui::TextUnformatted("Rotation (bone-local degrees):");
ImGui::SetNextItemWidth(-1.f);
if (ImGui::SliderFloat3("##rot", m_boneEuler, -180.f, 180.f)) {
// Convert euler degrees to quaternion
float rx = m_boneEuler[0] * M_PI / 180.f;
float ry = m_boneEuler[1] * M_PI / 180.f;
float rz = m_boneEuler[2] * M_PI / 180.f;
osg::Quat qx(rx, osg::Vec3(1,0,0));
osg::Quat qy(ry, osg::Vec3(0,1,0));
osg::Quat qz(rz, osg::Vec3(0,0,1));
// Local-space: Rx * Ry * Rz — rotates around bone's own axes
m_poseMgr->setBoneRotation(m_selectedBone, qx * qy * qz);
}
ImGui::Spacing();
ImGui::TextUnformatted("Translation:");
ImGui::SetNextItemWidth(-1.f);
if (ImGui::SliderFloat3("##trans", m_boneTrans, -20.f, 20.f)) {
m_poseMgr->setBoneTranslation(m_selectedBone,
osg::Vec3(m_boneTrans[0], m_boneTrans[1], m_boneTrans[2]));
}
ImGui::Spacing();
if (ImGui::Button("Reset This Bone", ImVec2(-1.f, 26.f))) {
m_poseMgr->resetBone(m_selectedBone);
memset(m_boneEuler, 0, sizeof(m_boneEuler));
memset(m_boneTrans, 0, sizeof(m_boneTrans));
}
ImGui::Separator();
} else {
ImGui::TextDisabled("Click a bone in the viewport\nor select from the tree below.");
ImGui::Separator();
}
// ── Bone search + tree ────────────────────────────────────────────────────
ImGui::Spacing();
ImGui::SetNextItemWidth(-1.f);
ImGui::InputText("##bonesearch", m_boneSearch, sizeof(m_boneSearch));
ImGui::Spacing();
ImGui::BeginChild("##bonetree", ImVec2(0, 0), false);
std::string filter(m_boneSearch);
std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower);
if (filter.empty()) {
// Show as tree — find root bones (no parent)
const auto& allNames = m_poseMgr->boneNames();
for (const auto& name : allNames) {
if (m_poseMgr->boneParent(name).empty())
renderBoneTree(name, 0);
}
} else {
// Flat filtered list
for (const auto& name : m_poseMgr->boneNames()) {
std::string lname = name;
std::transform(lname.begin(), lname.end(), lname.begin(), ::tolower);
if (lname.find(filter) == std::string::npos) continue;
bool sel = (name == m_selectedBone);
if (sel) ImGui::PushStyleColor(ImGuiCol_Text,
ImVec4(1.f, 0.85f, 0.3f, 1.f));
if (ImGui::Selectable(name.c_str(), sel)) {
m_selectedBone = name;
if (m_boneSel) m_boneSel->setSelected(name);
// Sync sliders to current pose
if (m_poseMgr) {
const auto& p = m_poseMgr->getBonePose(m_selectedBone);
if (p.modified) {
// Extract euler angles from quaternion via rotation matrix
osg::Matrix rotMat; rotMat.makeRotate(p.rotation);
// Extract XYZ euler: atan2 from rotation matrix elements
float ex = std::atan2( rotMat(2,1), rotMat(2,2));
float ey = std::atan2(-rotMat(2,0),
std::sqrt(rotMat(2,1)*rotMat(2,1)+rotMat(2,2)*rotMat(2,2)));
float ez = std::atan2( rotMat(1,0), rotMat(0,0));
m_boneEuler[0] = ex * 180.f / (float)M_PI;
m_boneEuler[1] = ey * 180.f / (float)M_PI;
m_boneEuler[2] = ez * 180.f / (float)M_PI;
m_boneTrans[0] = p.translation.x();
m_boneTrans[1] = p.translation.y();
m_boneTrans[2] = p.translation.z();
} else {
memset(m_boneEuler, 0, sizeof(m_boneEuler));
memset(m_boneTrans, 0, sizeof(m_boneTrans));
}
}
}
if (sel) ImGui::PopStyleColor();
}
}
ImGui::EndChild();
}
// ─────────────────────────────────────────────────────────────────────────────
void ImGuiLayer::renderBoneTree(const std::string& boneName, int depth) {
const auto& childMap = m_poseMgr->boneChildren();
auto it = childMap.find(boneName);
bool hasChildren = (it != childMap.end() && !it->second.empty());
bool sel = (boneName == m_selectedBone);
// PushID ensures unique IDs even for bones with identical display names
ImGui::PushID(boneName.c_str());
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow
| ImGuiTreeNodeFlags_SpanAvailWidth;
if (!hasChildren) flags |= ImGuiTreeNodeFlags_Leaf;
if (sel) flags |= ImGuiTreeNodeFlags_Selected;
const auto& pose = m_poseMgr->getBonePose(boneName);
int colorsPushed = 0;
if (pose.modified) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 0.85f, 0.3f, 1.f));
++colorsPushed;
}
bool open = ImGui::TreeNodeEx("##node", flags, "%s", boneName.c_str());
if (colorsPushed) ImGui::PopStyleColor(colorsPushed);
if (ImGui::IsItemClicked()) {
m_selectedBone = boneName;
if (m_boneSel) m_boneSel->setSelected(boneName);
if (m_poseMgr) {
const auto& p = m_poseMgr->getBonePose(boneName);
if (p.modified) {
// Extract euler angles from quaternion via rotation matrix
osg::Matrix rotMat; rotMat.makeRotate(p.rotation);
// Extract XYZ euler: atan2 from rotation matrix elements
float ex = std::atan2( rotMat(2,1), rotMat(2,2));
float ey = std::atan2(-rotMat(2,0),
std::sqrt(rotMat(2,1)*rotMat(2,1)+rotMat(2,2)*rotMat(2,2)));
float ez = std::atan2( rotMat(1,0), rotMat(0,0));
m_boneEuler[0] = ex * 180.f / (float)M_PI;
m_boneEuler[1] = ey * 180.f / (float)M_PI;
m_boneEuler[2] = ez * 180.f / (float)M_PI;
m_boneTrans[0] = p.translation.x();
m_boneTrans[1] = p.translation.y();
m_boneTrans[2] = p.translation.z();
} else {
memset(m_boneEuler, 0, sizeof(m_boneEuler));
memset(m_boneTrans, 0, sizeof(m_boneTrans));
}
}
}
if (open) {
if (hasChildren)
for (const auto& child : it->second)
renderBoneTree(child, depth + 1);
ImGui::TreePop();
}
ImGui::PopID();
}