From 60873c50b2488207ee94dd61f157b3b055e08f31 Mon Sep 17 00:00:00 2001 From: owner Date: Mon, 16 Mar 2026 00:22:49 -0400 Subject: [PATCH] poses --- .gitignore | 3 +- CMakeLists.txt | 5 +- include/Application.h | 15 +- include/BoneSelector.h | 55 +++++ include/ImGuiLayer.h | 41 +++- include/PoseManager.h | 94 ++++++++ include/SkeletonLoader.h | 93 ++++++++ logs.txt | 65 ++++++ src/Application.cpp | 99 +++++++- src/BoneSelector.cpp | 203 +++++++++++++++++ src/ImGuiLayer.cpp | 178 ++++++++++++++- src/PoseManager.cpp | 173 ++++++++++++++ src/ShaderManager.cpp | 13 +- src/SkeletonLoader.cpp | 473 +++++++++++++++++++++++++++++++++++++++ 14 files changed, 1486 insertions(+), 24 deletions(-) create mode 100644 include/BoneSelector.h create mode 100644 include/PoseManager.h create mode 100644 include/SkeletonLoader.h create mode 100644 logs.txt create mode 100644 src/BoneSelector.cpp create mode 100644 src/PoseManager.cpp create mode 100644 src/SkeletonLoader.cpp diff --git a/.gitignore b/.gitignore index 42afabf..274a80e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +imgui.ini diff --git a/CMakeLists.txt b/CMakeLists.txt index 20f3b1c..3a8316a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,7 +10,7 @@ if(NOT CMAKE_BUILD_TYPE) endif() find_package(OpenSceneGraph REQUIRED COMPONENTS - osgDB osgGA osgUtil osgViewer osgText osgSim) + osgDB osgGA osgUtil osgViewer osgText osgSim osgAnimation) find_package(assimp REQUIRED) find_package(OpenGL REQUIRED) find_package(PkgConfig REQUIRED) @@ -26,6 +26,9 @@ set(SOURCES src/OrbitManipulator.cpp src/ShaderManager.cpp src/AppConfig.cpp + src/SkeletonLoader.cpp + src/PoseManager.cpp + src/BoneSelector.cpp ) add_executable(${PROJECT_NAME} ${SOURCES}) diff --git a/include/Application.h b/include/Application.h index a2c3542..f2cbecc 100644 --- a/include/Application.h +++ b/include/Application.h @@ -14,9 +14,12 @@ #include "ShaderManager.h" #include "AppConfig.h" +#include "PoseManager.h" class MorphManager; class ImGuiLayer; +class SkeletonLoader; +class BoneSelector; class Application : public osgGA::GUIEventHandler { public: @@ -33,27 +36,35 @@ public: void applyPendingShader(); // called by update callback void applyMorphWeights(); // called by update callback + void applyPoseUpdate(); // called by update callback private: void setupLighting(); void setupGrid(); void requestShader(const std::string& mode); void setModelScale(float scale); + void setPoseMode(bool enabled); + void handleViewportClick(float x, float y); osg::ref_ptr m_viewer; osg::ref_ptr m_sceneRoot; osg::ref_ptr m_shaderGroup; osg::ref_ptr m_modelNode; - osg::ref_ptr m_modelXform; // scale/transform wrapper + osg::ref_ptr m_modelXform; + osg::ref_ptr m_skeletonRoot; AppConfig m_config; std::unique_ptr m_shaderMgr; std::unique_ptr m_morphMgr; + std::unique_ptr m_poseMgr; + std::unique_ptr m_boneSel; std::unique_ptr m_imguiLayer; + bool m_poseMode = false; + std::mutex m_shaderMutex; std::string m_pendingShader; std::string m_currentShader = "toon"; bool m_shaderDirty = false; bool m_reloadShaders = false; -}; \ No newline at end of file +}; diff --git a/include/BoneSelector.h b/include/BoneSelector.h new file mode 100644 index 0000000..8f1c986 --- /dev/null +++ b/include/BoneSelector.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +class PoseManager; + +class BoneSelector { +public: + explicit BoneSelector(PoseManager* poseMgr); + + void init(osgViewer::Viewer* viewer, osg::Group* sceneRoot); + + /// Update bone positions each frame. Only rebuilds geometry when + /// selection changes — sphere positions update via vertex array dirty(). + void updateVisualization(); + + std::string pick(float mouseX, float mouseY, osgViewer::Viewer* viewer); + + void setSelected(const std::string& boneName); + const std::string& selected() const { return m_selected; } + + void setVisible(bool v); + bool isVisible() const { return m_visible; } + +private: + void buildInitialGeometry(); // called once on init + void updatePositions(); // cheap per-frame position update + + PoseManager* m_poseMgr = nullptr; + osg::ref_ptr m_root; + osg::ref_ptr m_lines; + osg::ref_ptr m_spheres; + + // Line geometry arrays — updated in place each frame + osg::ref_ptr m_lineVerts; + osg::ref_ptr m_lineColors; + + // Sphere positions — one MatrixTransform per bone + std::vector> m_sphereXforms; + std::vector m_sphereBoneNames; + + std::string m_selected; + std::string m_prevSelected; // detect selection changes + bool m_visible = true; + bool m_geometryBuilt = false; +}; \ No newline at end of file diff --git a/include/ImGuiLayer.h b/include/ImGuiLayer.h index 6fce448..b1a8bb4 100644 --- a/include/ImGuiLayer.h +++ b/include/ImGuiLayer.h @@ -7,45 +7,70 @@ #include class MorphManager; +class PoseManager; +class BoneSelector; class AppConfig; class ImGuiLayer { public: - explicit ImGuiLayer(MorphManager* morphMgr, const AppConfig* cfg); + explicit ImGuiLayer(MorphManager* morphMgr, + const AppConfig* cfg); ~ImGuiLayer(); void init(osgViewer::Viewer* viewer); bool handleEvent(const osgGA::GUIEventAdapter& ea); - void renderPanel(); // called by ImGuiDrawCallback each frame + void renderPanel(); void markGLInitialized(); - // Callbacks set by Application so ImGui can drive app state + // Callbacks std::function onShaderChange; std::function onShaderReload; std::function onScaleChange; + // Pose callbacks + std::function onPoseModeToggle; + std::function onBoneVisToggle; - // Called by Application each frame so the shader tab shows current state void setCurrentShader(const std::string& s) { m_currentShader = s; } void setInitialScale(float s) { m_scale = s; m_scaleBuf[0] = 0; } + // Set pose subsystem references so the Pose tab can drive them + void setPoseManager(PoseManager* pm) { m_poseMgr = pm; } + void setBoneSelector(BoneSelector* bs) { m_boneSel = bs; } + + // Called by Application when the user clicks a bone in the viewport + void selectBone(const std::string& name) { m_selectedBone = name; } + private: void renderMorphTab(); void renderShaderTab(); void renderTransformTab(); + void renderPoseTab(); + void renderBoneTree(const std::string& boneName, int depth); MorphManager* m_morphMgr = nullptr; + PoseManager* m_poseMgr = nullptr; + BoneSelector* m_boneSel = nullptr; const AppConfig* m_cfg = nullptr; osgViewer::Viewer* m_viewer = nullptr; osg::ref_ptr m_camera; bool m_contextCreated = false; bool m_glInitialized = false; - float m_panelWidth = 380.f; // wider default so names are visible + float m_panelWidth = 380.f; - char m_searchBuf[256] = {}; - bool m_showOnlyActive = false; + char m_searchBuf[256] = {}; + char m_boneSearch[256] = {}; + bool m_showOnlyActive = false; std::string m_currentShader = "toon"; float m_scale = 1.0f; char m_scaleBuf[32] = {}; -}; \ No newline at end of file + + // Pose tab state + bool m_poseEnabled = false; + bool m_bonesVisible = true; + std::string m_selectedBone; + // Euler angles for selected bone (degrees, for display) + float m_boneEuler[3] = {}; + float m_boneTrans[3] = {}; +}; diff --git a/include/PoseManager.h b/include/PoseManager.h new file mode 100644 index 0000000..6aebef3 --- /dev/null +++ b/include/PoseManager.h @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +/** + * PoseManager + * ----------- + * Owns the current pose as a map of bone name → local rotation + translation + * offsets from the bind pose. Applies them to the osgAnimation bone tree + * each frame during the update traversal. + * + * Usage: + * // At load time: + * mgr.init(skeleton, boneMap); + * + * // From ImGui / bone gizmo: + * mgr.setBoneRotation("Head", osg::Quat(...)); + * + * // In update callback: + * mgr.applyPose(); + * + * // Reset: + * mgr.resetBone("Head"); + * mgr.resetAll(); + */ +class PoseManager { +public: + struct BonePose { + osg::Quat rotation = { 0, 0, 0, 1 }; // local rotation delta + osg::Vec3 translation = { 0, 0, 0 }; // local translation delta + bool modified = false; + }; + + PoseManager() = default; + + /// Called once after skeleton is loaded. + void init(osgAnimation::Skeleton* skeleton, + const std::unordered_map>& boneMap); + + /// Sorted list of all bone names (for UI tree). + const std::vector& boneNames() const { return m_boneNames; } + + /// Bone hierarchy as parent name → child names (for tree display). + const std::unordered_map>& + boneChildren() const { return m_children; } + + std::string boneParent(const std::string& name) const; + + // ── Pose setters ────────────────────────────────────────────────────────── + void setBoneRotation(const std::string& name, const osg::Quat& q); + void setBoneTranslation(const std::string& name, const osg::Vec3& t); + void resetBone(const std::string& name); + void resetAll(); + + // ── Pose getters ────────────────────────────────────────────────────────── + const BonePose& getBonePose(const std::string& name) const; + osg::Vec3 getBoneWorldPos(const std::string& name) const; + + // ── Per-frame update ────────────────────────────────────────────────────── + /// Apply current pose to the osgAnimation bone tree. + void applyPose(); + + /// Run a skeleton update traversal to refresh world-space bone matrices. + /// Call every frame so getBoneWorldPos() returns current positions. + void updateSkeletonMatrices(); + + bool isInitialized() const { return m_initialized; } + +private: + bool m_initialized = false; + unsigned int m_traversalCount = 1000; + + osg::ref_ptr m_skeleton; + std::unordered_map> m_boneMap; + std::unordered_map m_poses; + std::unordered_map m_bindMatrices; // local bind pose + std::unordered_map m_parents; // child → parent + std::unordered_map> m_children; + std::vector m_boneNames; // sorted + BonePose m_defaultPose; // returned for unknowns + + void buildHierarchy(osgAnimation::Bone* bone, const std::string& parentName); +}; \ No newline at end of file diff --git a/include/SkeletonLoader.h b/include/SkeletonLoader.h new file mode 100644 index 0000000..132bf8f --- /dev/null +++ b/include/SkeletonLoader.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +// Forward declarations +struct aiScene; +struct aiNode; +struct aiMesh; + +/** + * SkeletonLoader + * -------------- + * Reads the Assimp scene's bone hierarchy and mesh skin weights, + * and builds an osgAnimation scene graph: + * + * osgAnimation::Skeleton + * └─ osgAnimation::Bone (Hips) + * └─ osgAnimation::Bone (Spine) + * └─ ... + * + * Each skinned aiMesh becomes an osgAnimation::RigGeometry instead + * of a plain osg::Geometry, so the GPU handles skinning automatically. + * + * Usage: + * SkeletonLoader loader; + * auto result = loader.load(assimpScene, baseDir, morphMgr); + * // result.root — attach to scene graph + * // result.boneMap — name → Bone* for posing + */ +class MorphManager; + +class SkeletonLoader { +public: + struct Result { + osg::ref_ptr root; + osg::ref_ptr skeleton; + std::unordered_map> boneMap; + bool valid = false; + }; + + Result load(const struct aiScene* scene, + const std::string& baseDir, + MorphManager* morphMgr = nullptr); + +private: + // ── Bone hierarchy ──────────────────────────────────────────────────────── + + /// Recursively build Bone nodes from the aiNode tree. + osg::ref_ptr buildBoneTree( + const aiNode* node, + const std::unordered_map& boneNames, + std::unordered_map>& boneMap, + const osg::Matrix& parentAccum = osg::Matrix::identity()); + + /// Find the armature root node (first ancestor whose children are all bones). + const aiNode* findArmatureRoot(const aiNode* root, + const std::unordered_map& boneNames); + + // ── Mesh conversion ─────────────────────────────────────────────────────── + + /// Convert a skinned aiMesh to RigGeometry. + osg::ref_ptr convertSkinnedMesh( + const aiMesh* mesh, + const aiScene* scene, + const std::string& baseDir, + MorphManager* morphMgr, + const std::unordered_map>& boneMap); + + /// Convert an unskinned aiMesh to plain osg::Geometry (same as before). + osg::ref_ptr convertStaticMesh( + const aiMesh* mesh, + const aiScene* scene, + const std::string& baseDir, + MorphManager* morphMgr); + + /// Shared material/texture setup used by both converters. + void applyMaterial(osg::StateSet* ss, + const aiMesh* mesh, + const aiScene* scene, + const std::string& baseDir); +}; \ No newline at end of file diff --git a/logs.txt b/logs.txt new file mode 100644 index 0000000..4454455 --- /dev/null +++ b/logs.txt @@ -0,0 +1,65 @@ +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f36c1ae050 RigTransformSoftware::VertexGroup no bones found +0x55f36c1ae050 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f36c1ae150 RigTransformSoftware::VertexGroup no bones found +0x55f36c1ae150 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f374ff0910 RigTransformSoftware::VertexGroup no bones found +0x55f374ff0910 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f372abe300 RigTransformSoftware::VertexGroup no bones found +0x55f372abe300 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f372986100 RigTransformSoftware::VertexGroup no bones found +0x55f372986100 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f36bd40a10 RigTransformSoftware::VertexGroup no bones found +0x55f36bd40a10 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f36bf07ac0 RigTransformSoftware::VertexGroup no bones found +0x55f36bf07ac0 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f3755f8410 RigTransformSoftware::VertexGroup no bones found +0x55f3755f8410 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f37527b890 RigTransformSoftware::VertexGroup no bones found +0x55f37527b890 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f36c180aa0 RigTransformSoftware::VertexGroup no bones found +0x55f36c180aa0 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f36c1807d0 RigTransformSoftware::VertexGroup no bones found +0x55f36c1807d0 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f37526bc40 RigTransformSoftware::VertexGroup no bones found +0x55f37526bc40 RigTransformSoftware::VertexGroup no bones found +RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup +0x55f37526cb80 RigTransformSoftware::VertexGroup no bones found +0x55f37526cb80 RigTransformSoftware::VertexGroup no bones found +0x55f36c1ae050 RigTransformSoftware::VertexGroup no bones found +0x55f36c1ae050 RigTransformSoftware::VertexGroup no bones found +0x55f36c1ae150 RigTransformSoftware::VertexGroup no bones found +0x55f36c1ae150 RigTransformSoftware::VertexGroup no bones found +0x55f374ff0910 RigTransformSoftware::VertexGroup no bones found +0x55f374ff0910 RigTransformSoftware::VertexGroup no bones found +0x55f372abe300 RigTransformSoftware::VertexGroup no bones found +0x55f372abe300 RigTransformSoftware::VertexGroup no bones found +0x55f372986100 RigTransformSoftware::VertexGroup no bones found +0x55f372986100 RigTransformSoftware::VertexGroup no bones found +0x55f36bd40a10 RigTransformSoftware::VertexGroup no bones found +0x55f36bd40a10 RigTransformSoftware::VertexGroup no bones found +0x55f36bf07ac0 RigTransformSoftware::VertexGroup no bones found +0x55f36bf07ac0 RigTransformSoftware::VertexGroup no bones found +0x55f3755f8410 RigTransformSoftware::VertexGroup no bones found +0x55f3755f8410 RigTransformSoftware::VertexGroup no bones found +0x55f37527b890 RigTransformSoftware::VertexGroup no bones found +0x55f37527b890 RigTransformSoftware::VertexGroup no bones found +0x55f36c180aa0 RigTransformSoftware::VertexGroup no bones found +0x55f36c180aa0 RigTransformSoftware::VertexGroup no bones found +0x55f36c1807d0 RigTransformSoftware::VertexGroup no bones found +0x55f36c1807d0 RigTransformSoftware::VertexGroup no bones found +0x55f37526bc40 RigTransformSoftware::VertexGroup no bones found +0x55f37526bc40 RigTransformSoftware::VertexGroup no bones found +0x55f37526cb80 RigTransformSoftware::VertexGroup no bones found +0x55f37526cb80 RigTransformSoftware::VertexGroup no bones found diff --git a/src/Application.cpp b/src/Application.cpp index 1289c27..6d66437 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -5,6 +5,13 @@ #include "OrbitManipulator.h" #include "MorphManager.h" #include "AppConfig.h" +#include "SkeletonLoader.h" +#include "PoseManager.h" +#include "BoneSelector.h" +#include +#include +#include +#include #include "ImGuiLayer.h" #include @@ -15,6 +22,7 @@ #include #include #include +#include #include #include @@ -24,8 +32,9 @@ class AppUpdateCallback : public osg::NodeCallback { public: explicit AppUpdateCallback(Application* app) : m_app(app) {} void operator()(osg::Node* node, osg::NodeVisitor* nv) override { - m_app->applyPendingShader(); // shader switches - m_app->applyMorphWeights(); // morph deformation — must be in update traversal + m_app->applyPendingShader(); + m_app->applyMorphWeights(); + m_app->applyPoseUpdate(); // bone posing traverse(node, nv); } private: @@ -37,6 +46,7 @@ private: Application::Application() : m_shaderMgr(std::make_unique("assets/shaders")) , m_morphMgr (std::make_unique()) + , m_poseMgr (std::make_unique()) { m_config.load(); m_imguiLayer = std::make_unique(m_morphMgr.get(), &m_config); @@ -109,18 +119,61 @@ bool Application::init(int width, int height, const std::string& title) { bool Application::loadModel(const std::string& filepath) { std::cout << "[app] Loading model: " << filepath << "\n"; - ModelLoader loader; - m_modelNode = loader.load(filepath, m_morphMgr.get()); - if (!m_modelNode) return false; + // Try skeleton loader (handles FBX with bones) + Assimp::Importer importer; + constexpr unsigned flags = + aiProcess_Triangulate | aiProcess_GenSmoothNormals | + aiProcess_SortByPType | aiProcess_ImproveCacheLocality; + const aiScene* scene = importer.ReadFile(filepath, flags); - // Wrap model in a transform so scale/position can be adjusted at runtime + if (scene && scene->mRootNode) { + const std::string baseDir = + std::filesystem::path(filepath).parent_path().string(); + SkeletonLoader skelLoader; + auto skelResult = skelLoader.load(scene, baseDir, m_morphMgr.get()); + if (skelResult.valid) { + m_modelNode = skelResult.root; + m_poseMgr->init(skelResult.skeleton.get(), skelResult.boneMap); + m_boneSel = std::make_unique(m_poseMgr.get()); + m_boneSel->init(m_viewer.get(), m_sceneRoot.get()); + m_imguiLayer->setPoseManager(m_poseMgr.get()); + m_imguiLayer->setBoneSelector(m_boneSel.get()); + m_imguiLayer->onPoseModeToggle = [this](bool e) { setPoseMode(e); }; + m_imguiLayer->onBoneVisToggle = [this](bool v) { + if (m_boneSel) m_boneSel->setVisible(v); + }; + // Force a skeleton update traversal immediately so the bone map + // is populated before the first draw call. Without this, RigGeometry + // tries to skin on frame 0 before bones are linked, collapsing verts. + // We run it twice — once to build the bone map, once to propagate. + for (int warmup = 0; warmup < 3; ++warmup) { + osg::NodeVisitor uv(osg::NodeVisitor::UPDATE_VISITOR, + osg::NodeVisitor::TRAVERSE_ALL_CHILDREN); + uv.setTraversalNumber(warmup); + skelResult.root->accept(uv); + } + std::cout << "[app] Skeleton warmup done.\n"; + std::cout << "[app] Skeleton loaded: " + << m_poseMgr->boneNames().size() << " bones.\n"; + } + } + + // Fallback to plain loader if skeleton load failed + if (!m_modelNode) { + ModelLoader loader; + m_modelNode = loader.load(filepath, m_morphMgr.get()); + if (!m_modelNode) return false; + std::cout << "[app] Loaded as static mesh.\n"; + } + + // Wrap in scale transform m_modelXform = new osg::MatrixTransform; m_modelXform->setName("ModelTransform"); - float initScale = m_config.getFloat("model.scale", 1.0f); m_modelXform->setMatrix(osg::Matrix::scale(initScale, initScale, initScale)); m_modelXform->addChild(m_modelNode); m_shaderGroup->addChild(m_modelXform); + requestShader(m_currentShader); m_viewer->home(); @@ -149,6 +202,14 @@ bool Application::handle(const osgGA::GUIEventAdapter& ea, // Forward to ImGui first — if it wants the event, don't pass to camera if (m_imguiLayer->handleEvent(ea)) return true; + // Bone picking on left click in pose mode + if (m_poseMode && + ea.getEventType() == osgGA::GUIEventAdapter::PUSH && + ea.getButton() == osgGA::GUIEventAdapter::LEFT_MOUSE_BUTTON) { + handleViewportClick(ea.getX(), ea.getY()); + // Don't consume — let camera manipulator also handle it + } + if (ea.getEventType() != osgGA::GUIEventAdapter::KEYDOWN) return false; if (!m_modelNode) return false; @@ -191,6 +252,30 @@ void Application::applyMorphWeights() { if (m_morphMgr) m_morphMgr->applyWeights(); } +void Application::setPoseMode(bool enabled) { + m_poseMode = enabled; + if (m_boneSel) m_boneSel->setVisible(enabled); + std::cout << "[app] Pose mode: " << (enabled ? "ON" : "OFF") << "\n"; +} + +void Application::applyPoseUpdate() { + if (!m_poseMgr || !m_poseMgr->isInitialized()) return; + // applyPose sets local matrices, then updateSkeletonMatrices propagates FK + if (m_poseMode) m_poseMgr->applyPose(); + m_poseMgr->updateSkeletonMatrices(); // always keep world positions fresh + if (m_poseMode && m_boneSel) m_boneSel->updateVisualization(); +} + +void Application::handleViewportClick(float x, float y) { + if (!m_poseMode || !m_boneSel) return; + std::string hit = m_boneSel->pick(x, y, m_viewer.get()); + if (!hit.empty()) { + m_boneSel->setSelected(hit); + m_imguiLayer->selectBone(hit); + std::cout << "[app] Selected bone: " << hit << "\n"; + } +} + void Application::setModelScale(float scale) { if (m_modelXform) { scale = std::max(0.001f, scale); diff --git a/src/BoneSelector.cpp b/src/BoneSelector.cpp new file mode 100644 index 0000000..bb50080 --- /dev/null +++ b/src/BoneSelector.cpp @@ -0,0 +1,203 @@ +#include "BoneSelector.h" +#include "PoseManager.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── + +BoneSelector::BoneSelector(PoseManager* poseMgr) + : m_poseMgr(poseMgr) +{} + +// ───────────────────────────────────────────────────────────────────────────── + +void BoneSelector::init(osgViewer::Viewer* viewer, osg::Group* sceneRoot) { + (void)viewer; + + m_root = new osg::Group; + m_root->setName("BoneOverlay"); + + osg::StateSet* ss = m_root->getOrCreateStateSet(); + ss->setMode(GL_LIGHTING, osg::StateAttribute::OFF); + ss->setMode(GL_DEPTH_TEST,osg::StateAttribute::OFF); + ss->setRenderBinDetails(100, "RenderBin"); + + sceneRoot->addChild(m_root); + // Geometry built lazily on first updateVisualization() call +} + +// ───────────────────────────────────────────────────────────────────────────── + +void BoneSelector::buildInitialGeometry() { + if (!m_poseMgr || !m_poseMgr->isInitialized()) return; + + const auto& names = m_poseMgr->boneNames(); + + // ── Lines ───────────────────────────────────────────────────────────────── + m_lineVerts = new osg::Vec3Array; + m_lineColors = new osg::Vec4Array; + m_lineVerts->resize(names.size() * 2, osg::Vec3()); + m_lineColors->resize(names.size() * 2, osg::Vec4(0.4f,0.8f,1.f,0.7f)); + + auto lineGeom = new osg::Geometry; + lineGeom->setDataVariance(osg::Object::DYNAMIC); + lineGeom->setVertexArray(m_lineVerts); + lineGeom->setColorArray(m_lineColors, osg::Array::BIND_PER_VERTEX); + lineGeom->addPrimitiveSet( + new osg::DrawArrays(osg::PrimitiveSet::LINES, 0, m_lineVerts->size())); + lineGeom->getOrCreateStateSet()->setAttribute(new osg::LineWidth(1.5f)); + + m_lines = new osg::Geode; + m_lines->addDrawable(lineGeom); + m_root->addChild(m_lines); + + // ── Spheres — one MatrixTransform per bone ──────────────────────────────── + // We move them by updating their matrix, not rebuilding the drawables. + auto sphereGroup = new osg::Group; + m_root->addChild(sphereGroup); + + m_sphereXforms.clear(); + m_sphereBoneNames.clear(); + + for (const auto& name : names) { + auto xform = new osg::MatrixTransform; + xform->setName(name); + xform->setDataVariance(osg::Object::DYNAMIC); + + auto sphere = new osg::ShapeDrawable(new osg::Sphere(osg::Vec3(), 0.012f)); + sphere->setName(name); + sphere->setColor(osg::Vec4(0.3f, 0.9f, 1.f, 0.9f)); + + auto geode = new osg::Geode; + geode->addDrawable(sphere); + geode->setName(name); + xform->addChild(geode); + + sphereGroup->addChild(xform); + m_sphereXforms.push_back(xform); + m_sphereBoneNames.push_back(name); + } + + m_geometryBuilt = true; + std::cout << "[bones] Built overlay for " << names.size() << " bones.\n"; +} + +// ───────────────────────────────────────────────────────────────────────────── + +void BoneSelector::updatePositions() { + if (!m_poseMgr) return; + // names accessed via m_sphereBoneNames + bool selChanged = (m_selected != m_prevSelected); + m_prevSelected = m_selected; + + // Update line vertices and sphere transforms + int lineIdx = 0; + for (unsigned i = 0; i < m_sphereBoneNames.size() && i < m_sphereXforms.size(); ++i) { + const std::string& name = m_sphereBoneNames[i]; + osg::Vec3 pos = m_poseMgr->getBoneWorldPos(name); + + // Move sphere via transform + m_sphereXforms[i]->setMatrix(osg::Matrix::translate(pos)); + + // Update selection colour if changed + if (selChanged) { + bool isSel = (name == m_selected); + if (auto* geode = dynamic_cast( + m_sphereXforms[i]->getChild(0))) { + if (auto* sd = dynamic_cast( + geode->getDrawable(0))) { + sd->setColor(isSel + ? osg::Vec4(1.f, 0.9f, 0.f, 1.f) + : osg::Vec4(0.3f, 0.9f, 1.f, 0.9f)); + } + } + } + + // Line: parent → this bone + std::string parent = m_poseMgr->boneParent(name); + if (!parent.empty() && lineIdx + 1 < (int)m_lineVerts->size()) { + osg::Vec3 parentPos = m_poseMgr->getBoneWorldPos(parent); + bool isSel = (name == m_selected || parent == m_selected); + (*m_lineVerts)[lineIdx] = parentPos; + (*m_lineVerts)[lineIdx+1] = pos; + osg::Vec4 col = isSel + ? osg::Vec4(1.f, 0.8f, 0.f, 1.f) + : osg::Vec4(0.4f, 0.8f, 1.f, 0.7f); + (*m_lineColors)[lineIdx] = col; + (*m_lineColors)[lineIdx+1] = col; + lineIdx += 2; + } + } + + // Dirty line arrays + if (m_lineVerts) m_lineVerts->dirty(); + if (m_lineColors) m_lineColors->dirty(); + if (m_lines && m_lines->getNumDrawables() > 0) + m_lines->getDrawable(0)->dirtyBound(); +} + +// ───────────────────────────────────────────────────────────────────────────── + +void BoneSelector::updateVisualization() { + if (!m_poseMgr || !m_poseMgr->isInitialized() || !m_visible) { + if (m_root) m_root->setNodeMask(0); + return; + } + m_root->setNodeMask(~0u); + + if (!m_geometryBuilt) buildInitialGeometry(); + + updatePositions(); +} + +// ───────────────────────────────────────────────────────────────────────────── + +std::string BoneSelector::pick(float mouseX, float mouseY, + osgViewer::Viewer* viewer) { + if (!viewer || !m_root) return {}; + + osg::ref_ptr intersector = + new osgUtil::LineSegmentIntersector( + osgUtil::Intersector::WINDOW, mouseX, mouseY); + + osgUtil::IntersectionVisitor iv(intersector); + m_root->accept(iv); + + if (!intersector->containsIntersections()) return {}; + + const auto& hit = *intersector->getIntersections().begin(); + + // Walk node path for named node + for (auto it = hit.nodePath.rbegin(); it != hit.nodePath.rend(); ++it) { + const std::string& n = (*it)->getName(); + if (!n.empty() && std::find(m_sphereBoneNames.begin(), + m_sphereBoneNames.end(), n) + != m_sphereBoneNames.end()) + return n; + } + if (hit.drawable.valid() && !hit.drawable->getName().empty()) + return hit.drawable->getName(); + + return {}; +} + +// ───────────────────────────────────────────────────────────────────────────── + +void BoneSelector::setSelected(const std::string& boneName) { + m_selected = boneName; +} + +void BoneSelector::setVisible(bool v) { + m_visible = v; + if (m_root) m_root->setNodeMask(v ? ~0u : 0u); +} \ No newline at end of file diff --git a/src/ImGuiLayer.cpp b/src/ImGuiLayer.cpp index b8b4fc7..8e7ad1c 100644 --- a/src/ImGuiLayer.cpp +++ b/src/ImGuiLayer.cpp @@ -1,6 +1,8 @@ #include "ImGuiLayer.h" #include "MorphManager.h" #include "AppConfig.h" +#include "PoseManager.h" +#include "BoneSelector.h" #include #include @@ -266,6 +268,10 @@ void ImGuiLayer::renderPanel() { renderTransformTab(); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Pose")) { + renderPoseTab(); + ImGui::EndTabItem(); + } ImGui::EndTabBar(); } @@ -413,7 +419,6 @@ void ImGuiLayer::renderShaderTab() { // ── Transform tab ───────────────────────────────────────────────────────────── void ImGuiLayer::renderTransformTab() { - ImGui::Spacing(); ImGui::TextDisabled("Model scale:"); ImGui::Spacing(); @@ -482,4 +487,175 @@ void ImGuiLayer::renderTransformTab() { 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 (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)); + m_poseMgr->setBoneRotation(m_selectedBone, qz * qy * qx); + + } + + ImGui::Spacing(); + ImGui::TextUnformatted("Translation:"); + ImGui::SetNextItemWidth(-1.f); + if (ImGui::SliderFloat3("##trans", m_boneTrans, -0.5f, 0.5f)) { + 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); + 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); + 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(); } \ No newline at end of file diff --git a/src/PoseManager.cpp b/src/PoseManager.cpp new file mode 100644 index 0000000..6786b9e --- /dev/null +++ b/src/PoseManager.cpp @@ -0,0 +1,173 @@ +#include "PoseManager.h" +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── + +void PoseManager::init( + osgAnimation::Skeleton* skeleton, + const std::unordered_map>& boneMap) { + + m_skeleton = skeleton; + m_boneMap = boneMap; + + // Run one update traversal so all bones get their initial matrices computed + // from their StackedTransform, then we capture those as bind matrices. + { + osg::NodeVisitor nv(osg::NodeVisitor::UPDATE_VISITOR, + osg::NodeVisitor::TRAVERSE_ALL_CHILDREN); + nv.setTraversalNumber(1); + skeleton->accept(nv); + } + + for (auto& [name, bone] : boneMap) { + // Capture bind pose AFTER the first update so it reflects the + // fully-composed local matrix from StackedTransform + m_bindMatrices[name] = bone->getMatrix(); + m_poses[name] = BonePose{}; + + // Remove the UpdateBone callback permanently — we drive all bone + // matrices manually from now on. This prevents the callback from + // ever overwriting our pose values. + bone->setUpdateCallback(nullptr); + } + + // Build hierarchy from root bones only + for (auto& [name, bone] : boneMap) { + bool hasParentInMap = false; + for (unsigned i = 0; i < bone->getNumParents(); ++i) { + auto* parent = dynamic_cast(bone->getParent(i)); + if (parent && boneMap.count(parent->getName())) { + hasParentInMap = true; + break; + } + } + if (!hasParentInMap) + buildHierarchy(bone.get(), ""); + } + + m_boneNames.clear(); + for (auto& [name, _] : boneMap) + m_boneNames.push_back(name); + std::sort(m_boneNames.begin(), m_boneNames.end()); + + m_traversalCount = 1000; + m_initialized = true; + std::cout << "[pose] PoseManager initialized: " << boneMap.size() << " bones.\n"; +} + +// ───────────────────────────────────────────────────────────────────────────── + +void PoseManager::buildHierarchy(osgAnimation::Bone* bone, + const std::string& parentName) { + std::string name = bone->getName(); + if (!parentName.empty()) { + m_parents[name] = parentName; + m_children[parentName].push_back(name); + } + for (unsigned i = 0; i < bone->getNumChildren(); ++i) { + auto* child = dynamic_cast(bone->getChild(i)); + if (child) buildHierarchy(child, name); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +std::string PoseManager::boneParent(const std::string& name) const { + auto it = m_parents.find(name); + return it != m_parents.end() ? it->second : ""; +} + +void PoseManager::setBoneRotation(const std::string& name, const osg::Quat& q) { + auto it = m_poses.find(name); + if (it == m_poses.end()) return; + it->second.rotation = q; + it->second.modified = true; +} + +void PoseManager::setBoneTranslation(const std::string& name, const osg::Vec3& t) { + auto it = m_poses.find(name); + if (it == m_poses.end()) return; + it->second.translation = t; + it->second.modified = true; +} + +void PoseManager::resetBone(const std::string& name) { + auto it = m_poses.find(name); + if (it == m_poses.end()) return; + it->second = BonePose{}; +} + +void PoseManager::resetAll() { + for (auto& [name, pose] : m_poses) pose = BonePose{}; +} + +const PoseManager::BonePose& PoseManager::getBonePose(const std::string& name) const { + auto it = m_poses.find(name); + return it != m_poses.end() ? it->second : m_defaultPose; +} + +osg::Vec3 PoseManager::getBoneWorldPos(const std::string& name) const { + auto it = m_boneMap.find(name); + if (it == m_boneMap.end()) return {}; + return it->second->getMatrixInSkeletonSpace().getTrans(); +} + +// ───────────────────────────────────────────────────────────────────────────── + +void PoseManager::updateSkeletonMatrices() { + // Since all UpdateBone callbacks are removed, we manually propagate + // bone matrices through the hierarchy by walking parent→child. + if (!m_initialized || !m_skeleton) return; + + // Walk the skeleton tree and compute skeleton-space matrices + std::function propagate = + [&](osgAnimation::Bone* bone, const osg::Matrix& parentSkelMat) { + osg::Matrix skelMat = bone->getMatrix() * parentSkelMat; + bone->setMatrixInSkeletonSpace(skelMat); + for (unsigned i = 0; i < bone->getNumChildren(); ++i) { + auto* child = dynamic_cast(bone->getChild(i)); + if (child) propagate(child, skelMat); + } + }; + + // Skeleton's own matrix is identity by default + for (unsigned i = 0; i < m_skeleton->getNumChildren(); ++i) { + auto* root = dynamic_cast(m_skeleton->getChild(i)); + if (root) propagate(root, m_skeleton->getMatrix()); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +void PoseManager::applyPose() { + if (!m_initialized) return; + + // Set each bone's local matrix: bind pose + pose delta + for (auto& [name, pose] : m_poses) { + auto boneIt = m_boneMap.find(name); + if (boneIt == m_boneMap.end()) continue; + osgAnimation::Bone* bone = boneIt->second.get(); + + auto bindIt = m_bindMatrices.find(name); + if (bindIt == m_bindMatrices.end()) continue; + const osg::Matrix& bind = bindIt->second; + + if (!pose.modified) { + bone->setMatrix(bind); + continue; + } + + osg::Vec3 bindTrans = bind.getTrans(); + osg::Quat bindRot; osg::Vec3 sc; osg::Quat so; + bind.decompose(bindTrans, bindRot, sc, so); + + bone->setMatrix( + osg::Matrix::scale(sc) * + osg::Matrix::rotate(pose.rotation * bindRot) * + osg::Matrix::translate(bindTrans + pose.translation)); + } +} \ No newline at end of file diff --git a/src/ShaderManager.cpp b/src/ShaderManager.cpp index f3cb684..735849f 100644 --- a/src/ShaderManager.cpp +++ b/src/ShaderManager.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -83,11 +84,15 @@ public: osg::Drawable* drawable = geode.getDrawable(i); if (!drawable) continue; - // Use getStateSet() — don't create one if absent, it means - // this drawable truly has no material/texture. - osg::StateSet* ss = drawable->getStateSet(); + // RigGeometry stores material/texture on its SOURCE geometry + osg::StateSet* ss = nullptr; + if (auto* rig = dynamic_cast(drawable)) { + if (rig->getSourceGeometry()) + ss = rig->getSourceGeometry()->getStateSet(); + } + if (!ss) ss = drawable->getStateSet(); + if (!ss) { - // No StateSet at all — create one and mark no texture ss = drawable->getOrCreateStateSet(); ss->addUniform(new osg::Uniform("u_hasTexture", false), osg::StateAttribute::ON | osg::StateAttribute::OVERRIDE); diff --git a/src/SkeletonLoader.cpp b/src/SkeletonLoader.cpp new file mode 100644 index 0000000..47c5c87 --- /dev/null +++ b/src/SkeletonLoader.cpp @@ -0,0 +1,473 @@ +#include "SkeletonLoader.h" +#include "MorphManager.h" + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +static osg::ref_ptr loadTex(const std::string& path) { + auto tryLoad = [](const std::string& p) -> osg::ref_ptr { + auto img = osgDB::readImageFile(p); + if (!img) return {}; + auto t = new osg::Texture2D(img); + t->setWrap(osg::Texture::WRAP_S, osg::Texture::REPEAT); + t->setWrap(osg::Texture::WRAP_T, osg::Texture::REPEAT); + t->setFilter(osg::Texture::MIN_FILTER, osg::Texture::LINEAR_MIPMAP_LINEAR); + t->setFilter(osg::Texture::MAG_FILTER, osg::Texture::LINEAR); + return t; + }; + if (auto t = tryLoad(path)) return t; + auto dot = path.rfind('.'); + if (dot != std::string::npos) + for (auto ext : {".jpg",".jpeg",".png",".tga",".bmp"}) + if (auto t = tryLoad(path.substr(0, dot) + ext)) return t; + return {}; +} + +static osg::Matrix aiToOsg(const aiMatrix4x4& m) { + return osg::Matrix(m.a1,m.b1,m.c1,m.d1, m.a2,m.b2,m.c2,m.d2, + m.a3,m.b3,m.c3,m.d3, m.a4,m.b4,m.c4,m.d4); +} + +// ── applyMaterial ───────────────────────────────────────────────────────────── + +void SkeletonLoader::applyMaterial(osg::StateSet* ss, const aiMesh* mesh, + const aiScene* scene, const std::string& baseDir) { + if (mesh->mMaterialIndex >= scene->mNumMaterials) return; + const aiMaterial* mat = scene->mMaterials[mesh->mMaterialIndex]; + + auto osgMat = new osg::Material; + aiColor4D col; + if (AI_SUCCESS == mat->Get(AI_MATKEY_COLOR_DIFFUSE, col)) + osgMat->setDiffuse (osg::Material::FRONT_AND_BACK,{col.r,col.g,col.b,col.a}); + if (AI_SUCCESS == mat->Get(AI_MATKEY_COLOR_AMBIENT, col)) + osgMat->setAmbient (osg::Material::FRONT_AND_BACK,{col.r,col.g,col.b,col.a}); + if (AI_SUCCESS == mat->Get(AI_MATKEY_COLOR_SPECULAR, col)) + osgMat->setSpecular(osg::Material::FRONT_AND_BACK,{col.r,col.g,col.b,col.a}); + float shin = 0; + if (AI_SUCCESS == mat->Get(AI_MATKEY_SHININESS, shin)) + osgMat->setShininess(osg::Material::FRONT_AND_BACK, std::min(shin,128.f)); + ss->setAttribute(osgMat, osg::StateAttribute::ON); + + if (mat->GetTextureCount(aiTextureType_DIFFUSE) > 0) { + aiString tp; mat->GetTexture(aiTextureType_DIFFUSE, 0, &tp); + std::string full = baseDir + "/" + tp.C_Str(); + for (char& c : full) if (c=='\\') c='/'; + if (auto tex = loadTex(full)) + ss->setTextureAttributeAndModes(0, tex, osg::StateAttribute::ON); + } + + float opacity = 1.f; mat->Get(AI_MATKEY_OPACITY, opacity); + if (opacity < 1.f) { + ss->setMode(GL_BLEND, osg::StateAttribute::ON); + ss->setRenderingHint(osg::StateSet::TRANSPARENT_BIN); + ss->setAttribute(new osg::BlendFunc(osg::BlendFunc::SRC_ALPHA, + osg::BlendFunc::ONE_MINUS_SRC_ALPHA)); + } +} + +// ── convertStaticMesh ───────────────────────────────────────────────────────── + +osg::ref_ptr SkeletonLoader::convertStaticMesh( + const aiMesh* mesh, const aiScene* scene, + const std::string& baseDir, MorphManager* morphMgr) { + + auto geode = new osg::Geode; + auto geom = new osg::Geometry; + + auto baseVerts = new osg::Vec3Array; + baseVerts->reserve(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + baseVerts->push_back({mesh->mVertices[i].x,mesh->mVertices[i].y,mesh->mVertices[i].z}); + geom->setVertexArray(new osg::Vec3Array(*baseVerts)); + + osg::ref_ptr baseNormals; + if (mesh->HasNormals()) { + baseNormals = new osg::Vec3Array; + baseNormals->reserve(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + baseNormals->push_back({mesh->mNormals[i].x,mesh->mNormals[i].y,mesh->mNormals[i].z}); + geom->setNormalArray(new osg::Vec3Array(*baseNormals), osg::Array::BIND_PER_VERTEX); + } + + if (mesh->HasTextureCoords(0)) { + auto uvs = new osg::Vec2Array; uvs->reserve(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + uvs->push_back({mesh->mTextureCoords[0][i].x, mesh->mTextureCoords[0][i].y}); + geom->setTexCoordArray(0, uvs, osg::Array::BIND_PER_VERTEX); + } + + auto indices = new osg::DrawElementsUInt(osg::PrimitiveSet::TRIANGLES); + for (unsigned f = 0; f < mesh->mNumFaces; ++f) { + if (mesh->mFaces[f].mNumIndices != 3) continue; + indices->push_back(mesh->mFaces[f].mIndices[0]); + indices->push_back(mesh->mFaces[f].mIndices[1]); + indices->push_back(mesh->mFaces[f].mIndices[2]); + } + if (indices->empty()) return geode; + geom->addPrimitiveSet(indices); + applyMaterial(geom->getOrCreateStateSet(), mesh, scene, baseDir); + + if (morphMgr && mesh->mNumAnimMeshes > 0) { + geom->setDataVariance(osg::Object::DYNAMIC); + geom->setUseDisplayList(false); + geom->setUseVertexBufferObjects(true); + if (auto* v = dynamic_cast(geom->getVertexArray())) + v->setDataVariance(osg::Object::DYNAMIC); + if (auto* n = dynamic_cast(geom->getNormalArray())) + n->setDataVariance(osg::Object::DYNAMIC); + + morphMgr->registerMesh(geom, baseVerts, baseNormals); + int reg = 0; + for (unsigned a = 0; a < mesh->mNumAnimMeshes; ++a) { + const aiAnimMesh* am = mesh->mAnimMeshes[a]; + std::string name = am->mName.C_Str(); + if (name.empty() || (name.size()>3&&name.front()=='-'&&name.back()=='-')) continue; + if (am->mNumVertices != mesh->mNumVertices) continue; + auto dv = new osg::Vec3Array; dv->resize(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + (*dv)[i]={am->mVertices[i].x-mesh->mVertices[i].x, + am->mVertices[i].y-mesh->mVertices[i].y, + am->mVertices[i].z-mesh->mVertices[i].z}; + osg::ref_ptr dn; + if (am->mNormals && mesh->HasNormals()) { + dn = new osg::Vec3Array; dn->resize(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + (*dn)[i]={am->mNormals[i].x-mesh->mNormals[i].x, + am->mNormals[i].y-mesh->mNormals[i].y, + am->mNormals[i].z-mesh->mNormals[i].z}; + } + morphMgr->addTarget(geom, name, dv, dn); + ++reg; + } + if (reg) std::cout << "[skel] Static \"" << mesh->mName.C_Str() + << "\": " << reg << " morphs\n"; + } + + geode->addDrawable(geom); + return geode; +} + +// ── convertSkinnedMesh ──────────────────────────────────────────────────────── + +osg::ref_ptr SkeletonLoader::convertSkinnedMesh( + const aiMesh* mesh, const aiScene* scene, + const std::string& baseDir, MorphManager* morphMgr, + const std::unordered_map>& boneMap) { + + auto srcGeom = new osg::Geometry; + + auto verts = new osg::Vec3Array; verts->reserve(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + verts->push_back({mesh->mVertices[i].x,mesh->mVertices[i].y,mesh->mVertices[i].z}); + srcGeom->setVertexArray(verts); + + if (mesh->HasNormals()) { + auto norms = new osg::Vec3Array; norms->reserve(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + norms->push_back({mesh->mNormals[i].x,mesh->mNormals[i].y,mesh->mNormals[i].z}); + srcGeom->setNormalArray(norms, osg::Array::BIND_PER_VERTEX); + } + + if (mesh->HasTextureCoords(0)) { + auto uvs = new osg::Vec2Array; uvs->reserve(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + uvs->push_back({mesh->mTextureCoords[0][i].x, mesh->mTextureCoords[0][i].y}); + srcGeom->setTexCoordArray(0, uvs, osg::Array::BIND_PER_VERTEX); + } + + auto indices = new osg::DrawElementsUInt(osg::PrimitiveSet::TRIANGLES); + for (unsigned f = 0; f < mesh->mNumFaces; ++f) { + if (mesh->mFaces[f].mNumIndices != 3) continue; + indices->push_back(mesh->mFaces[f].mIndices[0]); + indices->push_back(mesh->mFaces[f].mIndices[1]); + indices->push_back(mesh->mFaces[f].mIndices[2]); + } + if (!indices->empty()) srcGeom->addPrimitiveSet(indices); + applyMaterial(srcGeom->getOrCreateStateSet(), mesh, scene, baseDir); + + // Build vertex influence map + auto* vim = new osgAnimation::VertexInfluenceMap; + int mappedBones = 0, skippedBones = 0; + for (unsigned b = 0; b < mesh->mNumBones; ++b) { + const aiBone* bone = mesh->mBones[b]; + std::string bname = bone->mName.C_Str(); + if (!boneMap.count(bname)) { ++skippedBones; continue; } + ++mappedBones; + auto& vi = (*vim)[bname]; + vi.setName(bname); + for (unsigned w = 0; w < bone->mNumWeights; ++w) + vi.push_back(osgAnimation::VertexIndexWeight( + bone->mWeights[w].mVertexId, bone->mWeights[w].mWeight)); + } + + if (skippedBones > 0) + std::cout << "[skel] Influence map \"" << mesh->mName.C_Str() + << "\": " << mappedBones << " mapped, " + << skippedBones << " skipped (not in boneMap)\n"; + + auto rig = new osgAnimation::RigGeometry; + rig->setName(mesh->mName.C_Str()); + rig->setSourceGeometry(srcGeom); + rig->setInfluenceMap(vim); + rig->setDataVariance(osg::Object::DYNAMIC); + rig->setUseDisplayList(false); + rig->setUseVertexBufferObjects(true); + + // Register morphs on the source geometry if present + // RigGeometry deforms the source geometry, so morphs must target it + if (morphMgr && mesh->mNumAnimMeshes > 0) { + auto baseVerts = dynamic_cast(srcGeom->getVertexArray()); + auto baseNorms = dynamic_cast(srcGeom->getNormalArray()); + if (baseVerts) { + srcGeom->setDataVariance(osg::Object::DYNAMIC); + morphMgr->registerMesh(srcGeom, baseVerts, + osg::ref_ptr(baseNorms)); + int reg = 0; + for (unsigned a = 0; a < mesh->mNumAnimMeshes; ++a) { + const aiAnimMesh* am = mesh->mAnimMeshes[a]; + std::string mname = am->mName.C_Str(); + if (mname.empty() || (mname.size()>3 && + mname.front()=='-' && mname.back()=='-')) continue; + if (am->mNumVertices != mesh->mNumVertices) continue; + auto dv = new osg::Vec3Array; dv->resize(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + (*dv)[i] = {am->mVertices[i].x - mesh->mVertices[i].x, + am->mVertices[i].y - mesh->mVertices[i].y, + am->mVertices[i].z - mesh->mVertices[i].z}; + osg::ref_ptr dn; + if (am->mNormals && mesh->HasNormals()) { + dn = new osg::Vec3Array; dn->resize(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + (*dn)[i] = {am->mNormals[i].x - mesh->mNormals[i].x, + am->mNormals[i].y - mesh->mNormals[i].y, + am->mNormals[i].z - mesh->mNormals[i].z}; + } + morphMgr->addTarget(srcGeom, mname, dv, dn); + ++reg; + } + if (reg) std::cout << "[skel] Rig \"" << mesh->mName.C_Str() + << "\" morphs=" << reg << "\n"; + } + } + + std::cout << "[skel] Rig \"" << mesh->mName.C_Str() + << "\" bones=" << mesh->mNumBones + << " verts=" << mesh->mNumVertices << "\n"; + return rig; +} + +// ── buildBoneTree ───────────────────────────────────────────────────────────── + +osg::ref_ptr SkeletonLoader::buildBoneTree( + const aiNode* node, + const std::unordered_map& boneNames, + std::unordered_map>& boneMap, + const osg::Matrix& parentAccum) { + + std::string name = node->mName.C_Str(); + + // Assimp injects "$AssimpFbx$_Translation" / "$AssimpFbx$_PreRotation" + // helper nodes between real bone nodes when reading FBX files. + // We must pass through them transparently to reach the real bone children. + if (!boneNames.count(name)) { + // "$AssimpFbx$_Translation", "$AssimpFbx$_PreRotation" etc. + // These helpers carry transform data that belongs to the next real bone. + // Accumulate them and pass the combined matrix down to the real bone. + // Chain this helper's transform onto the incoming accumulation + osg::Matrix accumulated = parentAccum * aiToOsg(node->mTransformation); + for (unsigned i = 0; i < node->mNumChildren; ++i) { + auto child = buildBoneTree(node->mChildren[i], boneNames, boneMap, + accumulated); + if (child) return child; + } + return {}; + } + + // Bail if already built (prevents double-add via multiple helper paths) + if (boneMap.count(name)) return boneMap[name]; + + // Combine accumulated parent helper transforms with this bone's own transform + osg::Matrix localMat = parentAccum * aiToOsg(node->mTransformation); + auto bone = new osgAnimation::Bone(name); + bone->setMatrix(localMat); + + auto updateCB = new osgAnimation::UpdateBone(name); + osg::Vec3 t = localMat.getTrans(); + osg::Quat q; osg::Vec3 sc; osg::Quat so; + localMat.decompose(t, q, sc, so); + auto& stack = updateCB->getStackedTransforms(); + stack.push_back(new osgAnimation::StackedTranslateElement("translate", t)); + stack.push_back(new osgAnimation::StackedQuaternionElement("quaternion", q)); + bone->setUpdateCallback(updateCB); + + boneMap[name] = bone; // register BEFORE recursing to break cycles + + for (unsigned i = 0; i < node->mNumChildren; ++i) { + auto child = buildBoneTree(node->mChildren[i], boneNames, boneMap, + osg::Matrix::identity()); + if (child && child.get() != bone && child->getNumParents() == 0) + bone->addChild(child); + } + return bone; +} + +// ── findArmatureRoot ────────────────────────────────────────────────────────── + +const aiNode* SkeletonLoader::findArmatureRoot( + const aiNode* node, + const std::unordered_map& boneNames) { + if (boneNames.count(node->mName.C_Str())) return node; + for (unsigned i = 0; i < node->mNumChildren; ++i) + if (auto f = findArmatureRoot(node->mChildren[i], boneNames)) return f; + return nullptr; +} + +// ── load ────────────────────────────────────────────────────────────────────── + +SkeletonLoader::Result SkeletonLoader::load( + const aiScene* scene, const std::string& baseDir, MorphManager* morphMgr) { + + Result result; + + // Collect bone names + inverse bind matrices + std::unordered_map boneNames; + std::unordered_map invBindMatrices; + for (unsigned m = 0; m < scene->mNumMeshes; ++m) { + const aiMesh* mesh = scene->mMeshes[m]; + for (unsigned b = 0; b < mesh->mNumBones; ++b) { + const aiBone* bone = mesh->mBones[b]; + boneNames[bone->mName.C_Str()] = true; + if (!invBindMatrices.count(bone->mName.C_Str())) + invBindMatrices[bone->mName.C_Str()] = aiToOsg(bone->mOffsetMatrix); + } + } + + if (boneNames.empty()) { std::cerr << "[skel] No bones.\n"; return result; } + std::cout << "[skel] Found " << boneNames.size() << " bones.\n"; + + // Build skeleton + auto skeleton = new osgAnimation::Skeleton; + skeleton->setName("Skeleton"); + skeleton->setDefaultUpdateCallback(); + result.skeleton = skeleton; + + const aiNode* armRoot = findArmatureRoot(scene->mRootNode, boneNames); + if (!armRoot) { std::cerr << "[skel] No armature root.\n"; return result; } + + auto rootBone = buildBoneTree(armRoot, boneNames, result.boneMap, + osg::Matrix::identity()); + if (!rootBone) { std::cerr << "[skel] Bone tree failed.\n"; return result; } + skeleton->addChild(rootBone); + std::cout << "[skel] Bone tree: " << result.boneMap.size() << " bones.\n"; + + // Recompute inverse bind matrices from our actual bone world positions. + // We can't use Assimp's mOffsetMatrix directly because we accumulated + // helper node transforms differently, so the bone world matrices we built + // don't match what Assimp computed. Instead, walk the bone tree to get + // each bone's world matrix and invert it. + { + // Build world matrices by walking the bone hierarchy + std::unordered_map worldMatrices; + std::function computeWorld = + [&](osgAnimation::Bone* bone, const osg::Matrix& parentWorld) { + osg::Matrix world = bone->getMatrix() * parentWorld; + worldMatrices[bone->getName()] = world; + for (unsigned i = 0; i < bone->getNumChildren(); ++i) { + auto* child = dynamic_cast(bone->getChild(i)); + if (child) computeWorld(child, world); + } + }; + computeWorld(rootBone.get(), osg::Matrix::identity()); + + for (auto& [name, bone] : result.boneMap) { + auto it = worldMatrices.find(name); + if (it != worldMatrices.end()) { + osg::Matrix invBind = osg::Matrix::inverse(it->second); + bone->setInvBindMatrixInSkeletonSpace(invBind); + } + } + } + + // ── Scene graph ─────────────────────────────────────────────────────────── + // RigGeometry finds its Skeleton by walking UP the parent path. + // So all mesh nodes must be DESCENDANTS of the Skeleton node. + // + // result.root + // skeleton <- ancestor of all RigGeometry + // meshGroup <- mesh nodes go here + // Body xform -> Geode -> RigGeometry + // ... + // rootBone (already added above) + + result.root = new osg::Group; + result.root->setName("SkinnedModel"); + result.root->addChild(skeleton); + + // The skeleton populates its internal bone map automatically during + // the first update traversal via its default update callback. + + auto meshGroup = new osg::Group; + meshGroup->setName("Meshes"); + skeleton->addChild(meshGroup); // meshes are children of skeleton + + std::function walkNodes = + [&](const aiNode* node, osg::Group* parent) { + if (boneNames.count(node->mName.C_Str())) return; + + osg::Matrix nodeMat = aiToOsg(node->mTransformation); + auto xform = new osg::MatrixTransform(nodeMat); + xform->setName(node->mName.C_Str()); + + for (unsigned mi = 0; mi < node->mNumMeshes; ++mi) { + const aiMesh* mesh = scene->mMeshes[node->mMeshes[mi]]; + auto geode = new osg::Geode; + if (mesh->mNumBones > 0) { + auto rig = convertSkinnedMesh(mesh, scene, baseDir, + morphMgr, result.boneMap); + if (rig) geode->addDrawable(rig); + } else { + auto sg = convertStaticMesh(mesh, scene, baseDir, morphMgr); + for (unsigned d = 0; d < sg->getNumDrawables(); ++d) + geode->addDrawable(sg->getDrawable(d)); + } + if (geode->getNumDrawables() > 0) xform->addChild(geode); + } + + parent->addChild(xform); + for (unsigned ci = 0; ci < node->mNumChildren; ++ci) + walkNodes(node->mChildren[ci], xform); + }; + + for (unsigned n = 0; n < scene->mRootNode->mNumChildren; ++n) + walkNodes(scene->mRootNode->mChildren[n], meshGroup); + + result.valid = true; + std::cout << "[skel] Done. " << result.boneMap.size() << " bones.\n"; + return result; +} \ No newline at end of file