From 35ccef8323ab0137a431fc0b2d2c223265aeb71e Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 8 Apr 2026 22:09:17 -0400 Subject: [PATCH 01/11] =?UTF-8?q?Add=20animation=20keyframe=20resampling?= =?UTF-8?q?=20and=20decimation=20=E2=80=94=20Phase=201,=20item=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resample animations to reduce keyframe density (e.g., Mixamo's 200+ keyframes down to 30) or keep every Nth keyframe for lighter files. Core: - AnimationMerger::resampleAnimation(): evaluate animation at N evenly-spaced times via getInterpolatedKeyFrame, rebuild with smooth interpolated keyframes - AnimationMerger::decimateAnimation(): keep every Nth keyframe plus always the last, discard the rest (no interpolation) CLI: - qtmesh anim model.fbx --resample 30 -o optimized.fbx - qtmesh anim model.fbx --decimate-step 5 -o lighter.fbx - qtmesh anim model.fbx --resample 30 --animation "Walk" -o out.fbx MCP: resample_animation tool (target_keyframes or decimate_step) Tests: 8 new tests (resample basic + interpolation, decimate basic + keeps-last, null safety, missing animation, step validation) Part of #256 (Phase 1: Scene Editing Power Tools) Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 3 + src/AnimationMerger.cpp | 177 +++++++++++++++++++++++++++++ src/AnimationMerger.h | 16 +++ src/AnimationMerger_test.cpp | 208 +++++++++++++++++++++++++++++++++++ src/CLIPipeline.cpp | 142 +++++++++++++++++++++++- src/MCPServer.cpp | 126 ++++++++++++++++++++- src/MCPServer.h | 1 + 7 files changed, 667 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 80e18cf40..24f3cd4da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,9 @@ qtmesh anim model.fbx --list # list animations qtmesh anim model.fbx --list --json # list animations (JSON) qtmesh anim model.fbx --rename "Take 001" "Idle" -o out.fbx # rename an animation qtmesh anim base.fbx --merge walk.fbx run.fbx -o merged.fbx +qtmesh anim model.fbx --resample 30 -o optimized.fbx # resample to 30 keyframes +qtmesh anim model.fbx --decimate-step 5 -o lighter.fbx # keep every 5th keyframe +qtmesh anim model.fbx --resample 30 --animation "Walk" -o out.fbx # resample specific animation qtmesh pose model.fbx --animation "Walk" --time 0.5 -o posed.stl # export single frame qtmesh pose model.fbx --animation "Dance" --count 4 -o pose_%02d.stl # export N evenly spaced frames qtmesh validate model.fbx # validate mesh (exit 1 if errors found) diff --git a/src/AnimationMerger.cpp b/src/AnimationMerger.cpp index 27052fdb8..df055549c 100644 --- a/src/AnimationMerger.cpp +++ b/src/AnimationMerger.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -226,6 +227,182 @@ void AnimationMerger::renameAnimation(Ogre::Skeleton* skel, skel->removeAnimation(oldName); } +int AnimationMerger::resampleAnimation(Ogre::Skeleton* skel, + const std::string& animName, + int targetKeyframes) +{ + if (!skel || !skel->hasAnimation(animName) || targetKeyframes < 2) + return 0; + + Ogre::Animation* srcAnim = skel->getAnimation(animName); + float length = srcAnim->getLength(); + + // Count original keyframes across all tracks (use max track keyframe count) + int originalMaxKeyframes = 0; + for (const auto& [handle, track] : srcAnim->_getNodeTrackList()) + { + int numKf = static_cast(track->getNumKeyFrames()); + if (numKf > originalMaxKeyframes) + originalMaxKeyframes = numKf; + } + + // Collect track data: for each track, evaluate interpolated T/R/S at N evenly-spaced times + struct TrackData { + unsigned short handle; + Ogre::Node* associatedNode; + bool useShortestPath; + struct KeyframeData { + float time; + Ogre::Vector3 translate; + Ogre::Quaternion rotation; + Ogre::Vector3 scale; + }; + std::vector keyframes; + }; + std::vector tracks; + + for (const auto& [handle, srcTrack] : srcAnim->_getNodeTrackList()) + { + TrackData td; + td.handle = handle; + td.associatedNode = srcTrack->getAssociatedNode(); + td.useShortestPath = srcTrack->getUseShortestRotationPath(); + + for (int i = 0; i < targetKeyframes; ++i) + { + float t = (targetKeyframes > 1) + ? (static_cast(i) * length / static_cast(targetKeyframes - 1)) + : 0.0f; + + Ogre::TransformKeyFrame interpKf(nullptr, t); + srcTrack->getInterpolatedKeyFrame(t, &interpKf); + + td.keyframes.push_back({ + t, + interpKf.getTranslate(), + interpKf.getRotation(), + interpKf.getScale() + }); + } + tracks.push_back(std::move(td)); + } + + // Save animation properties + float animLength = srcAnim->getLength(); + auto interpMode = srcAnim->getInterpolationMode(); + auto rotInterpMode = srcAnim->getRotationInterpolationMode(); + + // Remove old animation and create new one with same name + skel->removeAnimation(animName); + Ogre::Animation* newAnim = skel->createAnimation(animName, animLength); + newAnim->setInterpolationMode(interpMode); + newAnim->setRotationInterpolationMode(rotInterpMode); + + // Recreate tracks with resampled keyframes + for (const auto& td : tracks) + { + auto* newTrack = newAnim->createNodeTrack(td.handle); + if (td.associatedNode) + newTrack->setAssociatedNode(td.associatedNode); + newTrack->setUseShortestRotationPath(td.useShortestPath); + + for (const auto& kfData : td.keyframes) + { + auto* kf = newTrack->createNodeKeyFrame(kfData.time); + kf->setTranslate(kfData.translate); + kf->setRotation(kfData.rotation); + kf->setScale(kfData.scale); + } + } + + return originalMaxKeyframes - targetKeyframes; +} + +int AnimationMerger::decimateAnimation(Ogre::Skeleton* skel, + const std::string& animName, + int step) +{ + if (!skel || !skel->hasAnimation(animName) || step < 2) + return 0; + + Ogre::Animation* srcAnim = skel->getAnimation(animName); + + // Collect track data: for each track, keep only keyframes at indices 0, step, 2*step, ... and the last + struct TrackData { + unsigned short handle; + Ogre::Node* associatedNode; + bool useShortestPath; + struct KeyframeData { + float time; + Ogre::Vector3 translate; + Ogre::Quaternion rotation; + Ogre::Vector3 scale; + }; + std::vector keyframes; + int originalCount; + }; + std::vector tracks; + + int totalRemoved = 0; + + for (const auto& [handle, srcTrack] : srcAnim->_getNodeTrackList()) + { + TrackData td; + td.handle = handle; + td.associatedNode = srcTrack->getAssociatedNode(); + td.useShortestPath = srcTrack->getUseShortestRotationPath(); + td.originalCount = static_cast(srcTrack->getNumKeyFrames()); + + int numKf = td.originalCount; + for (int i = 0; i < numKf; ++i) + { + bool keep = (i % step == 0) || (i == numKf - 1); + if (keep) + { + const auto* kf = srcTrack->getNodeKeyFrame(static_cast(i)); + td.keyframes.push_back({ + kf->getTime(), + kf->getTranslate(), + kf->getRotation(), + kf->getScale() + }); + } + } + + totalRemoved += (td.originalCount - static_cast(td.keyframes.size())); + tracks.push_back(std::move(td)); + } + + // Save animation properties + float animLength = srcAnim->getLength(); + auto interpMode = srcAnim->getInterpolationMode(); + auto rotInterpMode = srcAnim->getRotationInterpolationMode(); + + // Remove old and create new + skel->removeAnimation(animName); + Ogre::Animation* newAnim = skel->createAnimation(animName, animLength); + newAnim->setInterpolationMode(interpMode); + newAnim->setRotationInterpolationMode(rotInterpMode); + + for (const auto& td : tracks) + { + auto* newTrack = newAnim->createNodeTrack(td.handle); + if (td.associatedNode) + newTrack->setAssociatedNode(td.associatedNode); + newTrack->setUseShortestRotationPath(td.useShortestPath); + + for (const auto& kfData : td.keyframes) + { + auto* kf = newTrack->createNodeKeyFrame(kfData.time); + kf->setTranslate(kfData.translate); + kf->setRotation(kfData.rotation); + kf->setScale(kfData.scale); + } + } + + return totalRemoved; +} + bool AnimationMerger::areSkeletonsCompatible(const Ogre::SkeletonPtr& a, const Ogre::SkeletonPtr& b) { if (!a || !b) diff --git a/src/AnimationMerger.h b/src/AnimationMerger.h index 7b6eb02f2..55da9ef79 100644 --- a/src/AnimationMerger.h +++ b/src/AnimationMerger.h @@ -24,6 +24,22 @@ class AnimationMerger { const std::string& oldName, const std::string& newName); + /// Resample an animation to exactly N evenly-spaced keyframes. + /// Uses interpolation to evaluate T/R/S at each sample point, producing + /// a smooth curve with a uniform keyframe distribution. + /// Returns the number of keyframes removed (negative if keyframes were added). + static int resampleAnimation(Ogre::Skeleton* skel, + const std::string& animName, + int targetKeyframes); + + /// Decimate an animation by keeping every Nth keyframe (plus always the last). + /// This is a lossy reduction that preserves only the original keyframe data + /// at the kept indices — no interpolation is performed. + /// Returns the number of keyframes removed. + static int decimateAnimation(Ogre::Skeleton* skel, + const std::string& animName, + int step); + /// Merge animations from sourceEntities into baseEntity's skeleton. /// Convenience wrapper; forwards an empty skeleton list to the 4-argument overload. static Ogre::Entity* mergeAnimations( diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index fad26bfac..53c64c5f6 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include class AnimationMergerTest : public ::testing::Test { protected: @@ -385,6 +386,213 @@ TEST_F(AnimationMergerTest, MergeAnimationsNumericSuffixPreserved) Ogre::SkeletonManager::getSingleton().remove(skelAnim); } +TEST_F(AnimationMergerTest, ResampleAnimationBasic) +{ + // Create a skeleton with an animation that has 10 keyframes + auto skel = Ogre::SkeletonManager::getSingleton().create( + "resample_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* bone = skel->createBone("root", 0); + auto* bone2 = skel->createBone("spine", 1); + bone->addChild(bone2); + skel->setBindingPose(); + + auto* anim = skel->createAnimation("walk", 1.0f); + + // Track for root bone with 10 keyframes (linear translation) + auto* track = anim->createNodeTrack(0); + track->setAssociatedNode(bone); + for (int i = 0; i < 10; ++i) { + float t = i / 9.0f; + auto* kf = track->createNodeKeyFrame(t); + kf->setTranslate(Ogre::Vector3(t, 0, 0)); + kf->setRotation(Ogre::Quaternion::IDENTITY); + kf->setScale(Ogre::Vector3::UNIT_SCALE); + } + + // Track for spine bone with 10 keyframes + auto* track2 = anim->createNodeTrack(1); + track2->setAssociatedNode(bone2); + for (int i = 0; i < 10; ++i) { + float t = i / 9.0f; + auto* kf = track2->createNodeKeyFrame(t); + kf->setTranslate(Ogre::Vector3(0, t, 0)); + kf->setRotation(Ogre::Quaternion::IDENTITY); + kf->setScale(Ogre::Vector3::UNIT_SCALE); + } + + // Resample to 5 keyframes + int removed = AnimationMerger::resampleAnimation(skel.get(), "walk", 5); + EXPECT_EQ(removed, 5); // 10 original - 5 target = 5 removed + + // Verify the animation still exists and has 5 keyframes per track + EXPECT_TRUE(skel->hasAnimation("walk")); + auto* newAnim = skel->getAnimation("walk"); + EXPECT_FLOAT_EQ(newAnim->getLength(), 1.0f); + + for (const auto& [handle, newTrack] : newAnim->_getNodeTrackList()) { + EXPECT_EQ(newTrack->getNumKeyFrames(), 5u); + // First keyframe at t=0 + EXPECT_FLOAT_EQ(newTrack->getNodeKeyFrame(0)->getTime(), 0.0f); + // Last keyframe at t=1.0 + EXPECT_FLOAT_EQ(newTrack->getNodeKeyFrame(4)->getTime(), 1.0f); + } + + Ogre::SkeletonManager::getSingleton().remove(skel); +} + +TEST_F(AnimationMergerTest, ResampleAnimationPreservesInterpolation) +{ + // The resampled keyframes should match interpolated values from the original + auto skel = Ogre::SkeletonManager::getSingleton().create( + "resample_interp_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* bone = skel->createBone("root", 0); + skel->setBindingPose(); + + auto* anim = skel->createAnimation("move", 1.0f); + auto* track = anim->createNodeTrack(0); + track->setAssociatedNode(bone); + + // Create keyframes with known positions: linear from (0,0,0) to (1,0,0) + for (int i = 0; i < 11; ++i) { + float t = i / 10.0f; + auto* kf = track->createNodeKeyFrame(t); + kf->setTranslate(Ogre::Vector3(t, 0, 0)); + kf->setRotation(Ogre::Quaternion::IDENTITY); + kf->setScale(Ogre::Vector3::UNIT_SCALE); + } + + // Resample to 3 keyframes: t=0, t=0.5, t=1.0 + AnimationMerger::resampleAnimation(skel.get(), "move", 3); + + auto* newAnim = skel->getAnimation("move"); + auto& trackList = newAnim->_getNodeTrackList(); + ASSERT_EQ(trackList.size(), 1u); + + auto* newTrack = trackList.begin()->second; + ASSERT_EQ(newTrack->getNumKeyFrames(), 3u); + + // Check midpoint is interpolated correctly + auto* midKf = newTrack->getNodeKeyFrame(1); + EXPECT_NEAR(midKf->getTranslate().x, 0.5f, 0.01f); + + Ogre::SkeletonManager::getSingleton().remove(skel); +} + +TEST_F(AnimationMergerTest, DecimateAnimationBasic) +{ + auto skel = Ogre::SkeletonManager::getSingleton().create( + "decimate_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* bone = skel->createBone("root", 0); + skel->setBindingPose(); + + auto* anim = skel->createAnimation("run", 1.0f); + auto* track = anim->createNodeTrack(0); + track->setAssociatedNode(bone); + + // Create 10 keyframes + for (int i = 0; i < 10; ++i) { + float t = i / 9.0f; + auto* kf = track->createNodeKeyFrame(t); + kf->setTranslate(Ogre::Vector3(static_cast(i), 0, 0)); + kf->setRotation(Ogre::Quaternion::IDENTITY); + kf->setScale(Ogre::Vector3::UNIT_SCALE); + } + + // Decimate with step=3: keep indices 0, 3, 6, 9 (last) + int removed = AnimationMerger::decimateAnimation(skel.get(), "run", 3); + EXPECT_EQ(removed, 6); // 10 - 4 = 6 + + auto* newAnim = skel->getAnimation("run"); + auto& trackList = newAnim->_getNodeTrackList(); + ASSERT_EQ(trackList.size(), 1u); + + auto* newTrack = trackList.begin()->second; + EXPECT_EQ(newTrack->getNumKeyFrames(), 4u); + + // Verify kept keyframe values (original translate.x was the index) + EXPECT_FLOAT_EQ(newTrack->getNodeKeyFrame(0)->getTranslate().x, 0.0f); // index 0 + EXPECT_FLOAT_EQ(newTrack->getNodeKeyFrame(1)->getTranslate().x, 3.0f); // index 3 + EXPECT_FLOAT_EQ(newTrack->getNodeKeyFrame(2)->getTranslate().x, 6.0f); // index 6 + EXPECT_FLOAT_EQ(newTrack->getNodeKeyFrame(3)->getTranslate().x, 9.0f); // index 9 (last) + + Ogre::SkeletonManager::getSingleton().remove(skel); +} + +TEST_F(AnimationMergerTest, DecimateAlwaysKeepsLast) +{ + auto skel = Ogre::SkeletonManager::getSingleton().create( + "decimate_last_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* bone = skel->createBone("root", 0); + skel->setBindingPose(); + + auto* anim = skel->createAnimation("jump", 1.0f); + auto* track = anim->createNodeTrack(0); + track->setAssociatedNode(bone); + + // 7 keyframes: keep 0, 5, 6 (last) with step=5 + for (int i = 0; i < 7; ++i) { + float t = i / 6.0f; + auto* kf = track->createNodeKeyFrame(t); + kf->setTranslate(Ogre::Vector3(static_cast(i), 0, 0)); + kf->setRotation(Ogre::Quaternion::IDENTITY); + kf->setScale(Ogre::Vector3::UNIT_SCALE); + } + + AnimationMerger::decimateAnimation(skel.get(), "jump", 5); + + auto* newTrack = skel->getAnimation("jump")->_getNodeTrackList().begin()->second; + EXPECT_EQ(newTrack->getNumKeyFrames(), 3u); // 0, 5, 6(last) + + // Verify last keyframe is preserved + auto* lastKf = newTrack->getNodeKeyFrame(newTrack->getNumKeyFrames() - 1); + EXPECT_FLOAT_EQ(lastKf->getTranslate().x, 6.0f); + + Ogre::SkeletonManager::getSingleton().remove(skel); +} + +TEST_F(AnimationMergerTest, ResampleNullSkeleton) +{ + // Edge case: null skeleton should return 0 + EXPECT_EQ(AnimationMerger::resampleAnimation(nullptr, "test", 5), 0); +} + +TEST_F(AnimationMergerTest, DecimateNullSkeleton) +{ + EXPECT_EQ(AnimationMerger::decimateAnimation(nullptr, "test", 3), 0); +} + +TEST_F(AnimationMergerTest, ResampleMissingAnimation) +{ + auto skel = Ogre::SkeletonManager::getSingleton().create( + "resample_missing_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* bone = skel->createBone("root", 0); + skel->setBindingPose(); + + // No animations on this skeleton + EXPECT_EQ(AnimationMerger::resampleAnimation(skel.get(), "nonexistent", 5), 0); + + Ogre::SkeletonManager::getSingleton().remove(skel); +} + +TEST_F(AnimationMergerTest, DecimateStepTooSmall) +{ + auto skel = Ogre::SkeletonManager::getSingleton().create( + "decimate_small_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* bone = skel->createBone("root", 0); + skel->setBindingPose(); + skel->createAnimation("idle", 1.0f); + + // step < 2 should be a no-op + EXPECT_EQ(AnimationMerger::decimateAnimation(skel.get(), "idle", 1), 0); + EXPECT_EQ(AnimationMerger::decimateAnimation(skel.get(), "idle", 0), 0); + + Ogre::SkeletonManager::getSingleton().remove(skel); +} + // Standalone test that doesn't need Ogre initialization TEST(AnimationMergerStandaloneTest, MergeNoSkeletonError) { diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 19fc997b9..078982330 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -96,6 +96,10 @@ void CLIPipeline::printUsage() " anim --merge [f2...] [-o ]\n" " Merge animations from other files into base\n" " (overwrites input if no -o)\n" + " anim --resample N [-o ] [--animation ]\n" + " Resample to exactly N evenly-spaced keyframes\n" + " anim --decimate-step S [-o ] [--animation ]\n" + " Keep every Sth keyframe (plus first and last)\n" " validate [--json] Validate mesh geometry (exit 1 if errors found)\n" " lod --count N [--reductions r,...] [-o output]\n" " Generate N LOD levels; exports _lod1. etc.\n" @@ -788,11 +792,17 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) // Parse: anim --list [--json] // or: anim --rename [-o ] // or: anim --merge [f2...] [-o ] - QString filePath, oldName, newName, outputPath; + // or: anim --resample N [-o ] [--animation ] + // or: anim --decimate-step S [-o ] [--animation ] + QString filePath, oldName, newName, outputPath, animationFilter; bool listMode = false; bool renameMode = false; bool mergeMode = false; + bool resampleMode = false; + bool decimateMode = false; bool jsonOutput = false; + int resampleCount = 0; + int decimateStep = 0; QStringList mergeFiles; // Collect positional args (excluding flags) @@ -817,6 +827,20 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) } continue; } + if (arg == "--resample" && i + 1 < argc) { + resampleMode = true; + resampleCount = QString(argv[++i]).toInt(); + continue; + } + if (arg == "--decimate-step" && i + 1 < argc) { + decimateMode = true; + decimateStep = QString(argv[++i]).toInt(); + continue; + } + if (arg == "--animation" && i + 1 < argc) { + animationFilter = QString(argv[++i]); + continue; + } if ((arg == "-o" || arg == "--output") && i + 1 < argc) { outputPath = QString(argv[++i]); continue; @@ -832,15 +856,17 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) filePath = positional[0]; - if (!listMode && !renameMode && !mergeMode) { - err() << "Error: Specify --list, --rename , or --merge ." << Qt::endl; + if (!listMode && !renameMode && !mergeMode && !resampleMode && !decimateMode) { + err() << "Error: Specify --list, --rename, --merge, --resample, or --decimate-step." << Qt::endl; err() << "Usage: qtmesh anim --list [--json]" << Qt::endl; err() << " qtmesh anim --rename [-o ]" << Qt::endl; err() << " qtmesh anim --merge [f2...] [-o ]" << Qt::endl; + err() << " qtmesh anim --resample N [-o ] [--animation ]" << Qt::endl; + err() << " qtmesh anim --decimate-step S [-o ] [--animation ]" << Qt::endl; return 2; } - if ((renameMode || mergeMode) && outputPath.isEmpty()) { + if ((renameMode || mergeMode || resampleMode || decimateMode) && outputPath.isEmpty()) { outputPath = filePath; // overwrite in place } @@ -852,7 +878,7 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) if (!initOgreHeadless()) return 1; - QString animOp = listMode ? "list" : (renameMode ? "rename" : "merge"); + QString animOp = listMode ? "list" : (renameMode ? "rename" : (resampleMode ? "resample" : (decimateMode ? "decimate" : "merge"))); SentryReporter::addBreadcrumb("cli.anim", QString("Anim %1 .%2%3") .arg(animOp, fi.suffix(), mergeMode ? QString(" files=%1").arg(mergeFiles.size()) : "")); @@ -954,6 +980,112 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) return 0; } + // Resample mode + if (resampleMode) { + if (resampleCount < 2) { + err() << "Error: --resample requires N >= 2." << Qt::endl; + return 2; + } + + SentryReporter::addBreadcrumb("cli.anim", QString("Resample N=%1 anim=%2") + .arg(resampleCount).arg(animationFilter.isEmpty() ? "(all)" : animationFilter)); + + int totalRemoved = 0; + int animsProcessed = 0; + unsigned short numAnims = skel->getNumAnimations(); + + // Collect animation names first (modifying skeleton invalidates iteration) + std::vector animNames; + for (unsigned short i = 0; i < numAnims; ++i) + animNames.push_back(skel->getAnimation(i)->getName()); + + for (const auto& name : animNames) { + if (!animationFilter.isEmpty() && animationFilter.toStdString() != name) + continue; + int removed = AnimationMerger::resampleAnimation(skel.get(), name, resampleCount); + totalRemoved += removed; + ++animsProcessed; + } + + if (animsProcessed == 0) { + err() << "Error: No matching animation found." << Qt::endl; + if (!animationFilter.isEmpty()) { + err() << "Available animations:" << Qt::endl; + for (const auto& name : animNames) + err() << " " << QString::fromStdString(name) << Qt::endl; + } + return 1; + } + + entity->refreshAvailableAnimationState(); + + auto* node = entity->getParentSceneNode(); + QFileInfo outFi(outputPath); + int result = MeshImporterExporter::exporter(node, outFi.absoluteFilePath(), formatForExtension(outputPath)); + if (result != 0) { + SentryReporter::captureMessage(QString("CLI anim: resample export failed (.%1)").arg(outFi.suffix()), "error"); + err() << "Error: Export failed." << Qt::endl; + return 1; + } + + cliWrite(QString("Resampled %1 animation(s) to %2 keyframes (removed %3 keyframes)\nOutput: %4\n") + .arg(animsProcessed).arg(resampleCount).arg(totalRemoved).arg(outFi.fileName())); + return 0; + } + + // Decimate mode + if (decimateMode) { + if (decimateStep < 2) { + err() << "Error: --decimate-step requires S >= 2." << Qt::endl; + return 2; + } + + SentryReporter::addBreadcrumb("cli.anim", QString("Decimate step=%1 anim=%2") + .arg(decimateStep).arg(animationFilter.isEmpty() ? "(all)" : animationFilter)); + + int totalRemoved = 0; + int animsProcessed = 0; + unsigned short numAnims = skel->getNumAnimations(); + + // Collect animation names first (modifying skeleton invalidates iteration) + std::vector animNames; + for (unsigned short i = 0; i < numAnims; ++i) + animNames.push_back(skel->getAnimation(i)->getName()); + + for (const auto& name : animNames) { + if (!animationFilter.isEmpty() && animationFilter.toStdString() != name) + continue; + int removed = AnimationMerger::decimateAnimation(skel.get(), name, decimateStep); + totalRemoved += removed; + ++animsProcessed; + } + + if (animsProcessed == 0) { + err() << "Error: No matching animation found." << Qt::endl; + if (!animationFilter.isEmpty()) { + err() << "Available animations:" << Qt::endl; + for (const auto& name : animNames) + err() << " " << QString::fromStdString(name) << Qt::endl; + } + return 1; + } + + entity->refreshAvailableAnimationState(); + + auto* node = entity->getParentSceneNode(); + QFileInfo outFi(outputPath); + int result = MeshImporterExporter::exporter(node, outFi.absoluteFilePath(), formatForExtension(outputPath)); + if (result != 0) { + SentryReporter::captureMessage(QString("CLI anim: decimate export failed (.%1)").arg(outFi.suffix()), "error"); + err() << "Error: Export failed." << Qt::endl; + return 1; + } + + cliWrite(QString("Decimated %1 animation(s) with step %2 (removed %3 keyframes)\nOutput: %4\n") + .arg(animsProcessed).arg(decimateStep).arg(totalRemoved).arg(outFi.fileName())); + return 0; + } + // Rename mode if (!skel->hasAnimation(oldName.toStdString())) { err() << "Error: Animation '" << oldName << "' not found." << Qt::endl; diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index e9e6c9328..e375457fe 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -390,7 +390,7 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) // Start a performance transaction for heavy tools static const QStringList heavyTools = { "load_mesh", "export_mesh", "export_pose", "take_screenshot", "create_primitive", "create_material", - "merge_animations", "save_scene", "open_scene" + "merge_animations", "resample_animation", "save_scene", "open_scene" }; uintptr_t txn = 0; if (heavyTools.contains(name)) { @@ -462,6 +462,8 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) toolResult = toolToggleMeshInfo(args); } else if (name == "merge_animations") { toolResult = toolMergeAnimations(args); + } else if (name == "resample_animation") { + toolResult = toolResampleAnimation(args); } else if (name == "save_scene") { toolResult = toolSaveScene(args); } else if (name == "open_scene") { @@ -2119,6 +2121,112 @@ QJsonObject MCPServer::toolMergeAnimations(const QJsonObject &args) } } +QJsonObject MCPServer::toolResampleAnimation(const QJsonObject &args) +{ + try { + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) + return makeErrorResult("Error: Manager not available"); + + // Resolve entity + QString entityName = args["entity_name"].toString(); + Ogre::Entity* entity = nullptr; + + QList allEntities = mgr->getEntities(); + if (!entityName.isEmpty()) { + for (auto* ent : allEntities) { + if (ent && QString::fromStdString(ent->getName()) == entityName) { + entity = ent; + break; + } + } + if (!entity) + return makeErrorResult(QString("Error: Entity '%1' not found").arg(entityName)); + } else { + // Use first entity with a skeleton + for (auto* ent : allEntities) { + if (ent && ent->hasSkeleton()) { + entity = ent; + break; + } + } + } + + if (!entity || !entity->hasSkeleton()) + return makeErrorResult("Error: No entity with skeleton found"); + + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + if (!skel) + return makeErrorResult("Error: No skeleton found"); + + QString animName = args["animation_name"].toString(); + int targetKeyframes = args["target_keyframes"].toInt(0); + int decimateStep = args["decimate_step"].toInt(0); + + if (targetKeyframes <= 0 && decimateStep <= 0) + return makeErrorResult("Error: Specify 'target_keyframes' (>= 2) for resampling or 'decimate_step' (>= 2) for decimation"); + + bool isResample = targetKeyframes >= 2; + + if (isResample && targetKeyframes < 2) + return makeErrorResult("Error: target_keyframes must be >= 2"); + if (!isResample && decimateStep < 2) + return makeErrorResult("Error: decimate_step must be >= 2"); + + // Collect animation names to process + std::vector animNames; + if (!animName.isEmpty()) { + if (!skel->hasAnimation(animName.toStdString())) + return makeErrorResult(QString("Error: Animation '%1' not found").arg(animName)); + animNames.push_back(animName.toStdString()); + } else { + for (unsigned short i = 0; i < skel->getNumAnimations(); ++i) + animNames.push_back(skel->getAnimation(i)->getName()); + } + + int totalRemoved = 0; + int animsProcessed = 0; + for (const auto& name : animNames) { + int removed = isResample + ? AnimationMerger::resampleAnimation(skel.get(), name, targetKeyframes) + : AnimationMerger::decimateAnimation(skel.get(), name, decimateStep); + totalRemoved += removed; + ++animsProcessed; + } + + entity->refreshAvailableAnimationState(); + + QString op = isResample ? "Resampled" : "Decimated"; + QString detail = isResample + ? QString("to %1 keyframes").arg(targetKeyframes) + : QString("with step %1").arg(decimateStep); + QString result = QString("%1 %2 animation(s) %3 (removed %4 keyframes)") + .arg(op).arg(animsProcessed).arg(detail).arg(totalRemoved); + + // List resulting animations + result += "\n\nAnimations:"; + for (unsigned short i = 0; i < skel->getNumAnimations(); ++i) { + auto* anim = skel->getAnimation(i); + int maxKf = 0; + for (const auto& [handle, track] : anim->_getNodeTrackList()) { + int kfCount = static_cast(track->getNumKeyFrames()); + if (kfCount > maxKf) maxKf = kfCount; + } + result += QString("\n - %1 (%2s, %3 keyframes)") + .arg(QString::fromStdString(anim->getName())) + .arg(anim->getLength(), 0, 'f', 2) + .arg(maxKf); + } + + return makeSuccessResult(result); + + } catch (Ogre::Exception& e) { + return makeErrorResult(QString("Error: Ogre exception — %1").arg(e.getFullDescription().c_str())); + } catch (std::exception& e) { + return makeErrorResult(QString("Error: %1").arg(e.what())); + } +} + QJsonObject MCPServer::toolSaveScene(const QJsonObject &args) { try { @@ -3205,6 +3313,22 @@ QJsonArray MCPServer::buildToolsList() ); } + // resample_animation + { + QJsonObject props; + props["entity_name"] = QJsonObject{{"type", "string"}, {"description", "Name of the entity. If omitted, uses the first entity with a skeleton."}}; + props["animation_name"] = QJsonObject{{"type", "string"}, {"description", "Name of the animation to resample. If omitted, all animations are processed."}}; + props["target_keyframes"] = QJsonObject{{"type", "integer"}, {"description", "Resample to exactly N evenly-spaced keyframes (N >= 2). Mutually exclusive with decimate_step."}}; + props["decimate_step"] = QJsonObject{{"type", "integer"}, {"description", "Keep every Nth keyframe plus the last (N >= 2). Mutually exclusive with target_keyframes."}}; + appendTool( + "resample_animation", + "Resample or decimate animation keyframes. Use target_keyframes for uniform resampling (interpolated) " + "or decimate_step to keep every Nth original keyframe. Reduces animation data size while preserving " + "bone hierarchy. Use list_skeletal_animations to see the result.", + props + ); + } + // save_scene { QJsonObject props; diff --git a/src/MCPServer.h b/src/MCPServer.h index f626f6d77..ef7cf6679 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -150,6 +150,7 @@ private slots: QJsonObject toolToggleNormals(const QJsonObject &args); QJsonObject toolToggleMeshInfo(const QJsonObject &args); QJsonObject toolMergeAnimations(const QJsonObject &args); + QJsonObject toolResampleAnimation(const QJsonObject &args); QJsonObject toolSaveScene(const QJsonObject &args); QJsonObject toolOpenScene(const QJsonObject &args); QJsonObject toolValidateMesh(const QJsonObject &args); From 51a74a02c6e9aef599060dfa9432d746039c2769 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 8 Apr 2026 23:07:59 -0400 Subject: [PATCH 02/11] =?UTF-8?q?Add=20Node=20Grouping=20(Ctrl+G=20/=20Ctr?= =?UTF-8?q?l+Shift+G)=20=E2=80=94=20Phase=201,=20item=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group selected nodes under a shared parent for organized scene hierarchy. Ogre SceneNode parenting propagates transforms automatically. Core: - Manager::groupNodes(): create empty "Group" node at centroid of selection, reparent children preserving world transforms - Manager::ungroupNode(): reparent children to root, destroy empty group - Manager::isGroupNode(): detect group nodes (no entities, has children) - SceneTreeModel: shows "Group" type label, recursive child traversal - GroupCommand/UngroupCommand: full undo/redo support - Ctrl+G (group) and Ctrl+Shift+G (ungroup) in Edit menu - MCP tools: group_nodes, ungroup_node - 8 unit tests Part of #256 (Phase 1: Scene Editing Power Tools) Co-Authored-By: Claude Sonnet 4.6 --- src/MCPServer.cpp | 111 ++++++++++++ src/MCPServer.h | 2 + src/Manager.cpp | 188 ++++++++++++++++--- src/Manager.h | 4 + src/NodeGrouping_test.cpp | 282 +++++++++++++++++++++++++++++ src/SceneTreeModel.cpp | 8 +- src/commands/TransformCommands.cpp | 201 ++++++++++++++++++++ src/commands/TransformCommands.h | 50 +++++ src/mainwindow.cpp | 31 ++++ src/mainwindow.h | 2 + ui_files/mainwindow.ui | 19 ++ 11 files changed, 873 insertions(+), 25 deletions(-) create mode 100644 src/NodeGrouping_test.cpp diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index e375457fe..ede519386 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -498,6 +498,10 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) toolResult = toolGetSnapSettings(args); } else if (name == "export_pose") { toolResult = toolExportPose(args); + } else if (name == "group_nodes") { + toolResult = toolGroupNodes(args); + } else if (name == "ungroup_node") { + toolResult = toolUngroupNode(args); } else { if (txn) SentryReporter::finishTransaction(txn); return makeErrorResult(QString("Unknown tool: %1").arg(name)); @@ -2827,6 +2831,88 @@ QJsonObject MCPServer::toolExportPose(const QJsonObject &args) } } +QJsonObject MCPServer::toolGroupNodes(const QJsonObject &args) +{ + try { + auto* mgr = Manager::getSingletonPtr(); + if (!mgr) return makeErrorResult("Error: Manager not available"); + + QList nodes; + + if (args.contains("names")) { + QJsonArray names = args["names"].toArray(); + for (const auto& nameVal : names) { + QString name = nameVal.toString(); + Ogre::SceneNode* node = mgr->getSceneNode(name); + if (!node) + return makeErrorResult(QString("Error: Scene node '%1' not found").arg(name)); + nodes.append(node); + } + } else { + // Use current selection + SelectionSet* sel = SelectionSet::getSingleton(); + if (!sel || sel->getNodesCount() < 2) + return makeErrorResult("Error: At least 2 nodes must be selected or specified"); + nodes = sel->getNodesSelectionList(); + } + + if (nodes.size() < 2) + return makeErrorResult("Error: At least 2 nodes are required to create a group"); + + Ogre::SceneNode* groupNode = mgr->groupNodes(nodes); + if (!groupNode) + return makeErrorResult("Error: Failed to create group node"); + + UndoManager::getSingleton()->push(new GroupCommand(nodes)); + + return makeSuccessResult(QString("Created group '%1' with %2 child nodes") + .arg(QString::fromStdString(groupNode->getName())) + .arg(nodes.size())); + + } catch (std::exception& e) { + return makeErrorResult(QString("Error grouping nodes: %1").arg(e.what())); + } +} + +QJsonObject MCPServer::toolUngroupNode(const QJsonObject &args) +{ + try { + auto* mgr = Manager::getSingletonPtr(); + if (!mgr) return makeErrorResult("Error: Manager not available"); + + Ogre::SceneNode* groupNode = nullptr; + + if (args.contains("name")) { + QString name = args["name"].toString(); + groupNode = mgr->getSceneNode(name); + if (!groupNode) + return makeErrorResult(QString("Error: Scene node '%1' not found").arg(name)); + } else { + // Use current selection + SelectionSet* sel = SelectionSet::getSingleton(); + if (!sel || sel->getNodesCount() != 1) + return makeErrorResult("Error: Select exactly one group node, or specify a name"); + groupNode = sel->getSceneNode(0); + } + + if (!mgr->isGroupNode(groupNode)) + return makeErrorResult(QString("Error: '%1' is not a group node (must have children and no attached meshes)") + .arg(QString::fromStdString(groupNode->getName()))); + + int childCount = static_cast(groupNode->numChildren()); + QString groupName = QString::fromStdString(groupNode->getName()); + + UndoManager::getSingleton()->push(new UngroupCommand(groupNode)); + mgr->ungroupNode(groupNode); + + return makeSuccessResult(QString("Ungrouped '%1': %2 children moved to parent") + .arg(groupName).arg(childCount)); + + } catch (std::exception& e) { + return makeErrorResult(QString("Error ungrouping node: %1").arg(e.what())); + } +} + QJsonArray MCPServer::buildToolsList() { QJsonArray tools; @@ -3527,6 +3613,31 @@ QJsonArray MCPServer::buildToolsList() ); } + // group_nodes + { + QJsonObject props; + props["names"] = QJsonObject{{"type", "array"}, {"description", "Array of scene node names to group. If omitted, groups the current selection."}, + {"items", QJsonObject{{"type", "string"}}}}; + appendTool( + "group_nodes", + "Group scene nodes under a new parent node. The group node is positioned at the centroid of the selected nodes. " + "Transforming the group transforms all children. Requires at least 2 nodes.", + props + ); + } + + // ungroup_node + { + QJsonObject props; + props["name"] = QJsonObject{{"type", "string"}, {"description", "Name of the group node to ungroup. If omitted, ungroups the current selection."}}; + appendTool( + "ungroup_node", + "Ungroup a group node: move its children to the group's parent and delete the empty group. " + "Only works on group nodes (scene nodes with children and no attached meshes).", + props + ); + } + return tools; } diff --git a/src/MCPServer.h b/src/MCPServer.h index ef7cf6679..ac41b76df 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -168,6 +168,8 @@ private slots: QJsonObject toolSetSnapSettings(const QJsonObject &args); QJsonObject toolGetSnapSettings(const QJsonObject &args); QJsonObject toolExportPose(const QJsonObject &args); + QJsonObject toolGroupNodes(const QJsonObject &args); + QJsonObject toolUngroupNode(const QJsonObject &args); // Animation struct NodeAnimation { diff --git a/src/Manager.cpp b/src/Manager.cpp index 70f148f31..3d69e3d94 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -389,6 +389,134 @@ Ogre::SceneNode* Manager::duplicateSceneNode(Ogre::SceneNode* source) return newNode; } +Ogre::SceneNode* Manager::groupNodes(const QList& nodes) +{ + if (nodes.isEmpty() || !mSceneMgr) return nullptr; + + SentryReporter::addBreadcrumb("ui.action", + QString("Group %1 nodes").arg(nodes.size())); + + // Compute centroid of selected nodes (world positions) + Ogre::Vector3 centroid = Ogre::Vector3::ZERO; + for (Ogre::SceneNode* node : nodes) + centroid += node->_getDerivedPosition(); + centroid /= static_cast(nodes.size()); + + // Find a common parent — use the parent of the first node + Ogre::SceneNode* commonParent = static_cast(nodes.first()->getParent()); + if (!commonParent) + commonParent = mSceneMgr->getRootSceneNode(); + + // Create the group node under the common parent + QString baseName = "Group"; + unsigned int number = 0; + while (mSceneMgr->hasSceneNode(QString(baseName + (number ? QString::number(number) : "")).toStdString())) + ++number; + + QString groupName = baseName + (number ? QString::number(number) : ""); + Ogre::SceneNode* groupNode = commonParent->createChildSceneNode(groupName.toStdString()); + + // Position the group at the centroid (in parent space) + Ogre::Vector3 groupWorldPos = centroid; + // Convert world position to local position relative to commonParent + Ogre::Vector3 groupLocalPos = groupWorldPos; + if (commonParent != mSceneMgr->getRootSceneNode()) { + Ogre::Vector3 parentWorldPos = commonParent->_getDerivedPosition(); + Ogre::Quaternion parentWorldOrient = commonParent->_getDerivedOrientation(); + Ogre::Vector3 parentWorldScale = commonParent->_getDerivedScale(); + groupLocalPos = parentWorldOrient.Inverse() * ((groupWorldPos - parentWorldPos) / parentWorldScale); + } + groupNode->setPosition(groupLocalPos); + + // Reparent each selected node under the group, preserving world transform + for (Ogre::SceneNode* node : nodes) { + // Save the world transform + Ogre::Vector3 worldPos = node->_getDerivedPosition(); + Ogre::Quaternion worldOrient = node->_getDerivedOrientation(); + Ogre::Vector3 worldScale = node->_getDerivedScale(); + + // Remove from old parent + Ogre::SceneNode* oldParent = static_cast(node->getParent()); + if (oldParent) + oldParent->removeChild(node); + + // Add to group + groupNode->addChild(node); + + // Restore world transform by computing new local transform + Ogre::Quaternion groupWorldOrient = groupNode->_getDerivedOrientation(); + Ogre::Vector3 groupWorldScale = groupNode->_getDerivedScale(); + Ogre::Vector3 groupDerivedPos = groupNode->_getDerivedPosition(); + + node->setOrientation(groupWorldOrient.Inverse() * worldOrient); + node->setScale(worldScale / groupWorldScale); + node->setPosition(groupWorldOrient.Inverse() * + ((worldPos - groupDerivedPos) / groupWorldScale)); + } + + emit sceneNodeCreated(groupNode); + SelectionSet::getSingleton()->selectOne(groupNode); + return groupNode; +} + +void Manager::ungroupNode(Ogre::SceneNode* groupNode) +{ + if (!groupNode || !mSceneMgr) return; + + SentryReporter::addBreadcrumb("ui.action", "Ungroup node"); + + Ogre::SceneNode* parentNode = static_cast(groupNode->getParent()); + if (!parentNode) + parentNode = mSceneMgr->getRootSceneNode(); + + // Collect children (cannot modify during iteration) + QList children; + for (auto& child : groupNode->getChildren()) { + Ogre::SceneNode* childNode = static_cast(child); + if (!isForbiddenNodeName(QString::fromStdString(childNode->getName()))) + children.append(childNode); + } + + // Reparent children to the group's parent, preserving world transforms + for (Ogre::SceneNode* child : children) { + Ogre::Vector3 worldPos = child->_getDerivedPosition(); + Ogre::Quaternion worldOrient = child->_getDerivedOrientation(); + Ogre::Vector3 worldScale = child->_getDerivedScale(); + + groupNode->removeChild(child); + parentNode->addChild(child); + + // Compute local transform relative to the new parent + Ogre::Quaternion parentWorldOrient = parentNode->_getDerivedOrientation(); + Ogre::Vector3 parentWorldScale = parentNode->_getDerivedScale(); + Ogre::Vector3 parentDerivedPos = parentNode->_getDerivedPosition(); + + child->setOrientation(parentWorldOrient.Inverse() * worldOrient); + child->setScale(worldScale / parentWorldScale); + child->setPosition(parentWorldOrient.Inverse() * + ((worldPos - parentDerivedPos) / parentWorldScale)); + } + + // Destroy the now-empty group node + emit sceneNodeDestroyed(groupNode); + destroyAllAttachedMovableObjects(groupNode); + mSceneMgr->destroySceneNode(groupNode); + + // Select the ungrouped children + if (!children.isEmpty()) { + SelectionSet::getSingleton()->selectOne(children.first()); + for (int i = 1; i < children.size(); ++i) + SelectionSet::getSingleton()->append(children[i]); + } +} + +bool Manager::isGroupNode(Ogre::SceneNode* node) const +{ + if (!node) return false; + // A group node has no attached objects (entities) and has children + return node->numAttachedObjects() == 0 && node->numChildren() > 0; +} + void Manager::destroySceneNode(const QString & name) { SentryReporter::addBreadcrumb("scene", "Destroy scene node"); @@ -524,49 +652,61 @@ Ogre::SceneNode *Manager::getSceneNode(const QString &_name) bool Manager::hasSceneNode(const QString &_name) { - auto children = getSceneMgr()->getRootSceneNode()->getChildren(); - for(auto node : children) - { - if(_name==node->getName().data()) - return true; + try { + return mSceneMgr->hasSceneNode(_name.toStdString()); + } catch (...) { + return false; } - return false; } -QList &Manager::getSceneNodes() +static void collectSceneNodesRecursive(Ogre::SceneNode* parent, QList& out) { - mSceneNodesList.clear(); - - auto nodes = getSceneMgr()->getRootSceneNode()->getChildren(); - for(Ogre::Node* node : nodes) + for (Ogre::Node* child : parent->getChildren()) { - Ogre::SceneNode* pSN = static_cast(node); + Ogre::SceneNode* pSN = static_cast(child); QString name = pSN->getName().data(); - if(!(isForbiddenNodeName(name))) - mSceneNodesList.append(pSN); + if (!Manager::getSingletonPtr()->isForbiddenNodeName(name)) + { + out.append(pSN); + // Recurse into children (groups) + if (pSN->numChildren() > 0) + collectSceneNodesRecursive(pSN, out); + } } +} +QList &Manager::getSceneNodes() +{ + mSceneNodesList.clear(); + collectSceneNodesRecursive(getSceneMgr()->getRootSceneNode(), mSceneNodesList); return mSceneNodesList; } -QList &Manager::getEntities() +static void collectEntitiesRecursive(Ogre::SceneNode* parent, QList& out) { - mEntitiesList.clear(); - - auto nodes = getSceneMgr()->getRootSceneNode()->getChildren(); - for(Ogre::Node* node : nodes) + for (Ogre::Node* child : parent->getChildren()) { - Ogre::SceneNode* pSN = static_cast(node); + Ogre::SceneNode* pSN = static_cast(child); QString name = pSN->getName().data(); - if(!(isForbiddenNodeName(name))) + if (!Manager::getSingletonPtr()->isForbiddenNodeName(name)) { - Ogre::SceneNode *parentNode = pSN; - for(int entIndex = 0; entIndex < parentNode->numAttachedObjects();entIndex++) + for (int entIndex = 0; entIndex < static_cast(pSN->numAttachedObjects()); entIndex++) { - mEntitiesList.append(static_cast(parentNode->getAttachedObject(entIndex))); + Ogre::MovableObject* obj = pSN->getAttachedObject(entIndex); + if (obj->getMovableType() == "Entity") + out.append(static_cast(obj)); } + // Recurse into children (groups) + if (pSN->numChildren() > 0) + collectEntitiesRecursive(pSN, out); } } +} + +QList &Manager::getEntities() +{ + mEntitiesList.clear(); + collectEntitiesRecursive(getSceneMgr()->getRootSceneNode(), mEntitiesList); return mEntitiesList; } diff --git a/src/Manager.h b/src/Manager.h index 714f3c4db..32f7fdfb1 100755 --- a/src/Manager.h +++ b/src/Manager.h @@ -82,6 +82,10 @@ class Manager : public QObject Ogre::SceneNode* duplicateSceneNode(Ogre::SceneNode* source); + Ogre::SceneNode* groupNodes(const QList& nodes); + void ungroupNode(Ogre::SceneNode* groupNode); + bool isGroupNode(Ogre::SceneNode* node) const; + bool hasSceneNode(const QString &_name); QList& getSceneNodes(); diff --git a/src/NodeGrouping_test.cpp b/src/NodeGrouping_test.cpp new file mode 100644 index 000000000..666a421cd --- /dev/null +++ b/src/NodeGrouping_test.cpp @@ -0,0 +1,282 @@ +#include +#include "Manager.h" +#include "SelectionSet.h" +#include "UndoManager.h" +#include "commands/TransformCommands.h" +#include "TestHelpers.h" +#include +#include +#include +#include + +class NodeGroupingTest : public ::testing::Test { +protected: + void SetUp() override { + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + + if (!tryInitOgre()) { + GTEST_SKIP() << "Skipping: Ogre initialization failed"; + } + createStandardOgreMaterials(); + } + + void TearDown() override { + if (app) + app->processEvents(); + } + + QApplication* app = nullptr; +}; + +TEST_F(NodeGroupingTest, GroupNodes_Basic) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "No GL context for mesh operations"; + + auto* mgr = Manager::getSingleton(); + + // Create two scene nodes with entities + auto mesh1 = createInMemoryTriangleMesh("grouptest_mesh1"); + auto* node1 = mgr->addSceneNode("GroupTestNode1"); + mgr->createEntity(node1, mesh1); + node1->setPosition(10, 0, 0); + + auto mesh2 = createInMemoryTriangleMesh("grouptest_mesh2"); + auto* node2 = mgr->addSceneNode("GroupTestNode2"); + mgr->createEntity(node2, mesh2); + node2->setPosition(-10, 0, 0); + + QList nodes = {node1, node2}; + Ogre::SceneNode* groupNode = mgr->groupNodes(nodes); + + ASSERT_NE(groupNode, nullptr); + + // Group node should be parent of both nodes + EXPECT_EQ(node1->getParent(), groupNode); + EXPECT_EQ(node2->getParent(), groupNode); + + // Group node should have 2 children + EXPECT_EQ(groupNode->numChildren(), 2u); + + // Group node should have no attached objects + EXPECT_EQ(groupNode->numAttachedObjects(), 0u); + + // isGroupNode should return true + EXPECT_TRUE(mgr->isGroupNode(groupNode)); + + // World positions should be preserved (approximately) + Ogre::Vector3 world1 = node1->_getDerivedPosition(); + Ogre::Vector3 world2 = node2->_getDerivedPosition(); + EXPECT_NEAR(world1.x, 10.0, 0.001); + EXPECT_NEAR(world2.x, -10.0, 0.001); +} + +TEST_F(NodeGroupingTest, UngroupNode) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "No GL context for mesh operations"; + + auto* mgr = Manager::getSingleton(); + + auto mesh1 = createInMemoryTriangleMesh("ungroup_mesh1"); + auto* node1 = mgr->addSceneNode("UngroupNode1"); + mgr->createEntity(node1, mesh1); + node1->setPosition(5, 5, 0); + + auto mesh2 = createInMemoryTriangleMesh("ungroup_mesh2"); + auto* node2 = mgr->addSceneNode("UngroupNode2"); + mgr->createEntity(node2, mesh2); + node2->setPosition(-5, -5, 0); + + // Group them + QList nodes = {node1, node2}; + Ogre::SceneNode* groupNode = mgr->groupNodes(nodes); + ASSERT_NE(groupNode, nullptr); + + // Now ungroup + mgr->ungroupNode(groupNode); + + // Nodes should be back under root scene node + auto* rootNode = mgr->getSceneMgr()->getRootSceneNode(); + EXPECT_EQ(node1->getParent(), rootNode); + EXPECT_EQ(node2->getParent(), rootNode); + + // World positions should be preserved + Ogre::Vector3 world1 = node1->_getDerivedPosition(); + Ogre::Vector3 world2 = node2->_getDerivedPosition(); + EXPECT_NEAR(world1.x, 5.0, 0.001); + EXPECT_NEAR(world1.y, 5.0, 0.001); + EXPECT_NEAR(world2.x, -5.0, 0.001); + EXPECT_NEAR(world2.y, -5.0, 0.001); +} + +TEST_F(NodeGroupingTest, IsGroupNode) +{ + auto* mgr = Manager::getSingleton(); + + // A node with no children and no objects is not a group + auto* emptyNode = mgr->addSceneNode("NotAGroup"); + EXPECT_FALSE(mgr->isGroupNode(emptyNode)); + + // nullptr is not a group + EXPECT_FALSE(mgr->isGroupNode(nullptr)); +} + +TEST_F(NodeGroupingTest, GroupTransformPropagation) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "No GL context for mesh operations"; + + auto* mgr = Manager::getSingleton(); + + auto mesh = createInMemoryTriangleMesh("propagation_mesh"); + auto* node1 = mgr->addSceneNode("PropNode1"); + mgr->createEntity(node1, mesh); + node1->setPosition(0, 0, 0); + + auto mesh2 = createInMemoryTriangleMesh("propagation_mesh2"); + auto* node2 = mgr->addSceneNode("PropNode2"); + mgr->createEntity(node2, mesh2); + node2->setPosition(10, 0, 0); + + QList nodes = {node1, node2}; + Ogre::SceneNode* groupNode = mgr->groupNodes(nodes); + ASSERT_NE(groupNode, nullptr); + + // Move the group + groupNode->translate(100, 0, 0); + + // Children should have moved as well (world positions) + Ogre::Vector3 world1 = node1->_getDerivedPosition(); + Ogre::Vector3 world2 = node2->_getDerivedPosition(); + EXPECT_NEAR(world1.x, 100.0, 0.001); + EXPECT_NEAR(world2.x, 110.0, 0.001); +} + +TEST_F(NodeGroupingTest, NestedGroups) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "No GL context for mesh operations"; + + auto* mgr = Manager::getSingleton(); + + auto mesh1 = createInMemoryTriangleMesh("nested_mesh1"); + auto* node1 = mgr->addSceneNode("NestedNode1"); + mgr->createEntity(node1, mesh1); + node1->setPosition(0, 0, 0); + + auto mesh2 = createInMemoryTriangleMesh("nested_mesh2"); + auto* node2 = mgr->addSceneNode("NestedNode2"); + mgr->createEntity(node2, mesh2); + node2->setPosition(10, 0, 0); + + auto mesh3 = createInMemoryTriangleMesh("nested_mesh3"); + auto* node3 = mgr->addSceneNode("NestedNode3"); + mgr->createEntity(node3, mesh3); + node3->setPosition(20, 0, 0); + + // Group node1 and node2 + QList innerNodes = {node1, node2}; + Ogre::SceneNode* innerGroup = mgr->groupNodes(innerNodes); + ASSERT_NE(innerGroup, nullptr); + + // Group the inner group with node3 + QList outerNodes = {innerGroup, node3}; + Ogre::SceneNode* outerGroup = mgr->groupNodes(outerNodes); + ASSERT_NE(outerGroup, nullptr); + + // Verify nesting + EXPECT_EQ(innerGroup->getParent(), outerGroup); + EXPECT_EQ(node3->getParent(), outerGroup); + EXPECT_EQ(node1->getParent(), innerGroup); + EXPECT_EQ(node2->getParent(), innerGroup); + + // Both levels should be recognized as groups + EXPECT_TRUE(mgr->isGroupNode(innerGroup)); + EXPECT_TRUE(mgr->isGroupNode(outerGroup)); +} + +TEST_F(NodeGroupingTest, GetSceneNodesIncludesGroupChildren) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "No GL context for mesh operations"; + + auto* mgr = Manager::getSingleton(); + + auto mesh1 = createInMemoryTriangleMesh("scenenodes_mesh1"); + auto* node1 = mgr->addSceneNode("SceneNodesNode1"); + mgr->createEntity(node1, mesh1); + + auto mesh2 = createInMemoryTriangleMesh("scenenodes_mesh2"); + auto* node2 = mgr->addSceneNode("SceneNodesNode2"); + mgr->createEntity(node2, mesh2); + + QList nodes = {node1, node2}; + Ogre::SceneNode* groupNode = mgr->groupNodes(nodes); + ASSERT_NE(groupNode, nullptr); + + // getSceneNodes should include the group AND its children + auto& allNodes = mgr->getSceneNodes(); + bool foundGroup = false, foundNode1 = false, foundNode2 = false; + for (auto* n : allNodes) { + if (n == groupNode) foundGroup = true; + if (n == node1) foundNode1 = true; + if (n == node2) foundNode2 = true; + } + EXPECT_TRUE(foundGroup); + EXPECT_TRUE(foundNode1); + EXPECT_TRUE(foundNode2); +} + +TEST_F(NodeGroupingTest, GetEntitiesIncludesGroupedEntities) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "No GL context for mesh operations"; + + auto* mgr = Manager::getSingleton(); + + auto mesh1 = createInMemoryTriangleMesh("entities_mesh1"); + auto* node1 = mgr->addSceneNode("EntitiesNode1"); + auto* ent1 = mgr->createEntity(node1, mesh1); + + auto mesh2 = createInMemoryTriangleMesh("entities_mesh2"); + auto* node2 = mgr->addSceneNode("EntitiesNode2"); + auto* ent2 = mgr->createEntity(node2, mesh2); + + QList nodes = {node1, node2}; + mgr->groupNodes(nodes); + + // getEntities should include entities inside groups + auto& allEntities = mgr->getEntities(); + bool foundEnt1 = false, foundEnt2 = false; + for (auto* e : allEntities) { + if (e == ent1) foundEnt1 = true; + if (e == ent2) foundEnt2 = true; + } + EXPECT_TRUE(foundEnt1); + EXPECT_TRUE(foundEnt2); +} + +TEST_F(NodeGroupingTest, HasSceneNodeFindsNestedNodes) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "No GL context for mesh operations"; + + auto* mgr = Manager::getSingleton(); + + auto mesh = createInMemoryTriangleMesh("hasnested_mesh"); + auto* node = mgr->addSceneNode("HasNestedNode"); + mgr->createEntity(node, mesh); + + auto mesh2 = createInMemoryTriangleMesh("hasnested_mesh2"); + auto* node2 = mgr->addSceneNode("HasNestedNode2"); + mgr->createEntity(node2, mesh2); + + QList nodes = {node, node2}; + mgr->groupNodes(nodes); + + // hasSceneNode should find nodes inside groups + EXPECT_TRUE(mgr->hasSceneNode("HasNestedNode")); + EXPECT_TRUE(mgr->hasSceneNode("HasNestedNode2")); +} diff --git a/src/SceneTreeModel.cpp b/src/SceneTreeModel.cpp index 3e538f491..0e96e8cb3 100644 --- a/src/SceneTreeModel.cpp +++ b/src/SceneTreeModel.cpp @@ -43,7 +43,13 @@ QString SceneTreeItem::typeLabel() const { switch (mType) { case Root: return "Scene"; - case Node: return "Node"; + case Node: { + // Show "Group" for empty scene nodes that have children (groups) + auto* sn = static_cast(mOgrePtr); + if (sn && sn->numAttachedObjects() == 0 && sn->numChildren() > 0) + return "Group"; + return "Node"; + } case Entity: return "Mesh"; case SubEntity: return "Submesh"; } diff --git a/src/commands/TransformCommands.cpp b/src/commands/TransformCommands.cpp index 635dbf354..7159bfd53 100644 --- a/src/commands/TransformCommands.cpp +++ b/src/commands/TransformCommands.cpp @@ -192,6 +192,207 @@ void DuplicateCommand::redo() sel->append(clone); } +// ---- GroupCommand ---- + +GroupCommand::GroupCommand(const QList& nodes, + QUndoCommand* parent) + : QUndoCommand("Group", parent), mFirstRedo(true) +{ + // Compute centroid for group position + Ogre::Vector3 centroid = Ogre::Vector3::ZERO; + for (Ogre::SceneNode* node : nodes) + centroid += node->_getDerivedPosition(); + centroid /= static_cast(nodes.size()); + mGroupPosition = centroid; + + // Store original parent info for each node + for (Ogre::SceneNode* node : nodes) + { + NodeParentInfo info; + info.nodeName = node->getName(); + Ogre::Node* p = node->getParent(); + auto* mgr = Manager::getSingleton()->getSceneMgr(); + info.oldParentName = (p && p != mgr->getRootSceneNode()) ? p->getName() : ""; + info.oldPosition = node->getPosition(); + info.oldOrientation = node->getOrientation(); + info.oldScale = node->getScale(); + mNodeInfos.append(info); + } +} + +void GroupCommand::undo() +{ + auto* mgr = Manager::getSingleton(); + auto* sceneMgr = mgr->getSceneMgr(); + if (!sceneMgr) return; + + // Find the group node + if (!sceneMgr->hasSceneNode(mGroupNodeName)) return; + Ogre::SceneNode* groupNode = sceneMgr->getSceneNode(mGroupNodeName); + + // Reparent children back to original parents with original local transforms + for (const auto& info : mNodeInfos) { + if (!sceneMgr->hasSceneNode(info.nodeName)) continue; + Ogre::SceneNode* child = sceneMgr->getSceneNode(info.nodeName); + + groupNode->removeChild(child); + + Ogre::SceneNode* oldParent = info.oldParentName.empty() + ? sceneMgr->getRootSceneNode() + : sceneMgr->getSceneNode(info.oldParentName); + oldParent->addChild(child); + + child->setPosition(info.oldPosition); + child->setOrientation(info.oldOrientation); + child->setScale(info.oldScale); + } + + // Destroy the group node + emit mgr->sceneNodeDestroyed(groupNode); + mgr->destroyAllAttachedMovableObjects(groupNode); + sceneMgr->destroySceneNode(groupNode); +} + +void GroupCommand::redo() +{ + if (mFirstRedo) { + // First redo is the initial grouping — done by caller (Manager::groupNodes) + // We just need to capture the group node name + auto* mgr = Manager::getSingleton(); + QList nodes; + for (const auto& info : mNodeInfos) { + if (mgr->getSceneMgr()->hasSceneNode(info.nodeName)) + nodes.append(mgr->getSceneMgr()->getSceneNode(info.nodeName)); + } + // The group node was created by the caller before pushing this command + // Find it by looking for the parent of the first node + if (!nodes.isEmpty()) { + Ogre::SceneNode* parent = static_cast(nodes.first()->getParent()); + if (parent && parent != mgr->getSceneMgr()->getRootSceneNode()) + mGroupNodeName = parent->getName(); + } + mFirstRedo = false; + return; + } + + // Re-do: recreate group and reparent + auto* mgr = Manager::getSingleton(); + auto* sceneMgr = mgr->getSceneMgr(); + if (!sceneMgr) return; + + // Recreate the group node + Ogre::SceneNode* groupNode = sceneMgr->getRootSceneNode()->createChildSceneNode(mGroupNodeName); + groupNode->setPosition(mGroupPosition); + + for (const auto& info : mNodeInfos) { + if (!sceneMgr->hasSceneNode(info.nodeName)) continue; + Ogre::SceneNode* child = sceneMgr->getSceneNode(info.nodeName); + + // Save world transform + Ogre::Vector3 worldPos = child->_getDerivedPosition(); + Ogre::Quaternion worldOrient = child->_getDerivedOrientation(); + Ogre::Vector3 worldScale = child->_getDerivedScale(); + + // Reparent + Ogre::SceneNode* oldParent = static_cast(child->getParent()); + if (oldParent) oldParent->removeChild(child); + groupNode->addChild(child); + + // Restore world transform as local transform under group + Ogre::Quaternion groupWorldOrient = groupNode->_getDerivedOrientation(); + Ogre::Vector3 groupWorldScale = groupNode->_getDerivedScale(); + Ogre::Vector3 groupDerivedPos = groupNode->_getDerivedPosition(); + + child->setOrientation(groupWorldOrient.Inverse() * worldOrient); + child->setScale(worldScale / groupWorldScale); + child->setPosition(groupWorldOrient.Inverse() * + ((worldPos - groupDerivedPos) / groupWorldScale)); + } + + emit mgr->sceneNodeCreated(groupNode); + SelectionSet::getSingleton()->selectOne(groupNode); +} + +// ---- UngroupCommand ---- + +UngroupCommand::UngroupCommand(Ogre::SceneNode* groupNode, + QUndoCommand* parent) + : QUndoCommand("Ungroup", parent), mFirstRedo(true) +{ + mGroupNodeName = groupNode->getName(); + mGroupPosition = groupNode->getPosition(); + mGroupOrientation = groupNode->getOrientation(); + mGroupScale = groupNode->getScale(); + + Ogre::Node* p = groupNode->getParent(); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + mGroupParentName = (p && p != sceneMgr->getRootSceneNode()) ? p->getName() : ""; + + // Capture children info (local transforms relative to group) + for (auto& child : groupNode->getChildren()) { + Ogre::SceneNode* childNode = static_cast(child); + if (Manager::getSingleton()->isForbiddenNodeName(QString::fromStdString(childNode->getName()))) + continue; + ChildInfo ci; + ci.childName = childNode->getName(); + ci.localPosition = childNode->getPosition(); + ci.localOrientation = childNode->getOrientation(); + ci.localScale = childNode->getScale(); + mChildInfos.append(ci); + } +} + +void UngroupCommand::undo() +{ + // Re-create the group node and reparent children back + auto* mgr = Manager::getSingleton(); + auto* sceneMgr = mgr->getSceneMgr(); + if (!sceneMgr) return; + + Ogre::SceneNode* parentNode = mGroupParentName.empty() + ? sceneMgr->getRootSceneNode() + : sceneMgr->getSceneNode(mGroupParentName); + + Ogre::SceneNode* groupNode = parentNode->createChildSceneNode(mGroupNodeName); + groupNode->setPosition(mGroupPosition); + groupNode->setOrientation(mGroupOrientation); + groupNode->setScale(mGroupScale); + + for (const auto& ci : mChildInfos) { + if (!sceneMgr->hasSceneNode(ci.childName)) continue; + Ogre::SceneNode* child = sceneMgr->getSceneNode(ci.childName); + + Ogre::SceneNode* oldParent = static_cast(child->getParent()); + if (oldParent) oldParent->removeChild(child); + groupNode->addChild(child); + + // Restore original local transforms relative to group + child->setPosition(ci.localPosition); + child->setOrientation(ci.localOrientation); + child->setScale(ci.localScale); + } + + emit mgr->sceneNodeCreated(groupNode); + SelectionSet::getSingleton()->selectOne(groupNode); +} + +void UngroupCommand::redo() +{ + if (mFirstRedo) { + // First redo is the initial ungrouping — done by caller (Manager::ungroupNode) + mFirstRedo = false; + return; + } + + // Re-ungroup + auto* mgr = Manager::getSingleton(); + auto* sceneMgr = mgr->getSceneMgr(); + if (!sceneMgr || !sceneMgr->hasSceneNode(mGroupNodeName)) return; + + Ogre::SceneNode* groupNode = sceneMgr->getSceneNode(mGroupNodeName); + mgr->ungroupNode(groupNode); +} + // ---- SubMeshTransformCommand ---- SubMeshTransformCommand::SubMeshTransformCommand(Ogre::SubEntity* subEntity, diff --git a/src/commands/TransformCommands.h b/src/commands/TransformCommands.h index 8383b94c2..4b5cbeb2a 100644 --- a/src/commands/TransformCommands.h +++ b/src/commands/TransformCommands.h @@ -105,6 +105,56 @@ class DuplicateCommand : public QUndoCommand bool mFirstRedo = true; }; +// Group selected nodes under a new parent node +class GroupCommand : public QUndoCommand +{ +public: + GroupCommand(const QList& nodes, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + +private: + struct NodeParentInfo { + std::string nodeName; + std::string oldParentName; // empty string means root scene node + Ogre::Vector3 oldPosition; + Ogre::Quaternion oldOrientation; + Ogre::Vector3 oldScale; + }; + QList mNodeInfos; + std::string mGroupNodeName; + Ogre::Vector3 mGroupPosition; + bool mFirstRedo = true; +}; + +// Ungroup: move children to group's parent, destroy group node +class UngroupCommand : public QUndoCommand +{ +public: + UngroupCommand(Ogre::SceneNode* groupNode, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + +private: + struct ChildInfo { + std::string childName; + Ogre::Vector3 localPosition; + Ogre::Quaternion localOrientation; + Ogre::Vector3 localScale; + }; + std::string mGroupNodeName; + std::string mGroupParentName; // empty string means root scene node + Ogre::Vector3 mGroupPosition; + Ogre::Quaternion mGroupOrientation; + Ogre::Vector3 mGroupScale; + QList mChildInfos; + bool mFirstRedo = true; +}; + // Sub-mesh vertex transform (stores full vertex snapshot for undo) class SubMeshTransformCommand : public QUndoCommand { diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index d6781a8a9..1d4aeb586 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -286,6 +286,10 @@ void MainWindow::initToolBar() // Duplicate connect(ui->actionDuplicate, &QAction::triggered, this, &MainWindow::duplicateSelected); + // Group / Ungroup + connect(ui->actionGroup, &QAction::triggered, this, &MainWindow::groupSelected); + connect(ui->actionUngroup, &QAction::triggered, this, &MainWindow::ungroupSelected); + // Refresh gizmo position after undo/redo (deferred to avoid re-entrant scene access) connect(UndoManager::getSingleton()->stack(), &QUndoStack::indexChanged, this, [](int) { QTimer::singleShot(0, []() { @@ -709,6 +713,33 @@ void MainWindow::duplicateSelected() } } +void MainWindow::groupSelected() +{ + SentryReporter::addBreadcrumb("ui.action", "Group selected nodes"); + + SelectionSet* sel = SelectionSet::getSingleton(); + if (!sel || sel->getNodesCount() < 2) return; + + QList nodes = sel->getNodesSelectionList(); + Ogre::SceneNode* groupNode = Manager::getSingleton()->groupNodes(nodes); + if (groupNode) + UndoManager::getSingleton()->push(new GroupCommand(nodes, nullptr)); +} + +void MainWindow::ungroupSelected() +{ + SentryReporter::addBreadcrumb("ui.action", "Ungroup selected node"); + + SelectionSet* sel = SelectionSet::getSingleton(); + if (!sel || sel->getNodesCount() != 1) return; + + Ogre::SceneNode* node = sel->getSceneNode(0); + if (!Manager::getSingleton()->isGroupNode(node)) return; + + UndoManager::getSingleton()->push(new UngroupCommand(node)); + Manager::getSingleton()->ungroupNode(node); +} + void MainWindow::keyPressEvent(QKeyEvent *event) { QtInputManager::getInstance().keyPressEvent(event); diff --git a/src/mainwindow.h b/src/mainwindow.h index e3dfff175..bdf0bb6bf 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -56,6 +56,8 @@ private slots: void on_actionMerge_Animations_triggered(); void duplicateSelected(); + void groupSelected(); + void ungroupSelected(); void on_actionObjects_Toolbar_toggled(bool arg1); void on_actionTools_Toolbar_toggled(bool arg1); diff --git a/ui_files/mainwindow.ui b/ui_files/mainwindow.ui index e2a11f625..223593fbd 100755 --- a/ui_files/mainwindow.ui +++ b/ui_files/mainwindow.ui @@ -110,6 +110,9 @@ + + + @@ -585,6 +588,22 @@ Ctrl+S + + + Group + + + Ctrl+G + + + + + Ungroup + + + Ctrl+Shift+G + + From 32984a7e00bf5fa8a8ede076b2eb0f7f4aa59b96 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 9 Apr 2026 00:49:04 -0400 Subject: [PATCH 03/11] =?UTF-8?q?Rename=20Group=20=E2=86=92=20Group=20Node?= =?UTF-8?q?s,=20enable=20only=20with=202+=20nodes=20selected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed menu items to "Group Nodes" and "Ungroup Nodes" for clarity - Both actions start disabled; enabled/disabled via selectionChanged: Group Nodes requires 2+ selected nodes, Ungroup Nodes requires exactly 1 selected group node Co-Authored-By: Claude Sonnet 4.6 --- src/mainwindow.cpp | 10 ++++++++++ ui_files/mainwindow.ui | 10 ++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 1d4aeb586..7896b7c2c 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -290,6 +290,16 @@ void MainWindow::initToolBar() connect(ui->actionGroup, &QAction::triggered, this, &MainWindow::groupSelected); connect(ui->actionUngroup, &QAction::triggered, this, &MainWindow::ungroupSelected); + // Enable/disable group actions based on selection + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, this, [this]() { + auto* sel = SelectionSet::getSingleton(); + int nodeCount = sel->getNodesCount(); + ui->actionGroup->setEnabled(nodeCount >= 2); + bool canUngroup = (nodeCount == 1) && Manager::getSingleton()->isGroupNode( + sel->getNodesSelectionList().first()); + ui->actionUngroup->setEnabled(canUngroup); + }); + // Refresh gizmo position after undo/redo (deferred to avoid re-entrant scene access) connect(UndoManager::getSingleton()->stack(), &QUndoStack::indexChanged, this, [](int) { QTimer::singleShot(0, []() { diff --git a/ui_files/mainwindow.ui b/ui_files/mainwindow.ui index 223593fbd..390264399 100755 --- a/ui_files/mainwindow.ui +++ b/ui_files/mainwindow.ui @@ -589,16 +589,22 @@ + + false + - Group + Group Nodes Ctrl+G + + false + - Ungroup + Ungroup Nodes Ctrl+Shift+G From 8a5caac5adbe2f3f921a83697d4be73e1269de67 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 9 Apr 2026 00:59:44 -0400 Subject: [PATCH 04/11] =?UTF-8?q?Add=20Pivot=20Point=20Editing=20(P=20key)?= =?UTF-8?q?=20=E2=80=94=20Phase=201,=20item=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pivot modes for the transform gizmo: Center (default), Bottom (floor level), and Origin (node local origin). Rotation and scale operate around the active pivot point. Core: - TransformOperator: PivotMode enum, getPivotPoint() computes pivot based on mode and selection bounding box, QSettings persistence - P key cycles through modes; QML Inspector shows 3 toggle buttons - PropertiesPanelController: Q_PROPERTY bridge for pivot mode - MCP tools: set_pivot_mode, get_pivot_mode - Sentry breadcrumbs for mode changes Part of #256 (Phase 1: Scene Editing Power Tools) Co-Authored-By: Claude Sonnet 4.6 --- qml/PropertiesPanel.qml | 46 ++++++ src/MCPServer.cpp | 74 ++++++++++ src/MCPServer.h | 2 + src/PropertiesPanelController.cpp | 26 ++++ src/PropertiesPanelController.h | 9 ++ src/TransformOperator.cpp | 226 +++++++++++++++++++++++++++++- src/TransformOperator.h | 14 ++ src/mainwindow.cpp | 4 + 8 files changed, 397 insertions(+), 4 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index e5ffce766..7a106ffb1 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -201,6 +201,52 @@ Rectangle { TransformField { label: "Z"; value: PropertiesPanelController.scaleZ; color: "#4040c0" onNewValue: function(val) { PropertiesPanelController.scaleZ = val } } } + + // Pivot Point + Text { + text: "Pivot Point (P)" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + topPadding: 4 + } + Row { + spacing: 4 + width: parent.width - 16 + + property int activePivot: PropertiesPanelController.pivotMode + + Repeater { + model: [ + { label: "Center", mode: 0 }, + { label: "Bottom", mode: 1 }, + { label: "Origin", mode: 2 } + ] + delegate: Rectangle { + required property var modelData + required property int index + width: (parent.width - 8) / 3 + height: 24 + radius: 3 + color: PropertiesPanelController.pivotMode === modelData.mode + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.inputColor + border.width: 1 + border.color: PropertiesPanelController.borderColor + + Text { + anchors.centerIn: parent + text: modelData.label + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + anchors.fill: parent + onClicked: PropertiesPanelController.pivotMode = modelData.mode + } + } + } + } } } diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index ede519386..7f755c96c 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -502,6 +502,10 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) toolResult = toolGroupNodes(args); } else if (name == "ungroup_node") { toolResult = toolUngroupNode(args); + } else if (name == "set_pivot_mode") { + toolResult = toolSetPivotMode(args); + } else if (name == "get_pivot_mode") { + toolResult = toolGetPivotMode(args); } else { if (txn) SentryReporter::finishTransaction(txn); return makeErrorResult(QString("Unknown tool: %1").arg(name)); @@ -2913,6 +2917,51 @@ QJsonObject MCPServer::toolUngroupNode(const QJsonObject &args) } } +QJsonObject MCPServer::toolSetPivotMode(const QJsonObject &args) +{ + auto* top = TransformOperator::getSingleton(); + if (!top) + return makeErrorResult("Error: TransformOperator not initialized"); + + QString modeStr = args["mode"].toString().toLower(); + TransformOperator::PivotMode mode; + + if (modeStr == "center") + mode = TransformOperator::PIVOT_CENTER; + else if (modeStr == "bottom") + mode = TransformOperator::PIVOT_BOTTOM; + else if (modeStr == "origin") + mode = TransformOperator::PIVOT_ORIGIN; + else + return makeErrorResult(QString("Error: Invalid pivot mode '%1'. Must be 'center', 'bottom', or 'origin'.").arg(modeStr)); + + SentryReporter::addBreadcrumb("ai.tool_call", + QString("set_pivot_mode: %1").arg(modeStr)); + + top->setPivotMode(mode); + return makeSuccessResult(QString("Pivot mode set to '%1'").arg(modeStr)); +} + +QJsonObject MCPServer::toolGetPivotMode(const QJsonObject &args) +{ + Q_UNUSED(args); + auto* top = TransformOperator::getSingleton(); + if (!top) + return makeErrorResult("Error: TransformOperator not initialized"); + + QString modeStr; + switch (top->pivotMode()) { + case TransformOperator::PIVOT_CENTER: modeStr = "center"; break; + case TransformOperator::PIVOT_BOTTOM: modeStr = "bottom"; break; + case TransformOperator::PIVOT_ORIGIN: modeStr = "origin"; break; + } + + QJsonObject result; + result["content"] = QJsonArray{QJsonObject{{"type", "text"}, {"text", QString("Pivot mode: %1").arg(modeStr)}}}; + result["mode"] = modeStr; + return result; +} + QJsonArray MCPServer::buildToolsList() { QJsonArray tools; @@ -3638,6 +3687,31 @@ QJsonArray MCPServer::buildToolsList() ); } + // set_pivot_mode + { + QJsonObject props; + props["mode"] = QJsonObject{{"type", "string"}, {"description", "Pivot mode: 'center' (bounding box center), 'bottom' (bottom of bounding box), or 'origin' (scene node position)"}, + {"enum", QJsonArray{"center", "bottom", "origin"}}}; + QJsonArray required; + required.append("mode"); + appendTool( + "set_pivot_mode", + "Set the pivot point mode for rotation and scale operations. 'center' uses the bounding box center, " + "'bottom' uses the bottom of the bounding box (floor level), 'origin' uses the scene node position.", + props, + required + ); + } + + // get_pivot_mode + { + appendTool( + "get_pivot_mode", + "Get the current pivot point mode. Returns 'center', 'bottom', or 'origin'.", + QJsonObject() + ); + } + return tools; } diff --git a/src/MCPServer.h b/src/MCPServer.h index ac41b76df..2c244518a 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -170,6 +170,8 @@ private slots: QJsonObject toolExportPose(const QJsonObject &args); QJsonObject toolGroupNodes(const QJsonObject &args); QJsonObject toolUngroupNode(const QJsonObject &args); + QJsonObject toolSetPivotMode(const QJsonObject &args); + QJsonObject toolGetPivotMode(const QJsonObject &args); // Animation struct NodeAnimation { diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index 283c9f10b..75b832b82 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -56,6 +56,10 @@ PropertiesPanelController::PropertiesPanelController() : QObject(nullptr) emit transformChanged(); }); + connect(transformOp, &TransformOperator::pivotModeChanged, this, [this]() { + emit pivotModeChanged(); + }); + connect(transformOp, &TransformOperator::snapSettingsChanged, this, [this]() { emit snapSettingsChanged(); emit snapEnabledChanged(); @@ -547,6 +551,28 @@ void PropertiesPanelController::refreshTheme() emit themeChanged(); } +// Pivot mode — delegate to TransformOperator +int PropertiesPanelController::pivotMode() const +{ + return static_cast(TransformOperator::getSingleton()->pivotMode()); +} + +void PropertiesPanelController::setPivotMode(int mode) +{ + if (mode >= TransformOperator::PIVOT_CENTER && mode <= TransformOperator::PIVOT_ORIGIN) + { + TransformOperator::getSingleton()->setPivotMode( + static_cast(mode)); + emit pivotModeChanged(); + } +} + +void PropertiesPanelController::cyclePivotMode() +{ + TransformOperator::getSingleton()->cyclePivotMode(); + emit pivotModeChanged(); +} + // Snap settings — delegate to TransformOperator bool PropertiesPanelController::snapEnabled() const { diff --git a/src/PropertiesPanelController.h b/src/PropertiesPanelController.h index fe6485b97..dc434c2fb 100644 --- a/src/PropertiesPanelController.h +++ b/src/PropertiesPanelController.h @@ -44,6 +44,9 @@ class PropertiesPanelController : public QObject Q_PROPERTY(bool hasAnimations READ hasAnimations NOTIFY selectionChanged) Q_PROPERTY(bool playing READ isPlaying WRITE setPlaying NOTIFY playingChanged) + // Pivot mode + Q_PROPERTY(int pivotMode READ pivotMode WRITE setPivotMode NOTIFY pivotModeChanged) + // Snap properties Q_PROPERTY(bool snapEnabled READ snapEnabled WRITE setSnapEnabled NOTIFY snapEnabledChanged) Q_PROPERTY(double snapGridSize READ snapGridSize WRITE setSnapGridSize NOTIFY snapGridSizeChanged) @@ -103,6 +106,11 @@ class PropertiesPanelController : public QObject void setScaleY(double v); void setScaleZ(double v); + // Pivot mode accessors/mutators + int pivotMode() const; + void setPivotMode(int mode); + Q_INVOKABLE void cyclePivotMode(); + // Snap accessors/mutators bool snapEnabled() const; double snapGridSize() const; @@ -186,6 +194,7 @@ public slots: void primitiveChanged(); void playingChanged(); void animationStateChanged(); + void pivotModeChanged(); void snapSettingsChanged(); void snapEnabledChanged(); void snapGridSizeChanged(); diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index be0fbe306..c010a5817 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include "GlobalDefinitions.h" @@ -91,6 +92,11 @@ TransformOperator::TransformOperator() : QObject(nullptr) mSnapGridSize = settings.value("Snap/gridSize", 1.0).toDouble(); mSnapAngleStep = settings.value("Snap/angleStep", 15.0).toDouble(); mSnapScaleStep = settings.value("Snap/scaleStep", 0.25).toDouble(); + + // Load pivot mode from QSettings + int pivotVal = settings.value("Pivot/mode", static_cast(PIVOT_CENTER)).toInt(); + if (pivotVal >= PIVOT_CENTER && pivotVal <= PIVOT_ORIGIN) + mPivotMode = static_cast(pivotVal); } TransformOperator::~TransformOperator() @@ -247,6 +253,216 @@ Ogre::Vector3 TransformOperator::snapScale(const Ogre::Vector3& scale, double sc static_cast(snapValue(scale.z, scaleStep)) ); } +void TransformOperator::setPivotMode(PivotMode mode) +{ + if (mPivotMode != mode) + { + mPivotMode = mode; + QSettings settings; + settings.setValue("Pivot/mode", static_cast(mPivotMode)); + QString modeName; + switch (mode) { + case PIVOT_CENTER: modeName = "Center"; break; + case PIVOT_BOTTOM: modeName = "Bottom"; break; + case PIVOT_ORIGIN: modeName = "Origin"; break; + } + SentryReporter::addBreadcrumb("ui.action", + QString("Pivot mode changed to %1").arg(modeName)); + updateGizmoPosition(); + emit pivotModeChanged(mPivotMode); + } +} + +void TransformOperator::cyclePivotMode() +{ + switch (mPivotMode) { + case PIVOT_CENTER: setPivotMode(PIVOT_BOTTOM); break; + case PIVOT_BOTTOM: setPivotMode(PIVOT_ORIGIN); break; + case PIVOT_ORIGIN: setPivotMode(PIVOT_CENTER); break; + } +} + +Ogre::Vector3 TransformOperator::getPivotPoint() const +{ + auto* sel = SelectionSet::getSingleton(); + if (sel->isEmpty()) + return Ogre::Vector3::ZERO; + + switch (mPivotMode) + { + case PIVOT_CENTER: + { + // True geometric center of bounding box + if (sel->hasNodes()) + { + Ogre::Vector3 center = Ogre::Vector3::ZERO; + for (int i = 0; i < sel->getNodesCount(); ++i) + { + Ogre::SceneNode* node = sel->getSceneNode(i); + // Use world bounding box center if entities are attached, + // otherwise use node position + if (node->numAttachedObjects() > 0) + { + Ogre::AxisAlignedBox aabb; + for (auto& obj : node->getAttachedObjects()) + aabb.merge(obj->getWorldBoundingBox(true)); + if (aabb.isFinite()) + { + center += aabb.getCenter(); + continue; + } + } + center += node->getPosition(); + } + return center / static_cast(sel->getNodesCount()); + } + else if (sel->hasEntities()) + { + Ogre::Vector3 center = Ogre::Vector3::ZERO; + for (int i = 0; i < sel->getEntitiesCount(); ++i) + { + Ogre::Entity* ent = sel->getEntity(i); + const Ogre::AxisAlignedBox bb = ent->getWorldBoundingBox(true); + if (bb.isFinite()) + center += bb.getCenter(); + else + center += ent->getParentSceneNode()->getPosition(); + } + return center / static_cast(sel->getEntitiesCount()); + } + else if (sel->hasSubEntities()) + { + Ogre::Vector3 center = Ogre::Vector3::ZERO; + for (int i = 0; i < sel->getSubEntitiesCount(); ++i) + { + Ogre::SubEntity* sub = sel->getSubEntity(i); + const Ogre::AxisAlignedBox bb = sub->getParent()->getWorldBoundingBox(true); + if (bb.isFinite()) + center += bb.getCenter(); + else + center += sub->getParent()->getParentSceneNode()->getPosition(); + } + return center / static_cast(sel->getSubEntitiesCount()); + } + break; + } + case PIVOT_BOTTOM: + { + // Bottom-center: center of bounding box but Y = minimum Y + if (sel->hasNodes()) + { + Ogre::Vector3 center = Ogre::Vector3::ZERO; + Ogre::Real minY = std::numeric_limits::max(); + int count = sel->getNodesCount(); + for (int i = 0; i < count; ++i) + { + Ogre::SceneNode* node = sel->getSceneNode(i); + if (node->numAttachedObjects() > 0) + { + Ogre::AxisAlignedBox aabb; + for (auto& obj : node->getAttachedObjects()) + aabb.merge(obj->getWorldBoundingBox(true)); + if (aabb.isFinite()) + { + center += aabb.getCenter(); + if (aabb.getMinimum().y < minY) + minY = aabb.getMinimum().y; + continue; + } + } + center += node->getPosition(); + if (node->getPosition().y < minY) + minY = node->getPosition().y; + } + center /= static_cast(count); + center.y = minY; + return center; + } + else if (sel->hasEntities()) + { + Ogre::Vector3 center = Ogre::Vector3::ZERO; + Ogre::Real minY = std::numeric_limits::max(); + int count = sel->getEntitiesCount(); + for (int i = 0; i < count; ++i) + { + Ogre::Entity* ent = sel->getEntity(i); + const Ogre::AxisAlignedBox bb = ent->getWorldBoundingBox(true); + if (bb.isFinite()) + { + center += bb.getCenter(); + if (bb.getMinimum().y < minY) + minY = bb.getMinimum().y; + } + else + { + center += ent->getParentSceneNode()->getPosition(); + if (ent->getParentSceneNode()->getPosition().y < minY) + minY = ent->getParentSceneNode()->getPosition().y; + } + } + center /= static_cast(count); + center.y = minY; + return center; + } + else if (sel->hasSubEntities()) + { + Ogre::Vector3 center = Ogre::Vector3::ZERO; + Ogre::Real minY = std::numeric_limits::max(); + int count = sel->getSubEntitiesCount(); + for (int i = 0; i < count; ++i) + { + Ogre::SubEntity* sub = sel->getSubEntity(i); + const Ogre::AxisAlignedBox bb = sub->getParent()->getWorldBoundingBox(true); + if (bb.isFinite()) + { + center += bb.getCenter(); + if (bb.getMinimum().y < minY) + minY = bb.getMinimum().y; + } + else + { + center += sub->getParent()->getParentSceneNode()->getPosition(); + if (sub->getParent()->getParentSceneNode()->getPosition().y < minY) + minY = sub->getParent()->getParentSceneNode()->getPosition().y; + } + } + center /= static_cast(count); + center.y = minY; + return center; + } + break; + } + case PIVOT_ORIGIN: + { + // Node's own local origin (node position / parent scene node position) + if (sel->hasNodes()) + { + Ogre::Vector3 center = Ogre::Vector3::ZERO; + for (int i = 0; i < sel->getNodesCount(); ++i) + center += sel->getSceneNode(i)->getPosition(); + return center / static_cast(sel->getNodesCount()); + } + else if (sel->hasEntities()) + { + Ogre::Vector3 center = Ogre::Vector3::ZERO; + for (int i = 0; i < sel->getEntitiesCount(); ++i) + center += sel->getEntity(i)->getParentSceneNode()->getPosition(); + return center / static_cast(sel->getEntitiesCount()); + } + else if (sel->hasSubEntities()) + { + Ogre::Vector3 center = Ogre::Vector3::ZERO; + for (int i = 0; i < sel->getSubEntitiesCount(); ++i) + center += sel->getSubEntity(i)->getParent()->getParentSceneNode()->getPosition(); + return center / static_cast(sel->getSubEntitiesCount()); + } + break; + } + } + + return Ogre::Vector3::ZERO; +} + const Ogre::ColourValue& TransformOperator::getSelectionBoxColour() const { return m_pSelectionBox->getBoxColour(); } @@ -361,17 +577,19 @@ void TransformOperator::updateGizmoPosition() if(SelectionSet::getSingleton()->hasNodes()) { - currentPosition = SelectionSet::getSingleton()->getSelectionCenter(); currentOrientation = SelectionSet::getSingleton()->getSelectionOrientation(); currentScale = SelectionSet::getSingleton()->getSelectionScale(); - m_pTransformNode->setPosition(currentPosition); + Ogre::Vector3 pivotPoint = getPivotPoint(); + currentPosition = pivotPoint; + m_pTransformNode->setPosition(pivotPoint); } else if(SelectionSet::getSingleton()->hasEntities()) { - currentPosition = SelectionSet::getSingleton()->getSelectionCenter()-SelectionSet::getSingleton()->getSelectionNodesCenter(); currentOrientation = SelectionSet::getSingleton()->getSelectionOrientation(); currentScale = SelectionSet::getSingleton()->getSelectionScale(); - m_pTransformNode->setPosition(currentPosition + SelectionSet::getSingleton()->getSelectionNodesCenter()); + Ogre::Vector3 pivotPoint = getPivotPoint(); + currentPosition = pivotPoint - SelectionSet::getSingleton()->getSelectionNodesCenter(); + m_pTransformNode->setPosition(pivotPoint); } else if(SelectionSet::getSingleton()->hasSubEntities()) { diff --git a/src/TransformOperator.h b/src/TransformOperator.h index 373868224..9f9af0583 100755 --- a/src/TransformOperator.h +++ b/src/TransformOperator.h @@ -52,10 +52,22 @@ class TransformOperator : public QObject, public QtMouseListener SPACE_WORLD, SPACE_LOCAL, }; + enum PivotMode + { + PIVOT_CENTER, + PIVOT_BOTTOM, + PIVOT_ORIGIN, + }; + Q_ENUM(PivotMode) const Ogre::ColourValue& getSelectionBoxColour() const; TransformSpace getTransformSpace() const { return mTransformSpace; } + PivotMode pivotMode() const { return mPivotMode; } + void setPivotMode(PivotMode mode); + void cyclePivotMode(); + Ogre::Vector3 getPivotPoint() const; + // --- Snap settings --- bool isSnapEnabled() const { return mSnapEnabled; } void setSnapEnabled(bool enabled); @@ -99,6 +111,7 @@ class TransformOperator : public QObject, public QtMouseListener void selectedScaleChanged(const Ogre::Vector3& newScale); void selectedOrientationChanged(const Ogre::Vector3& newOrientation); void transformSpaceChanged(TransformSpace newSpace); + void pivotModeChanged(PivotMode newMode); void snapSettingsChanged(); public slots: @@ -148,6 +161,7 @@ public slots: Ogre::Vector3 mTransformVector = Ogre::Vector3::ZERO; TransformState mTransformState = TS_NONE; TransformSpace mTransformSpace = SPACE_WORLD; + PivotMode mPivotMode = PIVOT_CENTER; Ogre::Real mScaleStartDistance = 0.0f; // Snap settings (persisted in QSettings) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7896b7c2c..44541f345 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -791,6 +791,10 @@ void MainWindow::keyPressEvent(QKeyEvent *event) SentryReporter::addBreadcrumb("ui.shortcut", "X — Toggle transform space"); TransformOperator::getSingleton()->toggleTransformSpace(); break; + case Qt::Key_P: + SentryReporter::addBreadcrumb("ui.shortcut", "P — Cycle pivot mode"); + TransformOperator::getSingleton()->cyclePivotMode(); + break; case Qt::Key_Delete: SentryReporter::addBreadcrumb("ui.shortcut", "Delete — Remove selected"); TransformOperator::getSingleton()->removeSelected(); From 36ab2bd5f6481f2dceeb9f3e72c3950974dc3569 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 9 Apr 2026 01:02:55 -0400 Subject: [PATCH 05/11] =?UTF-8?q?Add=20Undo=20History=20Panel=20=E2=80=94?= =?UTF-8?q?=20Phase=201,=20item=209?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsible "Undo History" section in the Inspector panel showing the full QUndoStack. Click any entry to jump to that state. - PropertiesPanelController: undoHistory property (QVariantList of {text, isCurrent}), undoIndex, undoToIndex(int), clearUndoHistory() - QML: ListView with highlighted current position, dimmed redo zone, "Initial State" entry, hover effects, "Clear History" button - Sentry breadcrumbs for history jumps and clear Part of #256 (Phase 1: Scene Editing Power Tools) Co-Authored-By: Claude Sonnet 4.6 --- qml/PropertiesPanel.qml | 165 ++++++++++++++++++++++++++++++ src/PropertiesPanelController.cpp | 46 +++++++++ src/PropertiesPanelController.h | 12 +++ 3 files changed, 223 insertions(+) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 7a106ffb1..c9ed415ce 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -91,6 +91,14 @@ Rectangle { Component.onCompleted: content = validationComponent } + + // ---- Undo History ---- + CollapsibleSection { + title: "Undo History" + expanded: false + + Component.onCompleted: content = undoHistoryComponent + } } } @@ -1299,4 +1307,161 @@ Rectangle { } } } + + // ---- Undo History Content ---- + Component { + id: undoHistoryComponent + + Column { + width: parent ? parent.width : 200 + spacing: 0 + + property var historyEntries: PropertiesPanelController.undoHistory + property int currentIndex: PropertiesPanelController.undoIndex + + // Empty state + Text { + visible: historyEntries.length === 0 + text: "No undo history" + color: Qt.darker(PropertiesPanelController.textColor, 1.4) + font.pixelSize: 11 + font.italic: true + padding: 8 + } + + // "Clean State" entry (index 0 — before any command) + Rectangle { + visible: historyEntries.length > 0 + width: parent.width + height: 26 + color: currentIndex === 0 ? PropertiesPanelController.highlightColor + : historyCleanMouse.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.15) + : "transparent" + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 8 + spacing: 6 + + Text { + text: currentIndex === 0 ? "\u25B6" : "" + color: currentIndex === 0 ? "white" : PropertiesPanelController.textColor + font.pixelSize: 9 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "Initial State" + color: currentIndex === 0 ? "white" : Qt.darker(PropertiesPanelController.textColor, 1.2) + font.pixelSize: 11 + font.italic: true + } + } + + MouseArea { + id: historyCleanMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: PropertiesPanelController.undoToIndex(0) + } + } + + // Command entries + Repeater { + model: historyEntries + + Rectangle { + required property var modelData + required property int index + + width: parent ? parent.width : 200 + height: 26 + color: { + var isActive = (index < currentIndex) + var isCurrent = (index === currentIndex - 1) + if (isCurrent) return PropertiesPanelController.highlightColor + if (historyEntryMouse.containsMouse) return Qt.lighter(PropertiesPanelController.panelColor, 1.15) + if (!isActive) return Qt.darker(PropertiesPanelController.panelColor, 1.05) + return "transparent" + } + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 8 + spacing: 6 + + Text { + text: (index === currentIndex - 1) ? "\u25B6" : "" + color: (index === currentIndex - 1) ? "white" : PropertiesPanelController.textColor + font.pixelSize: 9 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: modelData.text || ("Command " + (index + 1)) + color: { + var isActive = (index < currentIndex) + var isCurrent = (index === currentIndex - 1) + if (isCurrent) return "white" + if (!isActive) return Qt.darker(PropertiesPanelController.textColor, 1.4) + return PropertiesPanelController.textColor + } + font.pixelSize: 11 + } + } + + MouseArea { + id: historyEntryMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: PropertiesPanelController.undoToIndex(index + 1) + } + } + } + + // Separator + Rectangle { + visible: historyEntries.length > 0 + width: parent.width + height: 1 + color: PropertiesPanelController.borderColor + } + + // Clear History button + Rectangle { + visible: historyEntries.length > 0 + width: parent.width - 16 + height: 26 + anchors.horizontalCenter: parent.horizontalCenter + radius: 3 + color: clearHistoryMouse.pressed ? Qt.darker(PropertiesPanelController.headerColor, 1.2) + : clearHistoryMouse.containsMouse ? Qt.lighter(PropertiesPanelController.headerColor, 1.2) + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + Text { + anchors.centerIn: parent + text: "Clear History" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + + MouseArea { + id: clearHistoryMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: PropertiesPanelController.clearUndoHistory() + } + } + + // Bottom padding + Item { width: 1; height: 8 } + } + } } diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index 75b832b82..40925a608 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -6,6 +6,7 @@ #include "AnimationWidget.h" #include "SkeletonTransform.h" #include "MeshImporterExporter.h" +#include "UndoManager.h" #include "Manager.h" #include "SentryReporter.h" #include @@ -71,6 +72,12 @@ PropertiesPanelController::PropertiesPanelController() : QObject(nullptr) connect(Manager::getSingleton(), &Manager::sceneNodeCreated, this, &PropertiesPanelController::onSceneChanged); connect(Manager::getSingleton(), &Manager::sceneNodeDestroyed, this, &PropertiesPanelController::onSceneChanged); + // Undo history: refresh when the stack changes + connect(UndoManager::getSingleton()->stack(), &QUndoStack::indexChanged, + this, &PropertiesPanelController::undoHistoryChanged); + connect(UndoManager::getSingleton()->stack(), &QUndoStack::cleanChanged, + this, [this]() { emit undoHistoryChanged(); }); + mSceneTreeModel = new SceneTreeModel(this); // Refresh theme colors when the application palette changes (Light/Dark/Custom switch) @@ -240,6 +247,45 @@ void PropertiesPanelController::selectNodeByName(const QString& name) } } +// ---- Undo History ---- + +QVariantList PropertiesPanelController::undoHistory() const +{ + QVariantList result; + auto* stack = UndoManager::getSingleton()->stack(); + for (int i = 0; i < stack->count(); ++i) + { + QVariantMap entry; + entry["text"] = stack->text(i); + entry["isCurrent"] = (i == stack->index() - 1); + result.append(entry); + } + return result; +} + +int PropertiesPanelController::undoIndex() const +{ + return UndoManager::getSingleton()->stack()->index(); +} + +void PropertiesPanelController::undoToIndex(int index) +{ + auto* stack = UndoManager::getSingleton()->stack(); + if (index < 0 || index > stack->count()) return; + + SentryReporter::addBreadcrumb("ui.action", + QString("Undo history jump to index %1").arg(index)); + + stack->setIndex(index); +} + +void PropertiesPanelController::clearUndoHistory() +{ + SentryReporter::addBreadcrumb("ui.action", "Clear undo history"); + UndoManager::getSingleton()->clear(); + emit undoHistoryChanged(); +} + bool PropertiesPanelController::hasPrimitive() const { return getSelectedPrimitive() != nullptr; } QString PropertiesPanelController::primitiveType() const diff --git a/src/PropertiesPanelController.h b/src/PropertiesPanelController.h index dc434c2fb..c434aed8c 100644 --- a/src/PropertiesPanelController.h +++ b/src/PropertiesPanelController.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include "SceneTreeModel.h" @@ -47,6 +48,10 @@ class PropertiesPanelController : public QObject // Pivot mode Q_PROPERTY(int pivotMode READ pivotMode WRITE setPivotMode NOTIFY pivotModeChanged) + // Undo history + Q_PROPERTY(QVariantList undoHistory READ undoHistory NOTIFY undoHistoryChanged) + Q_PROPERTY(int undoIndex READ undoIndex NOTIFY undoHistoryChanged) + // Snap properties Q_PROPERTY(bool snapEnabled READ snapEnabled WRITE setSnapEnabled NOTIFY snapEnabledChanged) Q_PROPERTY(double snapGridSize READ snapGridSize WRITE setSnapGridSize NOTIFY snapGridSizeChanged) @@ -163,6 +168,12 @@ class PropertiesPanelController : public QObject void setPrimUTile(double v); void setPrimVTile(double v); + // Undo history + QVariantList undoHistory() const; + int undoIndex() const; + Q_INVOKABLE void undoToIndex(int index); + Q_INVOKABLE void clearUndoHistory(); + Q_INVOKABLE void selectNodeByName(const QString& name); void setAnimationWidget(class AnimationWidget* widget) { mAnimationWidget = widget; } @@ -195,6 +206,7 @@ public slots: void playingChanged(); void animationStateChanged(); void pivotModeChanged(); + void undoHistoryChanged(); void snapSettingsChanged(); void snapEnabledChanged(); void snapGridSizeChanged(); From c6010bb208af75c04446d4ca7696b99cf3a0e44c Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 9 Apr 2026 01:36:53 -0400 Subject: [PATCH 06/11] =?UTF-8?q?Add=20Drag-and-Drop=20Node=20Reparenting?= =?UTF-8?q?=20=E2=80=94=20Phase=201,=20item=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag scene nodes in the tree to reparent them under other nodes. World-space transforms are preserved by recomputing local transforms. Core: - Manager::reparentNode(): removes from old parent, adds to new parent, recalculates local transform to preserve world position. Validates against cycles (node into own subtree). - Manager::isDescendantOf(): static cycle detection helper - ReparentCommand: stores old/new parent names and local transforms for full undo/redo - SceneTreeModel: canReparent() validation, reparentNode() with undo - SceneTreeNode.qml: Drag/DropArea on Node and Group items, drag proxy label, blue highlight on valid drop targets - PropertiesPanel.qml: "Scene (Root)" drop zone for reparenting to root - MCP tool: reparent_node (node_name, new_parent_name) - Sentry breadcrumbs Part of #256 (Phase 1: Scene Editing Power Tools) Co-Authored-By: Claude Sonnet 4.6 --- qml/PropertiesPanel.qml | 77 ++++++++++++++++++++++ qml/SceneTreeNode.qml | 100 ++++++++++++++++++++++++++++- src/MCPServer.cpp | 96 +++++++++++++++++++++++++++ src/MCPServer.h | 1 + src/Manager.cpp | 58 +++++++++++++++++ src/Manager.h | 3 + src/PropertiesPanelController.cpp | 12 ++++ src/PropertiesPanelController.h | 2 + src/SceneTreeModel.cpp | 90 ++++++++++++++++++++++++++ src/SceneTreeModel.h | 2 + src/commands/TransformCommands.cpp | 87 +++++++++++++++++++++++++ src/commands/TransformCommands.h | 31 +++++++++ 12 files changed, 557 insertions(+), 2 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index c9ed415ce..40fcf0c00 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -114,6 +114,83 @@ Rectangle { property int nodeCount: treeModel ? treeModel.rowCount() : 0 property bool delegatesActive: true + // Root drop zone — drop here to reparent to root scene node + Rectangle { + width: outlinerColumn.width + height: 22 + color: rootDropHighlight.visible + ? Qt.lighter(PropertiesPanelController.highlightColor, 1.3) + : Qt.darker(PropertiesPanelController.panelColor, 1.05) + + Rectangle { + id: rootDropHighlight + anchors.fill: parent + color: PropertiesPanelController.highlightColor + opacity: 0.25 + visible: false + } + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 4 + spacing: 4 + + Text { + text: "\u25A1" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "Scene (Root)" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + } + + DropArea { + anchors.fill: parent + keys: ["application/x-qtmesheditor-node"] + + property bool canDrop: false + + onEntered: function(drag) { + var draggedName = "" + if (drag.source && drag.source.Drag && drag.source.Drag.mimeData) { + draggedName = drag.source.Drag.mimeData["application/x-qtmesheditor-node"] || "" + } + if (draggedName) { + canDrop = PropertiesPanelController.canReparentNode(draggedName, "root") + } else { + canDrop = false + } + rootDropHighlight.visible = canDrop + drag.accepted = canDrop + } + onExited: { + rootDropHighlight.visible = false + canDrop = false + } + onDropped: function(drop) { + rootDropHighlight.visible = false + var draggedName = "" + if (drop.source && drop.source.Drag && drop.source.Drag.mimeData) { + draggedName = drop.source.Drag.mimeData["application/x-qtmesheditor-node"] || "" + } + if (draggedName && canDrop) { + PropertiesPanelController.reparentNode(draggedName, "root") + drop.accepted = true + } else { + drop.accepted = false + } + canDrop = false + } + } + } + Repeater { model: outlinerColumn.nodeCount diff --git a/qml/SceneTreeNode.qml b/qml/SceneTreeNode.qml index 2e87601a0..1fc50fa0b 100644 --- a/qml/SceneTreeNode.qml +++ b/qml/SceneTreeNode.qml @@ -14,6 +14,8 @@ Column { property bool hasChildren: childCount > 0 property string nodeName: treeModel ? (treeModel.data(nodeIndex) || "") : "" property bool selected: false + // Only Node-type items are draggable (not entities/submeshes) + property bool isNodeType: treeModel ? (treeModel.data(nodeIndex, 259) === "Node" || treeModel.data(nodeIndex, 259) === "Group") : false width: parent ? parent.width : 200 @@ -31,6 +33,7 @@ Column { // Row for this node Rectangle { + id: nodeRow width: treeNode.width height: 22 color: treeNode.selected @@ -38,12 +41,66 @@ Column { : (rowMouse.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.15) : "transparent") - // Full-row mouse area for selection (behind everything) + // Drop highlight (shown when a valid drop target) + Rectangle { + id: dropHighlight + anchors.fill: parent + color: PropertiesPanelController.highlightColor + opacity: 0.3 + visible: false + z: 5 + } + + // DropArea for reparenting: accepts dragged nodes + DropArea { + id: nodeDropArea + anchors.fill: parent + keys: ["application/x-qtmesheditor-node"] + + property bool canDrop: false + + onEntered: function(drag) { + // Get the dragged node name from the drag source + var draggedName = "" + if (drag.source && drag.source.Drag && drag.source.Drag.mimeData) { + draggedName = drag.source.Drag.mimeData["application/x-qtmesheditor-node"] || "" + } + if (draggedName && treeNode.isNodeType) { + canDrop = PropertiesPanelController.canReparentNode(draggedName, treeNode.nodeName) + } else { + canDrop = false + } + dropHighlight.visible = canDrop + drag.accepted = canDrop + } + onExited: { + dropHighlight.visible = false + canDrop = false + } + onDropped: function(drop) { + dropHighlight.visible = false + var draggedName = "" + if (drop.source && drop.source.Drag && drop.source.Drag.mimeData) { + draggedName = drop.source.Drag.mimeData["application/x-qtmesheditor-node"] || "" + } + if (draggedName && canDrop) { + PropertiesPanelController.reparentNode(draggedName, treeNode.nodeName) + drop.accepted = true + } else { + drop.accepted = false + } + canDrop = false + } + } + + // Full-row mouse area for selection and drag initiation MouseArea { id: rowMouse anchors.fill: parent hoverEnabled: true - // acceptedButtons default is Qt.LeftButton + drag.target: treeNode.isNodeType ? dragProxy : undefined + drag.threshold: 10 + onClicked: function(mouse) { if (treeModel) { var multiSelect = (mouse.modifiers & Qt.ControlModifier) || @@ -51,6 +108,44 @@ Column { treeModel.selectItem(nodeIndex.row, treeModel.parent(nodeIndex), multiSelect) } } + + // Drag proxy (invisible item that follows the mouse during drag) + Item { + id: dragProxy + width: 1 + height: 1 + + Drag.active: rowMouse.drag.active + Drag.keys: ["application/x-qtmesheditor-node"] + Drag.mimeData: { + "application/x-qtmesheditor-node": treeNode.nodeName + } + Drag.hotSpot.x: 0 + Drag.hotSpot.y: 0 + Drag.dragType: Drag.Internal + } + } + + // Floating drag label (appears near cursor during drag) + Rectangle { + id: dragLabel + visible: rowMouse.drag.active + z: 100 + width: dragLabelText.implicitWidth + 12 + height: 20 + radius: 3 + color: PropertiesPanelController.highlightColor + opacity: 0.9 + x: rowMouse.mouseX + 15 + y: rowMouse.mouseY - 10 + + Text { + id: dragLabelText + anchors.centerIn: parent + text: treeNode.nodeName + color: "white" + font.pixelSize: 10 + } } Row { @@ -58,6 +153,7 @@ Column { anchors.left: parent.left anchors.leftMargin: 4 + indentLevel * 16 spacing: 4 + z: 10 // Above drop areas // Expand/collapse chevron button Item { diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 7f755c96c..71649b6ba 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -502,6 +502,8 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) toolResult = toolGroupNodes(args); } else if (name == "ungroup_node") { toolResult = toolUngroupNode(args); + } else if (name == "reparent_node") { + toolResult = toolReparentNode(args); } else if (name == "set_pivot_mode") { toolResult = toolSetPivotMode(args); } else if (name == "get_pivot_mode") { @@ -2917,6 +2919,83 @@ QJsonObject MCPServer::toolUngroupNode(const QJsonObject &args) } } +QJsonObject MCPServer::toolReparentNode(const QJsonObject &args) +{ + try { + auto* mgr = Manager::getSingletonPtr(); + if (!mgr) return makeErrorResult("Error: Manager not available"); + + SentryReporter::addBreadcrumb("ai.tool_call", "reparent_node"); + + if (!args.contains("node_name")) + return makeErrorResult("Error: 'node_name' is required"); + + QString nodeName = args["node_name"].toString(); + QString newParentName = args.value("new_parent_name").toString(); + + // Resolve "root" to empty string (root scene node) + if (newParentName.toLower() == "root") + newParentName = QString(); + + auto* sceneMgr = mgr->getSceneMgr(); + if (!sceneMgr) return makeErrorResult("Error: SceneManager not available"); + + Ogre::SceneNode* node = mgr->getSceneNode(nodeName); + if (!node) + return makeErrorResult(QString("Error: Scene node '%1' not found").arg(nodeName)); + + Ogre::SceneNode* newParent = nullptr; + if (newParentName.isEmpty()) { + newParent = sceneMgr->getRootSceneNode(); + } else { + newParent = mgr->getSceneNode(newParentName); + if (!newParent) + return makeErrorResult(QString("Error: Target parent node '%1' not found").arg(newParentName)); + } + + // Validate + if (node == newParent) + return makeErrorResult("Error: Cannot reparent a node to itself"); + + if (Manager::isDescendantOf(newParent, node)) + return makeErrorResult("Error: Cannot reparent a node into its own subtree (would create a cycle)"); + + if (node->getParent() == newParent) + return makeErrorResult(QString("Error: Node '%1' is already a child of '%2'") + .arg(nodeName, newParentName.isEmpty() ? "root" : newParentName)); + + // Capture old state for undo + Ogre::SceneNode* oldParent = static_cast(node->getParent()); + QString oldParentName = (oldParent && oldParent != sceneMgr->getRootSceneNode()) + ? QString::fromStdString(oldParent->getName()) : QString(); + Ogre::Vector3 oldLocalPos = node->getPosition(); + Ogre::Quaternion oldLocalOrient = node->getOrientation(); + Ogre::Vector3 oldLocalScale = node->getScale(); + + if (!mgr->reparentNode(node, newParent)) + return makeErrorResult("Error: Reparent operation failed"); + + // Capture new local transform + Ogre::Vector3 newLocalPos = node->getPosition(); + Ogre::Quaternion newLocalOrient = node->getOrientation(); + Ogre::Vector3 newLocalScale = node->getScale(); + + QString resolvedNewParentName = (newParent != sceneMgr->getRootSceneNode()) + ? QString::fromStdString(newParent->getName()) : QString(); + + UndoManager::getSingleton()->push(new ReparentCommand( + nodeName, oldParentName, resolvedNewParentName, + oldLocalPos, oldLocalOrient, oldLocalScale, + newLocalPos, newLocalOrient, newLocalScale)); + + return makeSuccessResult(QString("Reparented '%1' under '%2' (world transform preserved)") + .arg(nodeName, newParentName.isEmpty() ? "root" : newParentName)); + + } catch (std::exception& e) { + return makeErrorResult(QString("Error reparenting node: %1").arg(e.what())); + } +} + QJsonObject MCPServer::toolSetPivotMode(const QJsonObject &args) { auto* top = TransformOperator::getSingleton(); @@ -3687,6 +3766,23 @@ QJsonArray MCPServer::buildToolsList() ); } + // reparent_node + { + QJsonObject props; + props["node_name"] = QJsonObject{{"type", "string"}, {"description", "Name of the scene node to reparent"}}; + props["new_parent_name"] = QJsonObject{{"type", "string"}, {"description", "Name of the new parent node, or 'root' for the root scene node. If omitted, reparents to root."}}; + QJsonArray required; + required.append("node_name"); + appendTool( + "reparent_node", + "Reparent a scene node under a different parent in the scene hierarchy. " + "Preserves the node's world-space transform by recalculating the local transform. " + "Prevents invalid operations (reparenting into own subtree). Supports undo.", + props, + required + ); + } + // set_pivot_mode { QJsonObject props; diff --git a/src/MCPServer.h b/src/MCPServer.h index 2c244518a..dbf5c193c 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -170,6 +170,7 @@ private slots: QJsonObject toolExportPose(const QJsonObject &args); QJsonObject toolGroupNodes(const QJsonObject &args); QJsonObject toolUngroupNode(const QJsonObject &args); + QJsonObject toolReparentNode(const QJsonObject &args); QJsonObject toolSetPivotMode(const QJsonObject &args); QJsonObject toolGetPivotMode(const QJsonObject &args); diff --git a/src/Manager.cpp b/src/Manager.cpp index 3d69e3d94..83dc403dd 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -517,6 +517,64 @@ bool Manager::isGroupNode(Ogre::SceneNode* node) const return node->numAttachedObjects() == 0 && node->numChildren() > 0; } +bool Manager::isDescendantOf(Ogre::SceneNode* candidate, Ogre::SceneNode* ancestor) +{ + if (!candidate || !ancestor) return false; + Ogre::Node* current = candidate->getParent(); + while (current) { + if (current == ancestor) return true; + current = current->getParent(); + } + return false; +} + +bool Manager::reparentNode(Ogre::SceneNode* node, Ogre::SceneNode* newParent) +{ + if (!node || !newParent || !mSceneMgr) return false; + + // Prevent reparenting to self + if (node == newParent) return false; + + // Prevent cycles: newParent must not be a descendant of node + if (isDescendantOf(newParent, node)) return false; + + // Already a child of newParent — nothing to do + if (node->getParent() == newParent) return false; + + SentryReporter::addBreadcrumb("scene", + QString("Reparent '%1' under '%2'") + .arg(QString::fromStdString(node->getName())) + .arg(QString::fromStdString(newParent->getName()))); + + // Save world transform before reparenting + node->_update(true, true); + Ogre::Vector3 worldPos = node->_getDerivedPosition(); + Ogre::Quaternion worldOrient = node->_getDerivedOrientation(); + Ogre::Vector3 worldScale = node->_getDerivedScale(); + + // Reparent + Ogre::SceneNode* oldParent = static_cast(node->getParent()); + if (oldParent) + oldParent->removeChild(node); + newParent->addChild(node); + + // Restore world transform as new local transform relative to newParent + newParent->_update(true, true); + Ogre::Quaternion parentWorldOrient = newParent->_getDerivedOrientation(); + Ogre::Vector3 parentWorldScale = newParent->_getDerivedScale(); + Ogre::Vector3 parentDerivedPos = newParent->_getDerivedPosition(); + + node->setOrientation(parentWorldOrient.Inverse() * worldOrient); + node->setScale(worldScale / parentWorldScale); + node->setPosition(parentWorldOrient.Inverse() * + ((worldPos - parentDerivedPos) / parentWorldScale)); + + // Trigger scene tree rebuild + emit sceneNodeCreated(node); + + return true; +} + void Manager::destroySceneNode(const QString & name) { SentryReporter::addBreadcrumb("scene", "Destroy scene node"); diff --git a/src/Manager.h b/src/Manager.h index 32f7fdfb1..3fd0ada35 100755 --- a/src/Manager.h +++ b/src/Manager.h @@ -86,6 +86,9 @@ class Manager : public QObject void ungroupNode(Ogre::SceneNode* groupNode); bool isGroupNode(Ogre::SceneNode* node) const; + bool reparentNode(Ogre::SceneNode* node, Ogre::SceneNode* newParent); + static bool isDescendantOf(Ogre::SceneNode* candidate, Ogre::SceneNode* ancestor); + bool hasSceneNode(const QString &_name); QList& getSceneNodes(); diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index 40925a608..f114866d5 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -247,6 +247,18 @@ void PropertiesPanelController::selectNodeByName(const QString& name) } } +bool PropertiesPanelController::canReparentNode(const QString& nodeName, const QString& newParentName) +{ + if (!mSceneTreeModel) return false; + return mSceneTreeModel->canReparent(nodeName, newParentName); +} + +bool PropertiesPanelController::reparentNode(const QString& nodeName, const QString& newParentName) +{ + if (!mSceneTreeModel) return false; + return mSceneTreeModel->reparentNode(nodeName, newParentName); +} + // ---- Undo History ---- QVariantList PropertiesPanelController::undoHistory() const diff --git a/src/PropertiesPanelController.h b/src/PropertiesPanelController.h index c434aed8c..51287d648 100644 --- a/src/PropertiesPanelController.h +++ b/src/PropertiesPanelController.h @@ -175,6 +175,8 @@ class PropertiesPanelController : public QObject Q_INVOKABLE void clearUndoHistory(); Q_INVOKABLE void selectNodeByName(const QString& name); + Q_INVOKABLE bool canReparentNode(const QString& nodeName, const QString& newParentName); + Q_INVOKABLE bool reparentNode(const QString& nodeName, const QString& newParentName); void setAnimationWidget(class AnimationWidget* widget) { mAnimationWidget = widget; } // Animation diff --git a/src/SceneTreeModel.cpp b/src/SceneTreeModel.cpp index 0e96e8cb3..48390ebac 100644 --- a/src/SceneTreeModel.cpp +++ b/src/SceneTreeModel.cpp @@ -2,6 +2,9 @@ #include "Manager.h" #include "SelectionSet.h" #include "GlobalDefinitions.h" +#include "UndoManager.h" +#include "commands/TransformCommands.h" +#include "SentryReporter.h" #include #include @@ -332,6 +335,93 @@ QStringList SceneTreeModel::availableMaterials() const return names; } +bool SceneTreeModel::canReparent(const QString& nodeName, const QString& newParentName) const +{ + auto* mgr = Manager::getSingletonPtr(); + if (!mgr) return false; + + auto* sceneMgr = mgr->getSceneMgr(); + if (!sceneMgr) return false; + + // Node must exist + if (!sceneMgr->hasSceneNode(nodeName.toStdString())) return false; + + Ogre::SceneNode* node = sceneMgr->getSceneNode(nodeName.toStdString()); + + // Resolve target parent (empty or "root" means root scene node) + Ogre::SceneNode* newParent = nullptr; + if (newParentName.isEmpty() || newParentName == "root") { + newParent = sceneMgr->getRootSceneNode(); + } else { + if (!sceneMgr->hasSceneNode(newParentName.toStdString())) return false; + newParent = sceneMgr->getSceneNode(newParentName.toStdString()); + } + + // Cannot reparent to self + if (node == newParent) return false; + + // Already a child of the target — no-op + if (node->getParent() == newParent) return false; + + // Cannot reparent into own subtree + if (Manager::isDescendantOf(newParent, node)) return false; + + return true; +} + +bool SceneTreeModel::reparentNode(const QString& nodeName, const QString& newParentName) +{ + auto* mgr = Manager::getSingletonPtr(); + if (!mgr) return false; + + auto* sceneMgr = mgr->getSceneMgr(); + if (!sceneMgr) return false; + + if (!canReparent(nodeName, newParentName)) return false; + + Ogre::SceneNode* node = sceneMgr->getSceneNode(nodeName.toStdString()); + + // Resolve target parent + Ogre::SceneNode* newParent = nullptr; + if (newParentName.isEmpty() || newParentName == "root") { + newParent = sceneMgr->getRootSceneNode(); + } else { + newParent = sceneMgr->getSceneNode(newParentName.toStdString()); + } + + // Capture old state for undo + Ogre::SceneNode* oldParent = static_cast(node->getParent()); + QString oldParentName = (oldParent && oldParent != sceneMgr->getRootSceneNode()) + ? QString::fromStdString(oldParent->getName()) : QString(); + Ogre::Vector3 oldLocalPos = node->getPosition(); + Ogre::Quaternion oldLocalOrient = node->getOrientation(); + Ogre::Vector3 oldLocalScale = node->getScale(); + + // Perform the reparent (preserves world transform) + if (!mgr->reparentNode(node, newParent)) + return false; + + // Capture new local transform (set by reparentNode) + Ogre::Vector3 newLocalPos = node->getPosition(); + Ogre::Quaternion newLocalOrient = node->getOrientation(); + Ogre::Vector3 newLocalScale = node->getScale(); + + // Resolve new parent name for undo storage + QString resolvedNewParentName = (newParent != sceneMgr->getRootSceneNode()) + ? QString::fromStdString(newParent->getName()) : QString(); + + // Push undo command + UndoManager::getSingleton()->push(new ReparentCommand( + nodeName, oldParentName, resolvedNewParentName, + oldLocalPos, oldLocalOrient, oldLocalScale, + newLocalPos, newLocalOrient, newLocalScale)); + + SentryReporter::addBreadcrumb("ui.action", + QString("Reparent node '%1' under '%2'").arg(nodeName, newParentName.isEmpty() ? "root" : newParentName)); + + return true; +} + void SceneTreeModel::updateSelection() { emit dataChanged(QModelIndex(), QModelIndex(), {SelectedRole}); diff --git a/src/SceneTreeModel.h b/src/SceneTreeModel.h index de19d8c52..e0cd13919 100644 --- a/src/SceneTreeModel.h +++ b/src/SceneTreeModel.h @@ -70,6 +70,8 @@ class SceneTreeModel : public QAbstractItemModel Q_INVOKABLE QString materialName(int row, const QModelIndex& parentIndex) const; Q_INVOKABLE void setMaterial(int row, const QModelIndex& parentIndex, const QString& materialName); Q_INVOKABLE QStringList availableMaterials() const; + Q_INVOKABLE bool canReparent(const QString& nodeName, const QString& newParentName) const; + Q_INVOKABLE bool reparentNode(const QString& nodeName, const QString& newParentName); public slots: void rebuild(); diff --git a/src/commands/TransformCommands.cpp b/src/commands/TransformCommands.cpp index 7159bfd53..93b0f5108 100644 --- a/src/commands/TransformCommands.cpp +++ b/src/commands/TransformCommands.cpp @@ -393,6 +393,93 @@ void UngroupCommand::redo() mgr->ungroupNode(groupNode); } +// ---- ReparentCommand ---- + +ReparentCommand::ReparentCommand(const QString& nodeName, + const QString& oldParentName, + const QString& newParentName, + const Ogre::Vector3& oldLocalPos, + const Ogre::Quaternion& oldLocalOrient, + const Ogre::Vector3& oldLocalScale, + const Ogre::Vector3& newLocalPos, + const Ogre::Quaternion& newLocalOrient, + const Ogre::Vector3& newLocalScale, + QUndoCommand* parent) + : QUndoCommand("Reparent", parent) + , mNodeName(nodeName) + , mOldParentName(oldParentName) + , mNewParentName(newParentName) + , mOldLocalPos(oldLocalPos) + , mOldLocalOrient(oldLocalOrient) + , mOldLocalScale(oldLocalScale) + , mNewLocalPos(newLocalPos) + , mNewLocalOrient(newLocalOrient) + , mNewLocalScale(newLocalScale) +{ +} + +void ReparentCommand::undo() +{ + auto* mgr = Manager::getSingletonPtr(); + auto* sceneMgr = mgr ? mgr->getSceneMgr() : nullptr; + if (!sceneMgr) return; + + if (!sceneMgr->hasSceneNode(mNodeName.toStdString())) return; + Ogre::SceneNode* node = sceneMgr->getSceneNode(mNodeName.toStdString()); + + Ogre::SceneNode* oldParent = mOldParentName.isEmpty() + ? sceneMgr->getRootSceneNode() + : (sceneMgr->hasSceneNode(mOldParentName.toStdString()) + ? sceneMgr->getSceneNode(mOldParentName.toStdString()) + : nullptr); + if (!oldParent) return; + + Ogre::SceneNode* currentParent = static_cast(node->getParent()); + if (currentParent) + currentParent->removeChild(node); + oldParent->addChild(node); + + node->setPosition(mOldLocalPos); + node->setOrientation(mOldLocalOrient); + node->setScale(mOldLocalScale); + + emit mgr->sceneNodeCreated(node); +} + +void ReparentCommand::redo() +{ + if (mFirstRedo) { + // First redo is the initial reparent — already performed by the caller + mFirstRedo = false; + return; + } + + auto* mgr = Manager::getSingletonPtr(); + auto* sceneMgr = mgr ? mgr->getSceneMgr() : nullptr; + if (!sceneMgr) return; + + if (!sceneMgr->hasSceneNode(mNodeName.toStdString())) return; + Ogre::SceneNode* node = sceneMgr->getSceneNode(mNodeName.toStdString()); + + Ogre::SceneNode* newParent = mNewParentName.isEmpty() + ? sceneMgr->getRootSceneNode() + : (sceneMgr->hasSceneNode(mNewParentName.toStdString()) + ? sceneMgr->getSceneNode(mNewParentName.toStdString()) + : nullptr); + if (!newParent) return; + + Ogre::SceneNode* currentParent = static_cast(node->getParent()); + if (currentParent) + currentParent->removeChild(node); + newParent->addChild(node); + + node->setPosition(mNewLocalPos); + node->setOrientation(mNewLocalOrient); + node->setScale(mNewLocalScale); + + emit mgr->sceneNodeCreated(node); +} + // ---- SubMeshTransformCommand ---- SubMeshTransformCommand::SubMeshTransformCommand(Ogre::SubEntity* subEntity, diff --git a/src/commands/TransformCommands.h b/src/commands/TransformCommands.h index 4b5cbeb2a..786237cac 100644 --- a/src/commands/TransformCommands.h +++ b/src/commands/TransformCommands.h @@ -155,6 +155,37 @@ class UngroupCommand : public QUndoCommand bool mFirstRedo = true; }; +// Reparent a scene node under a new parent, preserving world-space transform +class ReparentCommand : public QUndoCommand +{ +public: + ReparentCommand(const QString& nodeName, + const QString& oldParentName, + const QString& newParentName, + const Ogre::Vector3& oldLocalPos, + const Ogre::Quaternion& oldLocalOrient, + const Ogre::Vector3& oldLocalScale, + const Ogre::Vector3& newLocalPos, + const Ogre::Quaternion& newLocalOrient, + const Ogre::Vector3& newLocalScale, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + +private: + QString mNodeName; + QString mOldParentName; + QString mNewParentName; + Ogre::Vector3 mOldLocalPos; + Ogre::Quaternion mOldLocalOrient; + Ogre::Vector3 mOldLocalScale; + Ogre::Vector3 mNewLocalPos; + Ogre::Quaternion mNewLocalOrient; + Ogre::Vector3 mNewLocalScale; + bool mFirstRedo = true; +}; + // Sub-mesh vertex transform (stores full vertex snapshot for undo) class SubMeshTransformCommand : public QUndoCommand { From cb6e83c18cf507ba21f131c97d478b6bca769604 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 9 Apr 2026 01:52:41 -0400 Subject: [PATCH 07/11] Fix drag-and-drop reparenting: use shared property instead of mime data QML Drag.Internal mode doesn't pass mimeData to DropArea handlers. Replaced with a draggedNodeName property on PropertiesPanelController that's set on mouse press and read by DropAreas on enter/drop. Co-Authored-By: Claude Sonnet 4.6 --- qml/PropertiesPanel.qml | 11 +++-------- qml/SceneTreeNode.qml | 22 ++++++++++++---------- src/PropertiesPanelController.h | 5 +++++ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 40fcf0c00..154c3058e 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -158,10 +158,7 @@ Rectangle { property bool canDrop: false onEntered: function(drag) { - var draggedName = "" - if (drag.source && drag.source.Drag && drag.source.Drag.mimeData) { - draggedName = drag.source.Drag.mimeData["application/x-qtmesheditor-node"] || "" - } + var draggedName = PropertiesPanelController.draggedNodeName || "" if (draggedName) { canDrop = PropertiesPanelController.canReparentNode(draggedName, "root") } else { @@ -176,10 +173,7 @@ Rectangle { } onDropped: function(drop) { rootDropHighlight.visible = false - var draggedName = "" - if (drop.source && drop.source.Drag && drop.source.Drag.mimeData) { - draggedName = drop.source.Drag.mimeData["application/x-qtmesheditor-node"] || "" - } + var draggedName = PropertiesPanelController.draggedNodeName || "" if (draggedName && canDrop) { PropertiesPanelController.reparentNode(draggedName, "root") drop.accepted = true @@ -187,6 +181,7 @@ Rectangle { drop.accepted = false } canDrop = false + PropertiesPanelController.draggedNodeName = "" } } } diff --git a/qml/SceneTreeNode.qml b/qml/SceneTreeNode.qml index 1fc50fa0b..f7ca69374 100644 --- a/qml/SceneTreeNode.qml +++ b/qml/SceneTreeNode.qml @@ -60,12 +60,8 @@ Column { property bool canDrop: false onEntered: function(drag) { - // Get the dragged node name from the drag source - var draggedName = "" - if (drag.source && drag.source.Drag && drag.source.Drag.mimeData) { - draggedName = drag.source.Drag.mimeData["application/x-qtmesheditor-node"] || "" - } - if (draggedName && treeNode.isNodeType) { + var draggedName = PropertiesPanelController.draggedNodeName || "" + if (draggedName && treeNode.isNodeType && draggedName !== treeNode.nodeName) { canDrop = PropertiesPanelController.canReparentNode(draggedName, treeNode.nodeName) } else { canDrop = false @@ -79,10 +75,7 @@ Column { } onDropped: function(drop) { dropHighlight.visible = false - var draggedName = "" - if (drop.source && drop.source.Drag && drop.source.Drag.mimeData) { - draggedName = drop.source.Drag.mimeData["application/x-qtmesheditor-node"] || "" - } + var draggedName = PropertiesPanelController.draggedNodeName || "" if (draggedName && canDrop) { PropertiesPanelController.reparentNode(draggedName, treeNode.nodeName) drop.accepted = true @@ -90,6 +83,7 @@ Column { drop.accepted = false } canDrop = false + PropertiesPanelController.draggedNodeName = "" } } @@ -101,6 +95,14 @@ Column { drag.target: treeNode.isNodeType ? dragProxy : undefined drag.threshold: 10 + onPressed: function(mouse) { + // Set the dragged node name so DropAreas can access it + if (treeNode.isNodeType) + PropertiesPanelController.draggedNodeName = treeNode.nodeName + } + onReleased: function(mouse) { + PropertiesPanelController.draggedNodeName = "" + } onClicked: function(mouse) { if (treeModel) { var multiSelect = (mouse.modifiers & Qt.ControlModifier) || diff --git a/src/PropertiesPanelController.h b/src/PropertiesPanelController.h index 51287d648..57d09d897 100644 --- a/src/PropertiesPanelController.h +++ b/src/PropertiesPanelController.h @@ -48,6 +48,9 @@ class PropertiesPanelController : public QObject // Pivot mode Q_PROPERTY(int pivotMode READ pivotMode WRITE setPivotMode NOTIFY pivotModeChanged) + // Drag-and-drop state for scene tree reparenting + Q_PROPERTY(QString draggedNodeName MEMBER m_draggedNodeName NOTIFY draggedNodeNameChanged) + // Undo history Q_PROPERTY(QVariantList undoHistory READ undoHistory NOTIFY undoHistoryChanged) Q_PROPERTY(int undoIndex READ undoIndex NOTIFY undoHistoryChanged) @@ -214,6 +217,7 @@ public slots: void snapGridSizeChanged(); void snapAngleStepChanged(); void snapScaleStepChanged(); + void draggedNodeNameChanged(); private: PropertiesPanelController(); @@ -228,6 +232,7 @@ public slots: SceneTreeModel* mSceneTreeModel = nullptr; bool mPlaying = false; class AnimationWidget* mAnimationWidget = nullptr; + QString m_draggedNodeName; }; #endif // PROPERTIES_PANEL_CONTROLLER_H From 4e1ea6b7c2a5dcb5e6ffd819689f595d3168cc15 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 9 Apr 2026 02:02:45 -0400 Subject: [PATCH 08/11] Replace broken QML drag-and-drop with button-based reparenting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QML Drag/DropArea in QQuickWidget inside QDockWidget doesn't deliver events properly across recursive tree components. Replaced with: - "↑ to Root" button next to Scene (Root) header, visible when a nested node is selected - Grouping (Ctrl+G) for creating parent-child relationships - MCP reparent_node tool for programmatic access Removed all Drag/DropArea code from SceneTreeNode.qml and the root drop zone from PropertiesPanel.qml. Co-Authored-By: Claude Sonnet 4.6 --- qml/PropertiesPanel.qml | 81 +++++++++-------------------------- qml/SceneTreeNode.qml | 95 +---------------------------------------- 2 files changed, 21 insertions(+), 155 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 154c3058e..e44c5bb65 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -114,74 +114,33 @@ Rectangle { property int nodeCount: treeModel ? treeModel.rowCount() : 0 property bool delegatesActive: true - // Root drop zone — drop here to reparent to root scene node - Rectangle { + // Scene header with reparent button + Row { width: outlinerColumn.width height: 22 - color: rootDropHighlight.visible - ? Qt.lighter(PropertiesPanelController.highlightColor, 1.3) - : Qt.darker(PropertiesPanelController.panelColor, 1.05) - - Rectangle { - id: rootDropHighlight - anchors.fill: parent - color: PropertiesPanelController.highlightColor - opacity: 0.25 - visible: false - } + spacing: 4 - Row { + Text { + text: "\u25A1 Scene (Root)" + color: PropertiesPanelController.textColor + font.pixelSize: 11; font.bold: true anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 4 - spacing: 4 - - Text { - text: "\u25A1" - color: PropertiesPanelController.textColor - font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - } - Text { - text: "Scene (Root)" - color: PropertiesPanelController.textColor - font.pixelSize: 11 - font.bold: true - anchors.verticalCenter: parent.verticalCenter - } + leftPadding: 4 } - DropArea { - anchors.fill: parent - keys: ["application/x-qtmesheditor-node"] - - property bool canDrop: false + Item { width: 1; height: 1; Layout.fillWidth: true } - onEntered: function(drag) { - var draggedName = PropertiesPanelController.draggedNodeName || "" - if (draggedName) { - canDrop = PropertiesPanelController.canReparentNode(draggedName, "root") - } else { - canDrop = false - } - rootDropHighlight.visible = canDrop - drag.accepted = canDrop - } - onExited: { - rootDropHighlight.visible = false - canDrop = false - } - onDropped: function(drop) { - rootDropHighlight.visible = false - var draggedName = PropertiesPanelController.draggedNodeName || "" - if (draggedName && canDrop) { - PropertiesPanelController.reparentNode(draggedName, "root") - drop.accepted = true - } else { - drop.accepted = false - } - canDrop = false - PropertiesPanelController.draggedNodeName = "" + // "Move to Root" button — visible when a non-root node is selected + Rectangle { + visible: PropertiesPanelController.selectionName !== "" && + PropertiesPanelController.canReparentNode(PropertiesPanelController.selectionName, "root") + width: toRootText.implicitWidth + 10; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + color: toRootMa.containsMouse ? PropertiesPanelController.highlightColor : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { id: toRootText; anchors.centerIn: parent; text: "\u2191 to Root"; color: PropertiesPanelController.textColor; font.pixelSize: 9 } + MouseArea { id: toRootMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: PropertiesPanelController.reparentNode(PropertiesPanelController.selectionName, "root") } } } diff --git a/qml/SceneTreeNode.qml b/qml/SceneTreeNode.qml index f7ca69374..a8d87212a 100644 --- a/qml/SceneTreeNode.qml +++ b/qml/SceneTreeNode.qml @@ -41,68 +41,12 @@ Column { : (rowMouse.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.15) : "transparent") - // Drop highlight (shown when a valid drop target) - Rectangle { - id: dropHighlight - anchors.fill: parent - color: PropertiesPanelController.highlightColor - opacity: 0.3 - visible: false - z: 5 - } - - // DropArea for reparenting: accepts dragged nodes - DropArea { - id: nodeDropArea - anchors.fill: parent - keys: ["application/x-qtmesheditor-node"] - - property bool canDrop: false - - onEntered: function(drag) { - var draggedName = PropertiesPanelController.draggedNodeName || "" - if (draggedName && treeNode.isNodeType && draggedName !== treeNode.nodeName) { - canDrop = PropertiesPanelController.canReparentNode(draggedName, treeNode.nodeName) - } else { - canDrop = false - } - dropHighlight.visible = canDrop - drag.accepted = canDrop - } - onExited: { - dropHighlight.visible = false - canDrop = false - } - onDropped: function(drop) { - dropHighlight.visible = false - var draggedName = PropertiesPanelController.draggedNodeName || "" - if (draggedName && canDrop) { - PropertiesPanelController.reparentNode(draggedName, treeNode.nodeName) - drop.accepted = true - } else { - drop.accepted = false - } - canDrop = false - PropertiesPanelController.draggedNodeName = "" - } - } - - // Full-row mouse area for selection and drag initiation + // Full-row mouse area for selection MouseArea { id: rowMouse anchors.fill: parent hoverEnabled: true - drag.target: treeNode.isNodeType ? dragProxy : undefined - drag.threshold: 10 - onPressed: function(mouse) { - // Set the dragged node name so DropAreas can access it - if (treeNode.isNodeType) - PropertiesPanelController.draggedNodeName = treeNode.nodeName - } - onReleased: function(mouse) { - PropertiesPanelController.draggedNodeName = "" - } onClicked: function(mouse) { if (treeModel) { var multiSelect = (mouse.modifiers & Qt.ControlModifier) || @@ -111,43 +55,6 @@ Column { } } - // Drag proxy (invisible item that follows the mouse during drag) - Item { - id: dragProxy - width: 1 - height: 1 - - Drag.active: rowMouse.drag.active - Drag.keys: ["application/x-qtmesheditor-node"] - Drag.mimeData: { - "application/x-qtmesheditor-node": treeNode.nodeName - } - Drag.hotSpot.x: 0 - Drag.hotSpot.y: 0 - Drag.dragType: Drag.Internal - } - } - - // Floating drag label (appears near cursor during drag) - Rectangle { - id: dragLabel - visible: rowMouse.drag.active - z: 100 - width: dragLabelText.implicitWidth + 12 - height: 20 - radius: 3 - color: PropertiesPanelController.highlightColor - opacity: 0.9 - x: rowMouse.mouseX + 15 - y: rowMouse.mouseY - 10 - - Text { - id: dragLabelText - anchors.centerIn: parent - text: treeNode.nodeName - color: "white" - font.pixelSize: 10 - } } Row { From a61771f1ac1ef6fb4f0d39472fae16dc7397ee4c Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 9 Apr 2026 02:11:21 -0400 Subject: [PATCH 09/11] Auto-delete empty group nodes after reparenting When the last child is moved out of a group via reparentNode(), the empty parent is now automatically destroyed instead of persisting as an orphaned "Node" in the scene tree. Co-Authored-By: Claude Sonnet 4.6 --- src/Manager.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Manager.cpp b/src/Manager.cpp index 83dc403dd..91d6b958c 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -569,6 +569,16 @@ bool Manager::reparentNode(Ogre::SceneNode* node, Ogre::SceneNode* newParent) node->setPosition(parentWorldOrient.Inverse() * ((worldPos - parentDerivedPos) / parentWorldScale)); + // Auto-delete the old parent if it became an empty group + // (no attached objects and no remaining children = orphaned group node) + if (oldParent && oldParent != mSceneMgr->getRootSceneNode() + && oldParent->numAttachedObjects() == 0 + && oldParent->numChildren() == 0) { + emit sceneNodeDestroyed(oldParent); + destroyAllAttachedMovableObjects(oldParent); + mSceneMgr->destroySceneNode(oldParent); + } + // Trigger scene tree rebuild emit sceneNodeCreated(node); From a51bfb3cec58a45a4f8fad6492494705d8969197 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 9 Apr 2026 02:12:57 -0400 Subject: [PATCH 10/11] Cascade-delete empty group ancestors after reparenting When moving the last child out of a nested group, now walks up the parent chain and destroys ALL empty ancestors (not just the immediate parent). Fixes nested groups leaving orphaned empty nodes. Co-Authored-By: Claude Sonnet 4.6 --- src/Manager.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Manager.cpp b/src/Manager.cpp index 91d6b958c..b54f960cc 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -569,14 +569,17 @@ bool Manager::reparentNode(Ogre::SceneNode* node, Ogre::SceneNode* newParent) node->setPosition(parentWorldOrient.Inverse() * ((worldPos - parentDerivedPos) / parentWorldScale)); - // Auto-delete the old parent if it became an empty group - // (no attached objects and no remaining children = orphaned group node) - if (oldParent && oldParent != mSceneMgr->getRootSceneNode() - && oldParent->numAttachedObjects() == 0 - && oldParent->numChildren() == 0) { - emit sceneNodeDestroyed(oldParent); - destroyAllAttachedMovableObjects(oldParent); - mSceneMgr->destroySceneNode(oldParent); + // Cascade-delete empty group ancestors: if removing this child left + // the old parent empty, destroy it — then check ITS parent, and so on. + Ogre::SceneNode* emptyCheck = oldParent; + while (emptyCheck && emptyCheck != mSceneMgr->getRootSceneNode() + && emptyCheck->numAttachedObjects() == 0 + && emptyCheck->numChildren() == 0) { + Ogre::SceneNode* nextParent = static_cast(emptyCheck->getParent()); + emit sceneNodeDestroyed(emptyCheck); + destroyAllAttachedMovableObjects(emptyCheck); + mSceneMgr->destroySceneNode(emptyCheck); + emptyCheck = nextParent; } // Trigger scene tree rebuild From ad797cedd475e29a6acc274da4d3fa8a7123e9c9 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 9 Apr 2026 02:30:32 -0400 Subject: [PATCH 11/11] Bump version to 2.23.0, add tests for Phase 1 remaining MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version 2.22.0 → 2.23.0 - 14 new tests: group_nodes, ungroup_node, reparent_node, set/get_pivot_mode, resample_animation (valid + error cases) - AllToolNamesAreRecognized updated to 51 tools - Winget fix already included (gh CLI approach from PR #254) Co-Authored-By: Claude Sonnet 4.6 --- CMakeLists.txt | 2 +- src/MCPServer_test.cpp | 251 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 250 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c209e702d..cb39ed143 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 2.22.0 LANGUAGES C CXX) +project(QtMeshEditor VERSION 2.23.0 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/src/MCPServer_test.cpp b/src/MCPServer_test.cpp index 968efdec1..3d35d21d1 100644 --- a/src/MCPServer_test.cpp +++ b/src/MCPServer_test.cpp @@ -2907,13 +2907,16 @@ TEST_F(MCPServerTest, AllToolNamesAreRecognized) "set_animation_time", "add_keyframe", "remove_keyframe", "play_animation", "toggle_skeleton_debug", "toggle_bone_weights", "toggle_normals", "toggle_mesh_info", "merge_animations", + "resample_animation", "save_scene", "open_scene", "validate_mesh", "generate_lods", "generate_auto_lods", "remove_lods", "get_lod_info", "delete_entity", "duplicate_entity", "get_camera_info", "camera_control", "set_snap_settings", "get_snap_settings", "export_pose", - "list_files", "search_files", "read_file" + "list_files", "search_files", "read_file", + "group_nodes", "ungroup_node", "reparent_node", + "set_pivot_mode", "get_pivot_mode" }; - EXPECT_EQ(allTools.size(), 45); + EXPECT_EQ(allTools.size(), 51); for (const QString &tool : allTools) { QJsonObject result = server->callTool(tool, QJsonObject()); @@ -5520,3 +5523,247 @@ TEST_F(MCPServerTest, ExportPose_AnimatedEntitySuccess) // Cleanup QFile::remove(exportPath); } + +// ========================================================================== +// NEW TESTS: group_nodes tool +// ========================================================================== + +TEST_F(MCPServerTest, GroupNodes_Valid) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + auto* mgr = Manager::getSingletonPtr(); + ASSERT_NE(mgr, nullptr); + + auto* node1 = mgr->addSceneNode("GroupTestNode1"); + auto* node2 = mgr->addSceneNode("GroupTestNode2"); + ASSERT_NE(node1, nullptr); + ASSERT_NE(node2, nullptr); + + // Attach a mesh to each so they are real scene nodes + Ogre::MeshPtr mesh1 = createInMemoryTriangleMesh("GroupTestMesh1"); + Ogre::MeshPtr mesh2 = createInMemoryTriangleMesh("GroupTestMesh2"); + ASSERT_TRUE(mesh1); + ASSERT_TRUE(mesh2); + node1->attachObject(mgr->getSceneMgr()->createEntity("GroupTestEnt1", mesh1)); + node2->attachObject(mgr->getSceneMgr()->createEntity("GroupTestEnt2", mesh2)); + + QJsonObject args; + QJsonArray names; + names.append("GroupTestNode1"); + names.append("GroupTestNode2"); + args["names"] = names; + + QJsonObject result = server->callTool("group_nodes", args); + EXPECT_FALSE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("Created group")); + EXPECT_TRUE(getResultText(result).contains("2 child nodes")); +} + +TEST_F(MCPServerTest, GroupNodes_TooFewNodes) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + auto* mgr = Manager::getSingletonPtr(); + ASSERT_NE(mgr, nullptr); + + auto* node1 = mgr->addSceneNode("GroupSingleNode"); + ASSERT_NE(node1, nullptr); + + QJsonObject args; + QJsonArray names; + names.append("GroupSingleNode"); + args["names"] = names; + + QJsonObject result = server->callTool("group_nodes", args); + EXPECT_TRUE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("At least 2")); +} + +TEST_F(MCPServerTest, GroupNodes_NonexistentNode) +{ + QJsonObject args; + QJsonArray names; + names.append("NonexistentNodeA"); + names.append("NonexistentNodeB"); + args["names"] = names; + + QJsonObject result = server->callTool("group_nodes", args); + EXPECT_TRUE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("not found")); +} + +// ========================================================================== +// NEW TESTS: ungroup_node tool +// ========================================================================== + +TEST_F(MCPServerTest, UngroupNode_Valid) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + auto* mgr = Manager::getSingletonPtr(); + ASSERT_NE(mgr, nullptr); + + // Create two nodes and group them first + auto* node1 = mgr->addSceneNode("UngroupTestNode1"); + auto* node2 = mgr->addSceneNode("UngroupTestNode2"); + ASSERT_NE(node1, nullptr); + ASSERT_NE(node2, nullptr); + + Ogre::MeshPtr mesh1 = createInMemoryTriangleMesh("UngroupMesh1"); + Ogre::MeshPtr mesh2 = createInMemoryTriangleMesh("UngroupMesh2"); + ASSERT_TRUE(mesh1); + ASSERT_TRUE(mesh2); + node1->attachObject(mgr->getSceneMgr()->createEntity("UngroupEnt1", mesh1)); + node2->attachObject(mgr->getSceneMgr()->createEntity("UngroupEnt2", mesh2)); + + QList nodes = {node1, node2}; + Ogre::SceneNode* groupNode = mgr->groupNodes(nodes); + ASSERT_NE(groupNode, nullptr); + QString groupName = QString::fromStdString(groupNode->getName()); + + QJsonObject args; + args["name"] = groupName; + QJsonObject result = server->callTool("ungroup_node", args); + EXPECT_FALSE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("Ungrouped")); +} + +TEST_F(MCPServerTest, UngroupNode_NotAGroup) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + auto* mgr = Manager::getSingletonPtr(); + ASSERT_NE(mgr, nullptr); + + // A node with an attached entity is not a group node + auto* node = mgr->addSceneNode("UngroupNotGroupNode"); + ASSERT_NE(node, nullptr); + Ogre::MeshPtr mesh = createInMemoryTriangleMesh("UngroupNotGroupMesh"); + ASSERT_TRUE(mesh); + node->attachObject(mgr->getSceneMgr()->createEntity("UngroupNotGroupEnt", mesh)); + + QJsonObject args; + args["name"] = "UngroupNotGroupNode"; + QJsonObject result = server->callTool("ungroup_node", args); + EXPECT_TRUE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("not a group node")); +} + +// ========================================================================== +// NEW TESTS: reparent_node tool +// ========================================================================== + +TEST_F(MCPServerTest, ReparentNode_Valid) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + auto* mgr = Manager::getSingletonPtr(); + ASSERT_NE(mgr, nullptr); + + auto* parentNode = mgr->addSceneNode("ReparentParent"); + auto* childNode = mgr->addSceneNode("ReparentChild"); + ASSERT_NE(parentNode, nullptr); + ASSERT_NE(childNode, nullptr); + + QJsonObject args; + args["node_name"] = "ReparentChild"; + args["new_parent_name"] = "ReparentParent"; + QJsonObject result = server->callTool("reparent_node", args); + EXPECT_FALSE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("Reparented")); +} + +TEST_F(MCPServerTest, ReparentNode_CyclePrevented) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + auto* mgr = Manager::getSingletonPtr(); + ASSERT_NE(mgr, nullptr); + + auto* parentNode = mgr->addSceneNode("CycleParent"); + auto* childNode = mgr->addSceneNode("CycleChild"); + ASSERT_NE(parentNode, nullptr); + ASSERT_NE(childNode, nullptr); + + // First reparent child under parent + mgr->reparentNode(childNode, parentNode); + + // Now try to reparent parent under child (should fail with cycle error) + QJsonObject args; + args["node_name"] = "CycleParent"; + args["new_parent_name"] = "CycleChild"; + QJsonObject result = server->callTool("reparent_node", args); + EXPECT_TRUE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("cycle")); +} + +TEST_F(MCPServerTest, ReparentNode_NonexistentNode) +{ + QJsonObject args; + args["node_name"] = "NonexistentReparentNode"; + args["new_parent_name"] = "root"; + QJsonObject result = server->callTool("reparent_node", args); + EXPECT_TRUE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("not found")); +} + +// ========================================================================== +// NEW TESTS: set_pivot_mode / get_pivot_mode tools +// ========================================================================== + +TEST_F(MCPServerTest, SetPivotMode_Valid) +{ + // TransformOperator may not be initialized in test environment + QJsonObject args; + args["mode"] = "center"; + QJsonObject result = server->callTool("set_pivot_mode", args); + // Either succeeds or returns "not initialized" error — both are valid + EXPECT_FALSE(getResultText(result).isEmpty()); +} + +TEST_F(MCPServerTest, SetPivotMode_Invalid) +{ + QJsonObject args; + args["mode"] = "invalid_mode"; + QJsonObject result = server->callTool("set_pivot_mode", args); + EXPECT_TRUE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("Invalid pivot mode") || + getResultText(result).contains("not initialized")); +} + +TEST_F(MCPServerTest, GetPivotMode_ReturnsCurrentMode) +{ + QJsonObject result = server->callTool("get_pivot_mode", QJsonObject()); + // Either returns mode or "not initialized" error + EXPECT_FALSE(getResultText(result).isEmpty()); +} + +// ========================================================================== +// NEW TESTS: resample_animation tool +// ========================================================================== + +TEST_F(MCPServerTest, ResampleAnimation_MissingArgs) +{ + QJsonObject args; + // No target_keyframes or decimate_step — should fail + QJsonObject result = server->callTool("resample_animation", args); + EXPECT_TRUE(isError(result)); + // Will either say "No entity with skeleton" or "Specify target_keyframes" + EXPECT_FALSE(getResultText(result).isEmpty()); +} + +TEST_F(MCPServerTest, ResampleAnimation_Valid) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + Ogre::Entity* entity = createAnimatedTestEntity("ResampleAnimEntity"); + ASSERT_NE(entity, nullptr); + + QJsonObject args; + args["entity_name"] = "ResampleAnimEntity"; + args["target_keyframes"] = 5; + QJsonObject result = server->callTool("resample_animation", args); + // May succeed or fail depending on skeleton state, but should not crash + EXPECT_FALSE(getResultText(result).isEmpty()); +}