Skip to content

Phase 1: Scene Editing Power Tools (#256) - #262

Merged
fernandotonon merged 23 commits into
masterfrom
feat/phase1-scene-editing-tools
Apr 9, 2026
Merged

Phase 1: Scene Editing Power Tools (#256)#262
fernandotonon merged 23 commits into
masterfrom
feat/phase1-scene-editing-tools

Conversation

@fernandotonon

@fernandotonon fernandotonon commented Apr 8, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 1 of Scene Editing Power Tools (#256).

Completed

  • Duplicate Objects (Ctrl+D) — deep-clone with independent mesh/skeleton/animations. Full undo/redo. MCP tool.
  • Transform Snapping — grid/angle/scale with Ctrl-hold or persistent toggle. QML Inspector UI. MCP tools.
  • Sub-Mesh Selection and Transform — select sub-entities, transform vertices directly. Real-time viewport update. Undo/redo. MCP tool.
  • Export Current Pose — bake animation frame to static mesh (STL/OBJ/glTF/FBX). GUI + CLI + MCP.
  • Sentry breadcrumbs, keyboard shortcut tracking, version 2.22.0, MCP SERVER_VERSION 1.3.0

Test plan

  • Ctrl+D duplicates animated model with independent animations
  • Undo/redo cycle works correctly
  • Snap presets highlight correctly in dark mode
  • Sub-mesh transform updates viewport in real-time
  • Export Pose produces static mesh matching current pose

@coderabbitai

coderabbitai Bot commented Apr 8, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds scene-node duplication with deep-copy semantics and undo/redo; persistent snapping (operator, controller, UI, presets, accumulators, tests); a CLI/MCP “pose” export flow and MeshImporterExporter::exportCurrentPose; new MCP tools for duplication, snap settings, and pose export; CLI and docker entrypoint accept pose; CLAUDE.md documents Sentry breadcrumb rules and version-format guidance.

Changes

Cohort / File(s) Summary
Core duplication
src/Manager.h, src/Manager.cpp
Added Manager::duplicateSceneNode() to deep-duplicate SceneNode contents (entities, meshes, skeletons, materials) with unique naming and resource recreation.
Undo/Redo commands & tests
src/commands/TransformCommands.h, src/commands/TransformCommands.cpp, src/commands/TransformCommands_test.cpp
Added DuplicateCommand (mFirstRedo semantics), undo/redo logic, and unit tests covering visibility, entity-backed cases, and empty-clone handling.
UI integration
src/mainwindow.h, src/mainwindow.cpp, ui_files/mainwindow.ui
Added duplicateSelected() slot, wired actionDuplicate (Ctrl+D), pushes DuplicateCommand, updates selection to clones, and emits Sentry breadcrumbs for duplicate/transform actions.
Snap feature: operator, controller, UI, tests
src/TransformOperator.h, src/TransformOperator.cpp, src/TransformOperator_test.cpp, src/PropertiesPanelController.h, src/PropertiesPanelController.cpp, qml/PropertiesPanel.qml
Persisted snap settings via QSettings, added snap accumulators/helpers, public snap API and presets, PropertiesPanel snap UI and Export Pose button, controller bindings, and tests for snapping utilities and validation/signals.
Mesh pose export
src/MeshImporterExporter.h, src/MeshImporterExporter.cpp
Added exportCurrentPose(entity, outputPath, format) to capture software-skinned deformed vertex data into a skeleton-less static mesh and export via Assimp; returns status codes and exports materials.
CLI / headless pipeline
src/CLIPipeline.h, src/CLIPipeline.cpp, src/main.cpp, docker-entrypoint.sh
Added pose subcommand (CLIPipeline::cmdPose) supporting single/multi-frame exports, time/count parsing, output filename patterns, Ogre headless init, animation evaluation, Sentry breadcrumbs, and distinct exit codes; CLI detection and docker entrypoint updated.
MCP / AI tools
src/AIChatManager.cpp, src/MCPServer.h, src/MCPServer.cpp
Exposed duplicate_entity to AI tool prompt; added MCP handlers toolDuplicateEntity, toolSetSnapSettings, toolGetSnapSettings, toolExportPose, registered tool schemas, and marked export_pose as heavy.
Properties panel pose export wiring
src/PropertiesPanelController.cpp, src/PropertiesPanelController.h, qml/PropertiesPanel.qml
Added exportCurrentPose() controller API and QML Export Pose button (visible when entity has a skeleton) to trigger pose export.
Docs / guidelines
CLAUDE.md
Documented Sentry breadcrumb requirement for user-facing actions/tool/file I/O and enforced version string rule (plain X.Y.Z without leading v).

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant UI as User/UI
    participant MW as MainWindow
    participant MGR as Manager
    participant Ogre as Ogre Engine
    participant Undo as UndoManager

    UI->>MW: Trigger Duplicate (Ctrl+D / menu)
    MW->>MW: duplicateSelected()
    MW->>MGR: duplicateSceneNode(sourceNode)
    MGR->>Ogre: Create SceneNode, Clone Entity/Mesh/Skeleton
    Ogre-->>MGR: Cloned resources
    MGR-->>MW: ClonedNode(s)
    MW->>Undo: Push DuplicateCommand(sources, clones)
    MW->>MW: Clear selection and select clones
    Note right of MW: SentryReporter::addBreadcrumb(rgba(0,128,0,0.5),"Duplicate selected objects")
Loading
sequenceDiagram
    autonumber
    participant CLI as CLI / CLIPipeline
    participant Ogre as Ogre headless
    participant Entity as Ogre::Entity
    participant MIE as MeshImporterExporter
    participant FS as Filesystem

    CLI->>Ogre: Initialize headless runtime
    CLI->>Ogre: Import model file
    Ogre-->>CLI: Scene / Entity list
    CLI->>Entity: Select entity + enable animation / set time
    CLI->>MIE: exportCurrentPose(Entity, outputPath, format)
    MIE->>Entity: Request software skinning, evaluate animation
    MIE->>MIE: Build aiScene with deformed vertices (no bones/animations)
    MIE->>FS: Write exported file via Assimp
    FS-->>MIE: Success/Failure
    MIE-->>CLI: Return status
    Note right of CLI: SentryReporter::addBreadcrumb(rgba(128,0,128,0.5),"pose export failure") / addMessage on errors
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

Poem

🐇 I hop and copy, stitch and bind,

Clones appear with Ctrl+D in mind.
Snaps click true on grid and spin,
Posed frames saved with a happy grin.
Breadcrumbs mark the trail I find.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main change: Phase 1 implementation of the Scene Editing Power Tools epic with completed duplicate objects feature and foundational editing operations.
Description check ✅ Passed The PR description includes both a clear summary and detailed sections for completed features, test plan, and related changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/phase1-scene-editing-tools

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 96468334d1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Manager.cpp
Comment on lines +268 to +273
Ogre::SceneNode* newNode = addSceneNode(baseName);

// Copy transform
newNode->setPosition(source->getPosition());
newNode->setOrientation(source->getOrientation());
newNode->setScale(source->getScale());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve node user bindings when duplicating primitives

The duplicate path creates a fresh scene node and copies only transform/entity data, but it never copies source->getUserObjectBindings(). Primitive support relies on that binding (PrimitiveObject::isPrimitive), so duplicated primitive nodes are no longer recognized as primitives and lose primitive-specific editing paths (for example the Properties panel primitive controls). This is a functional regression for the stated primitive duplication scenario.

Useful? React with 👍 / 👎.

Comment thread src/Manager.cpp Outdated
Comment on lines +286 to +290
QString cloneMeshName = QString::fromStdString(newNode->getName()) + "_mesh";

// Remove stale resources from a previous undo cycle (redo re-creates them)
if (Ogre::MeshManager::getSingleton().getByName(cloneMeshName.toStdString()))
Ogre::MeshManager::getSingleton().remove(cloneMeshName.toStdString());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Generate per-entity clone names inside duplicate loop

Inside the attached-object loop, cloneMeshName is derived only from newNode->getName(), so every attached entity on that node reuses the same mesh/skeleton resource names. On the second entity, the code can remove the first clone as “stale” and then create another entity on the same node name path, which can throw Ogre duplicate-name exceptions or leave broken clones when a source node has multiple attached entities.

Useful? React with 👍 / 👎.

Comment thread src/commands/TransformCommands_test.cpp Outdated
Comment on lines +487 to +488
cmd->undo();
EXPECT_FALSE(entity->getVisible());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop dereferencing destroyed entity in duplicate undo test

This test invokes cmd->undo() and then reads entity->getVisible(), but DuplicateCommand::undo() destroys the nodes it tracks via Manager::destroySceneNode. In this test setup, the tracked node is the same one that owns entity, so the assertion dereferences freed state and can crash or flap instead of verifying behavior.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/commands/TransformCommands_test.cpp (1)

440-450: Verify the DuplicateCommand constructor usage.

The test passes the same list (clones) for both constructor parameters. Based on the AI summary, DuplicateCommand takes (clones, sources) where sources are the original nodes. Passing the same node as both clone and source may work for basic visibility testing but doesn't accurately represent the real use case where clones and sources are distinct nodes.

Consider creating separate source and clone nodes to better reflect actual usage:

 TEST_F(TransformCommandsTests, DuplicateCommand_Constructor) {
     Manager* mgr = Manager::getSingleton();
-    Ogre::SceneNode* node = mgr->addSceneNode("DupCmdNode1");
-    ASSERT_NE(node, nullptr);
+    Ogre::SceneNode* source = mgr->addSceneNode("DupCmdSource1");
+    Ogre::SceneNode* clone = mgr->addSceneNode("DupCmdClone1");
+    ASSERT_NE(source, nullptr);
+    ASSERT_NE(clone, nullptr);
 
-    QList<Ogre::SceneNode*> clones = {node};
-    auto* cmd = new DuplicateCommand(clones, clones);
+    QList<Ogre::SceneNode*> clones = {clone};
+    QList<Ogre::SceneNode*> sources = {source};
+    auto* cmd = new DuplicateCommand(clones, sources);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/TransformCommands_test.cpp` around lines 440 - 450, The test
uses the same QList for both parameters when constructing DuplicateCommand,
which misrepresents real usage where clones and sources are distinct; update the
test to create two separate SceneNode instances via
Manager::getSingleton()->addSceneNode (e.g., one for the source and one for the
clone), build two distinct QList<Ogre::SceneNode*> (sources and clones) and pass
them to the DuplicateCommand constructor, ensuring the constructor call and
expectations use the separate lists (referencing DuplicateCommand,
Manager::getSingleton, and addSceneNode).
src/Manager.cpp (2)

262-268: Consider adding a Sentry breadcrumb for duplication.

Per the coding guidelines in CLAUDE.md, user-facing actions should be tracked with SentryReporter::addBreadcrumb. The duplication operation is a significant user action invoked via Ctrl+D and MCP.

If the callers (MainWindow::duplicateSelected and toolDuplicateEntity) already add breadcrumbs, this can be skipped. Otherwise, consider adding one here:

 Ogre::SceneNode* Manager::duplicateSceneNode(Ogre::SceneNode* source)
 {
     if (!source || !mSceneMgr) return nullptr;
+    SentryReporter::addBreadcrumb("scene", "Duplicate scene node");
 
     // Generate a unique name based on the source

As per coding guidelines: "All user-facing actions and significant operations must be tracked with SentryReporter::addBreadcrumb".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Manager.cpp` around lines 262 - 268, The duplicateSceneNode function
lacks a Sentry breadcrumb for this user-facing action; add a call to
SentryReporter::addBreadcrumb inside Manager::duplicateSceneNode (e.g., before
or after creating newNode) to record the duplication event with a short message
and relevant metadata (source node name, new node name or id). If
MainWindow::duplicateSelected or toolDuplicateEntity already add breadcrumbs,
skip adding here; otherwise insert SentryReporter::addBreadcrumb(...) into
Manager::duplicateSceneNode to satisfy the CLAUDE.md guideline for tracking user
actions.

342-358: Silent exception handling may hide issues.

Line 349 silently catches exceptions when associating bones with tracks. While defensive coding is appropriate here, a warning log would help diagnose skeleton-related issues during duplication.

🛡️ Suggested improvement
                         try {
                             if (srcTrack->getAssociatedNode()) {
                                 Ogre::Bone* dstBone = clonedSkel->getBone(handle);
                                 if (dstBone) dstTrack->setAssociatedNode(dstBone);
                             }
-                        } catch (...) { /* handle not found — skip association */ }
+                        } catch (const Ogre::Exception& e) {
+                            qWarning("Bone association skipped for handle %u: %s",
+                                     handle, e.getFullDescription().c_str());
+                        } catch (...) { /* unexpected error — skip association */ }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Manager.cpp` around lines 342 - 358, The catch-all in the loop over
srcAnim->_getNodeTrackList() silently swallows errors when trying to associate
bones (clonedSkel->getBone and dstTrack->setAssociatedNode), which hides
skeleton issues; update the catch to log a warning via the project's logger
(include context: handle, srcAnim/dstAnim identifiers if available, and the
exception message) so failures during dstAnim->createNodeTrack/association are
visible for debugging while still allowing the loop to continue. Ensure you
reference the same scope (the try around clonedSkel->getBone /
dstTrack->setAssociatedNode) and avoid changing control flow — only replace the
empty catch with a safe logging statement that includes the handle and error
details.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/TransformCommands.cpp`:
- Around line 180-185: The redo path is creating incomplete clones because
Manager::duplicateSceneNode does not duplicate child nodes; update the logic in
the loop in TransformCommands.cpp (the for over mSourceNodeNames which calls
Manager::getSingleton()->duplicateSceneNode) to duplicate the entire subtree
instead of only the single node: either extend Manager::duplicateSceneNode to
recursively clone child SceneNodes (preserving hierarchy and attachments) or
implement a helper that traverses src (obtained via getSceneMgr()->getSceneNode)
and for each node creates a new node, re-parents children under the
corresponding cloned parent, and then append the top-level clone to
mClonedNodes; ensure the new code preserves transforms and any attached objects
so undo/redo keeps the full subtree intact.

In `@src/MCPServer.cpp`:
- Around line 2414-2438: The MCPServer::toolDuplicateEntity implementation
currently calls Manager::getSingleton()->duplicateSceneNode(sourceNode) directly
which bypasses undo/redo; instead construct and execute/push a DuplicateCommand
for the source node so the operation is recorded. Replace the direct duplicate
call with creating a DuplicateCommand (e.g. new DuplicateCommand(sourceNode))
and run it through the app's command/undo facility (the CommandManager/UndoStack
execute/push API used elsewhere) and then use the resulting cloned node from the
command to build the success result; keep SelectionSet usage for the selection
path unchanged.

---

Nitpick comments:
In `@src/commands/TransformCommands_test.cpp`:
- Around line 440-450: The test uses the same QList for both parameters when
constructing DuplicateCommand, which misrepresents real usage where clones and
sources are distinct; update the test to create two separate SceneNode instances
via Manager::getSingleton()->addSceneNode (e.g., one for the source and one for
the clone), build two distinct QList<Ogre::SceneNode*> (sources and clones) and
pass them to the DuplicateCommand constructor, ensuring the constructor call and
expectations use the separate lists (referencing DuplicateCommand,
Manager::getSingleton, and addSceneNode).

In `@src/Manager.cpp`:
- Around line 262-268: The duplicateSceneNode function lacks a Sentry breadcrumb
for this user-facing action; add a call to SentryReporter::addBreadcrumb inside
Manager::duplicateSceneNode (e.g., before or after creating newNode) to record
the duplication event with a short message and relevant metadata (source node
name, new node name or id). If MainWindow::duplicateSelected or
toolDuplicateEntity already add breadcrumbs, skip adding here; otherwise insert
SentryReporter::addBreadcrumb(...) into Manager::duplicateSceneNode to satisfy
the CLAUDE.md guideline for tracking user actions.
- Around line 342-358: The catch-all in the loop over
srcAnim->_getNodeTrackList() silently swallows errors when trying to associate
bones (clonedSkel->getBone and dstTrack->setAssociatedNode), which hides
skeleton issues; update the catch to log a warning via the project's logger
(include context: handle, srcAnim/dstAnim identifiers if available, and the
exception message) so failures during dstAnim->createNodeTrack/association are
visible for debugging while still allowing the loop to continue. Ensure you
reference the same scope (the try around clonedSkel->getBone /
dstTrack->setAssociatedNode) and avoid changing control flow — only replace the
empty catch with a safe logging statement that includes the handle and error
details.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3eafbf48-e210-4613-959a-de9f610ebf48

📥 Commits

Reviewing files that changed from the base of the PR and between 650ad86 and 9646833.

📒 Files selected for processing (12)
  • CLAUDE.md
  • src/AIChatManager.cpp
  • src/MCPServer.cpp
  • src/MCPServer.h
  • src/Manager.cpp
  • src/Manager.h
  • src/commands/TransformCommands.cpp
  • src/commands/TransformCommands.h
  • src/commands/TransformCommands_test.cpp
  • src/mainwindow.cpp
  • src/mainwindow.h
  • ui_files/mainwindow.ui

Comment on lines +180 to +185
for (const QString& name : mSourceNodeNames) {
if (!Manager::getSingleton()->hasSceneNode(name)) continue;
Ogre::SceneNode* src = Manager::getSingleton()->getSceneMgr()
->getSceneNode(name.toStdString());
Ogre::SceneNode* clone = Manager::getSingleton()->duplicateSceneNode(src);
if (clone) mClonedNodes.append(clone);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Redo can recreate incomplete clones for hierarchical nodes.

Line 184 relies on Manager::duplicateSceneNode, and that implementation currently does not recurse child scene nodes. Duplicating a parent node and then undo/redo can drop its subtree structure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/TransformCommands.cpp` around lines 180 - 185, The redo path is
creating incomplete clones because Manager::duplicateSceneNode does not
duplicate child nodes; update the logic in the loop in TransformCommands.cpp
(the for over mSourceNodeNames which calls
Manager::getSingleton()->duplicateSceneNode) to duplicate the entire subtree
instead of only the single node: either extend Manager::duplicateSceneNode to
recursively clone child SceneNodes (preserving hierarchy and attachments) or
implement a helper that traverses src (obtained via getSceneMgr()->getSceneNode)
and for each node creates a new node, re-parents children under the
corresponding cloned parent, and then append the top-level clone to
mClonedNodes; ensure the new code preserves transforms and any attached objects
so undo/redo keeps the full subtree intact.

Comment thread src/MCPServer.cpp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

♻️ Duplicate comments (1)
src/MCPServer.cpp (1)

2418-2442: ⚠️ Potential issue | 🟠 Major

duplicate_entity still bypasses undo/redo stack.

Line 2435 duplicates directly and returns success, but never records a DuplicateCommand, so MCP duplication cannot be undone/redone like UI duplication.

Proposed fix
+#include "UndoManager.h"
+#include "commands/TransformCommands.h"
@@
-    Ogre::SceneNode* clone = Manager::getSingleton()->duplicateSceneNode(sourceNode);
+    Ogre::SceneNode* clone = Manager::getSingleton()->duplicateSceneNode(sourceNode);
     if (!clone)
         return makeErrorResult("Error: Failed to duplicate node.");
+
+    // Keep MCP duplication behavior consistent with UI duplication.
+    QList<Ogre::SceneNode*> sources{sourceNode};
+    QList<Ogre::SceneNode*> clones{clone};
+    UndoManager::getSingleton()->push(new DuplicateCommand(sources, clones));
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/MCPServer.cpp`:
- Around line 482-491: The MCP tool dispatches for toolDuplicateEntity,
toolCameraControl, toolGetCameraInfo, toolSetSnapSettings, and
toolGetSnapSettings currently log via callTool() using category "mcp.tool";
update the invocation logging to use the required breadcrumb category by calling
SentryReporter::addBreadcrumb(...) with category "ai.tool_call" (or adjust
callTool to emit that breadcrumb) whenever these tools are invoked so all MCP
tool calls are tracked as ai.tool_call; ensure the breadcrumb includes the tool
name and relevant args/context consistent with other tool invocations.
- Around line 2520-2562: The handler MCPServer::toolSetSnapSettings mutates
TransformOperator via setSnapEnabled, setSnapGridSize, setSnapAngleStep and
setSnapScaleStep as it validates each field, causing partial commits on later
validation failure; make the operation atomic by first validating and collecting
all new values (e.g., optional<bool> newEnabled, optional<double>
newGridSize/angleStep/scaleStep) without calling TransformOperator, return
makeErrorResult on any invalid input, then—only after all inputs are valid—call
top->setSnapEnabled/setSnapGridSize/setSnapAngleStep/setSnapScaleStep and build
the changes list, finally returning makeSuccessResult. Ensure you still handle
the case of no fields provided by returning the same makeErrorResult message.

In `@src/MCPServer.h`:
- Around line 165-167: The MCP surface was expanded with new handlers
(toolDuplicateEntity, toolSetSnapSettings, toolGetSnapSettings) but
SERVER_VERSION in MCPServer.h wasn’t updated; update the SERVER_VERSION constant
in MCPServer.h to a new patch/minor version (e.g., 1.2.0 or appropriate per your
versioning policy) so clients can detect the new MCP capabilities, and ensure
any accompanying version-string references or build metadata that rely on
SERVER_VERSION are updated consistently.

In `@src/TransformOperator_test.cpp`:
- Around line 600-642: The tests mutate persistent QSettings via
TransformOperator's setters (setSnapEnabled, setSnapGridSize, setSnapAngleStep,
setSnapScaleStep) and then assert signal counts for snapSettingsChanged, so make
the tests deterministic by isolating QSettings: in the test fixture
(TransformOperatorTests) before constructing the TransformOperator instance,
either redirect QSettings to a temporary INI file or use
QSettings::beginGroup/clear/endGroup to back up and clear the "Snap" group (or
restore it after tests); ensure the fixture restores the original settings or
removes the temporary store in teardown so subsequent runs start from a clean
Snap state and the signal-count assertions remain stable.

In `@src/TransformOperator.cpp`:
- Around line 678-684: Snapping is being applied after converting local deltas
back to world space, so in SPACE_LOCAL mode quantization uses world axes;
instead, when mode == SPACE_LOCAL use the already computed localDelta / localRot
and accumulate and snap in gizmo-local coordinates (use mSnapTranslationAccum
and mSnapRotationAccum but store and quantize the local-space values via
snapTranslation/snapRotation with mSnapGridSize), then transform the snapped
local result back to world before applying to the selection; update both the
translation path (where mSnapTranslationAccum and snapTranslation are used) and
the rotation path (where localRot is handled around the same region) to follow
this local-space accumulate -> snap -> world-transform flow so local
moves/rotations preserve the local step size.
- Around line 845-856: mSnapScaleAccum currently yields per-step deltas which
are being applied as multiplicative increments via scaleSelected(snappedFactor),
causing compounded or stalled scaling; change the logic to track a cumulative
snapped factor since drag start (e.g., a new member like mSnapScaleAppliedTotal
initialized to Ogre::Vector3::UNIT_SCALE at drag start), compute the new snapped
total from mSnapScaleAccum and mSnapScaleStep (using snapScale), derive the
deltaFactor = newSnappedTotal / mSnapScaleAppliedTotal, call
scaleSelected(deltaFactor) (not the absolute snappedFactor), then set
mSnapScaleAppliedTotal = newSnappedTotal and only reset mScaleStartDistance when
snappedDelta is zero as before; update uses of mSnapScaleAccum, snapScale(),
snappedDelta, snappedFactor and scaleSelected() accordingly.
- Around line 87-92: Loaded snap steps (mSnapGridSize, mSnapAngleStep,
mSnapScaleStep) are used without validation and can be zero/negative/inf; clamp
each after reading from QSettings to a safe positive range (e.g., min > 0 and
reasonable max) and normalize NaNs/infs to defaults, and make snapValue(double
value, double step) defensive by returning the original value if step is invalid
(<= epsilon, NaN or infinite) instead of dividing; update places that reference
these members to rely on the validated values and use a small EPS constant when
checking step validity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3f1f1d5c-d315-4f9d-a3ea-e9027bf06f3b

📥 Commits

Reviewing files that changed from the base of the PR and between 9646833 and 6847d81.

📒 Files selected for processing (8)
  • qml/PropertiesPanel.qml
  • src/MCPServer.cpp
  • src/MCPServer.h
  • src/PropertiesPanelController.cpp
  • src/PropertiesPanelController.h
  • src/TransformOperator.cpp
  • src/TransformOperator.h
  • src/TransformOperator_test.cpp

Comment thread src/MCPServer.cpp
Comment on lines +482 to +491
} else if (name == "duplicate_entity") {
toolResult = toolDuplicateEntity(args);
} else if (name == "camera_control") {
toolResult = toolCameraControl(args);
} else if (name == "get_camera_info") {
toolResult = toolGetCameraInfo(args);
} else if (name == "set_snap_settings") {
toolResult = toolSetSnapSettings(args);
} else if (name == "get_snap_settings") {
toolResult = toolGetSnapSettings(args);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the required breadcrumb category for MCP tool invocations.

These newly dispatched MCP tools are currently logged through callTool() with category mcp.tool; the guideline requires ai.tool_call.

Proposed fix
-    SentryReporter::addBreadcrumb("mcp.tool", QStringLiteral("Tool call: %1").arg(name));
+    SentryReporter::addBreadcrumb("ai.tool_call", QStringLiteral("Tool call: %1").arg(name));
@@
-        SentryReporter::addBreadcrumb("mcp.tool",
+        SentryReporter::addBreadcrumb("ai.tool_call",
             QStringLiteral("Tool error: %1").arg(name), "error");

As per coding guidelines: Track all user-facing actions with SentryReporter::addBreadcrumb() using category ai.tool_call for MCP tool invocations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/MCPServer.cpp` around lines 482 - 491, The MCP tool dispatches for
toolDuplicateEntity, toolCameraControl, toolGetCameraInfo, toolSetSnapSettings,
and toolGetSnapSettings currently log via callTool() using category "mcp.tool";
update the invocation logging to use the required breadcrumb category by calling
SentryReporter::addBreadcrumb(...) with category "ai.tool_call" (or adjust
callTool to emit that breadcrumb) whenever these tools are invoked so all MCP
tool calls are tracked as ai.tool_call; ensure the breadcrumb includes the tool
name and relevant args/context consistent with other tool invocations.

Comment thread src/MCPServer.cpp
Comment thread src/MCPServer.h
Comment on lines +600 to +642
TEST_F(TransformOperatorTests, SnapSettingsRoundTripAndEmitSignal)
{
QSignalSpy spy(op, &TransformOperator::snapSettingsChanged);
ASSERT_TRUE(spy.isValid());

op->setSnapEnabled(true);
EXPECT_TRUE(op->isSnapEnabled());
EXPECT_EQ(spy.count(), 1);

op->setSnapGridSize(2.0);
EXPECT_DOUBLE_EQ(op->snapGridSize(), 2.0);
EXPECT_EQ(spy.count(), 2);

op->setSnapAngleStep(45.0);
EXPECT_DOUBLE_EQ(op->snapAngleStep(), 45.0);
EXPECT_EQ(spy.count(), 3);

op->setSnapScaleStep(0.5);
EXPECT_DOUBLE_EQ(op->snapScaleStep(), 0.5);
EXPECT_EQ(spy.count(), 4);

// Setting same value should not emit again
op->setSnapEnabled(true);
EXPECT_EQ(spy.count(), 4);

op->setSnapGridSize(2.0);
EXPECT_EQ(spy.count(), 4);
}

TEST_F(TransformOperatorTests, SnapSettingsRejectInvalidValues)
{
op->setSnapGridSize(1.0);
op->setSnapGridSize(-1.0);
EXPECT_DOUBLE_EQ(op->snapGridSize(), 1.0);

op->setSnapAngleStep(15.0);
op->setSnapAngleStep(0.0);
EXPECT_DOUBLE_EQ(op->snapAngleStep(), 15.0);

op->setSnapScaleStep(0.25);
op->setSnapScaleStep(-0.5);
EXPECT_DOUBLE_EQ(op->snapScaleStep(), 0.25);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find TransformOperator files
find . -type f -name "TransformOperator*" | head -20

Repository: fernandotonon/QtMeshEditor

Length of output: 156


🏁 Script executed:

# Check the test file structure and fixture
head -100 src/TransformOperator_test.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 2630


🏁 Script executed:

# Search for QSettings usage in TransformOperator.cpp
rg "QSettings" src/TransformOperator.cpp -A 2 -B 2

Repository: fernandotonon/QtMeshEditor

Length of output: 1090


🏁 Script executed:

# Check the test fixture definition for any QSettings handling
ast-grep --pattern 'class TransformOperatorTests : $$$' src/TransformOperator_test.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 4380


🏁 Script executed:

# Look for setUp/tearDown or fixture methods
rg "SetUp|TearDown|setUp|tearDown" src/TransformOperator_test.cpp -A 5

Repository: fernandotonon/QtMeshEditor

Length of output: 363


🏁 Script executed:

# Check TransformOperator::kill() implementation
rg "void kill\(\)" src/TransformOperator.cpp -A 10

Repository: fernandotonon/QtMeshEditor

Length of output: 52


🏁 Script executed:

# Check if TransformOperator destructor clears QSettings
rg "~TransformOperator" src/TransformOperator.cpp -A 10

Repository: fernandotonon/QtMeshEditor

Length of output: 357


🏁 Script executed:

# Look for any QSettings isolation in test setup or other test files
rg "QSettings" src/ -l | head -10

Repository: fernandotonon/QtMeshEditor

Length of output: 294


🏁 Script executed:

# Check if there are any environment-specific or test-specific QSettings handling
rg "temporaryPath\|tempDir\|QStandardPaths.*Test" src/TransformOperator_test.cpp -A 3

Repository: fernandotonon/QtMeshEditor

Length of output: 52


🏁 Script executed:

# See the full snap-related tests to understand the exact behavior
sed -n '600,642p' src/TransformOperator_test.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 1246


Isolate the snap-setting tests from the real QSettings store.

TransformOperator persists these setters to QSettings, so after this test runs once a later run can start with snapEnabled=true or different step values. This makes the signal-count assertions order-dependent and pollutes local developer settings. Redirect QSettings to a temporary store, or clear/restore the Snap/* group in the fixture before constructing TransformOperator.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/TransformOperator_test.cpp` around lines 600 - 642, The tests mutate
persistent QSettings via TransformOperator's setters (setSnapEnabled,
setSnapGridSize, setSnapAngleStep, setSnapScaleStep) and then assert signal
counts for snapSettingsChanged, so make the tests deterministic by isolating
QSettings: in the test fixture (TransformOperatorTests) before constructing the
TransformOperator instance, either redirect QSettings to a temporary INI file or
use QSettings::beginGroup/clear/endGroup to back up and clear the "Snap" group
(or restore it after tests); ensure the fixture restores the original settings
or removes the temporary store in teardown so subsequent runs start from a clean
Snap state and the signal-count assertions remain stable.

Comment thread src/TransformOperator.cpp
Comment on lines +87 to +92
// Load snap settings from QSettings
QSettings settings;
mSnapEnabled = settings.value("Snap/enabled", false).toBool();
mSnapGridSize = settings.value("Snap/gridSize", 1.0).toDouble();
mSnapAngleStep = settings.value("Snap/angleStep", 15.0).toDouble();
mSnapScaleStep = settings.value("Snap/scaleStep", 0.25).toDouble();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate the file and get its basic info
fd -t f "TransformOperator.cpp" --type f

Repository: fernandotonon/QtMeshEditor

Length of output: 95


🏁 Script executed:

# Check the file exists and get line count
wc -l src/TransformOperator.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 100


🏁 Script executed:

# Read lines 87-92 to verify the settings loading code
sed -n '85,94p' src/TransformOperator.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 483


🏁 Script executed:

# Read lines 220-230 to check the "Also applies to" location
sed -n '220,230p' src/TransformOperator.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 381


🏁 Script executed:

# Find and read the snapValue() method to verify it divides by step
ast-grep --pattern 'double snapValue($_, $_) { $$$ }' | head -20

Repository: fernandotonon/QtMeshEditor

Length of output: 52


🏁 Script executed:

# Also search for snapValue with rg to find its implementation
rg -A 5 'double.*snapValue.*step' src/TransformOperator.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 281


🏁 Script executed:

# Check if there are any existing guards or validation around snapValue calls
rg -B 2 -A 2 'snapValue\(' src/TransformOperator.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 811


🏁 Script executed:

# Check if there's any validation of the snap settings after loading
sed -n '87,120p' src/TransformOperator.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 1172


Validate persisted snap steps before using them.

The constructor loads Snap/* values from QSettings without validation. snapValue() divides by step unconditionally, so a corrupted config returning 0, a negative number, or inf will produce NaN or inf on the first snapping operation. Clamp the loaded values and make snapValue() fail-safe for invalid steps.

Defensive clamp for persisted settings
+namespace {
+double sanitizeSnapStep(const QVariant& value, double fallback)
+{
+    const double step = value.toDouble();
+    return std::isfinite(step) && step > 0.0 ? step : fallback;
+}
+}
+
     QSettings settings;
     mSnapEnabled    = settings.value("Snap/enabled", false).toBool();
-    mSnapGridSize   = settings.value("Snap/gridSize", 1.0).toDouble();
-    mSnapAngleStep  = settings.value("Snap/angleStep", 15.0).toDouble();
-    mSnapScaleStep  = settings.value("Snap/scaleStep", 0.25).toDouble();
+    mSnapGridSize   = sanitizeSnapStep(settings.value("Snap/gridSize", 1.0), 1.0);
+    mSnapAngleStep  = sanitizeSnapStep(settings.value("Snap/angleStep", 15.0), 15.0);
+    mSnapScaleStep  = sanitizeSnapStep(settings.value("Snap/scaleStep", 0.25), 0.25);
...
 double TransformOperator::snapValue(double value, double step)
 {
+    if (!std::isfinite(step) || step <= 0.0)
+        return value;
     return std::round(value / step) * step;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/TransformOperator.cpp` around lines 87 - 92, Loaded snap steps
(mSnapGridSize, mSnapAngleStep, mSnapScaleStep) are used without validation and
can be zero/negative/inf; clamp each after reading from QSettings to a safe
positive range (e.g., min > 0 and reasonable max) and normalize NaNs/infs to
defaults, and make snapValue(double value, double step) defensive by returning
the original value if step is invalid (<= epsilon, NaN or infinite) instead of
dividing; update places that reference these members to rely on the validated
values and use a small EPS constant when checking step validity.

Comment thread src/TransformOperator.cpp
Comment on lines +678 to +684
// Apply snap if Ctrl is held or snap is permanently enabled
bool snapping = mSnapEnabled || (e->modifiers() & Qt::ControlModifier);
if (snapping)
{
mSnapTranslationAccum += translation;
Ogre::Vector3 snapped = snapTranslation(mSnapTranslationAccum, mSnapGridSize);
if (snapped.isZeroLength())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep snapping in gizmo-local coordinates for SPACE_LOCAL.

The code already computes localDelta / localRot, but the new snap paths quantize after converting those values back into world space. Once the selection is rotated, a one-step local move or local rotation is rounded against world components instead of the active local axis, so local mode no longer preserves its own step size.

Also applies to: 761-786

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/TransformOperator.cpp` around lines 678 - 684, Snapping is being applied
after converting local deltas back to world space, so in SPACE_LOCAL mode
quantization uses world axes; instead, when mode == SPACE_LOCAL use the already
computed localDelta / localRot and accumulate and snap in gizmo-local
coordinates (use mSnapTranslationAccum and mSnapRotationAccum but store and
quantize the local-space values via snapTranslation/snapRotation with
mSnapGridSize), then transform the snapped local result back to world before
applying to the selection; update both the translation path (where
mSnapTranslationAccum and snapTranslation are used) and the rotation path (where
localRot is handled around the same region) to follow this local-space
accumulate -> snap -> world-transform flow so local moves/rotations preserve the
local step size.

Comment thread src/TransformOperator.cpp Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/CLIPipeline.cpp (1)

1331-1334: Consider adding mutual exclusivity check for --time and --count.

Currently, if both --time and --count are provided, --count silently takes precedence (line 1378 checks count > 0 first). This could confuse users who expect an error or explicit documentation of precedence.

Proposed fix to reject conflicting options
     if (time < 0.0f && count <= 0) {
         err() << "Error: Specify --time <t> or --count <N>." << Qt::endl;
         return 2;
     }
+
+    if (time >= 0.0f && count > 0) {
+        err() << "Error: --time and --count are mutually exclusive." << Qt::endl;
+        return 2;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CLIPipeline.cpp` around lines 1331 - 1334, Add a mutual exclusivity check
that rejects using both --time and --count together: inside the CLI
parsing/validation block where the variables time and count are inspected (the
same area that currently checks if (time < 0.0f && count <= 0)), add a condition
that detects time >= 0.0f && count > 0 and prints an error via err() with a
clear message (e.g., "Error: --time and --count are mutually exclusive.") and
return a non-zero exit code; this will prevent the current silent precedence
behavior where count wins and ensure users are informed of the conflict.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@qml/PropertiesPanel.qml`:
- Around line 1186-1204: The Export Pose button is hidden because entityGroups
is built by PropertiesPanelController.animationData(), which currently skips
entities with no animation states; update animationData() to also include
entities that have a skeleton (even if states->getAnimationStates() is empty) so
grp.hasSkeleton will be true and the existing Rectangle/UI (id exportPoseMouse,
text "Export Pose", onClicked: PropertiesPanelController.exportCurrentPose())
becomes visible for skeleton-only models; alternatively, if you prefer a UI
change instead, add a separate Skeleton section in PropertiesPanel.qml that
iterates skeleton-bearing entities (from a new controller method like
PropertiesPanelController.skeletonData()) and exposes an Export Pose button
wired to PropertiesPanelController.exportCurrentPose().

In `@src/MeshImporterExporter.cpp`:
- Around line 1297-1450: The pose-bake needs to be exception-safe: create an
RAII guard that calls entity->addSoftwareAnimationRequest(false) in its ctor and
entity->removeSoftwareAnimationRequest(false) in its dtor (so the request is
always released), and manage the aiScene* using a smart pointer (e.g.,
std::unique_ptr<aiScene> with a deleter) so the scene is freed on exceptions;
wrap the whole bake/build block (everything after addSoftwareAnimationRequest up
to compactAiMesh calls) in a try/catch that catches exceptions, ensures the
function returns -1 on failure, and reuses entity->_updateAnimation() as before
(call it after the RAII guard is constructed) — reference the symbols
entity->addSoftwareAnimationRequest, entity->removeSoftwareAnimationRequest,
entity->_updateAnimation, aiScene, and compactAiMesh to locate where to apply
the RAII and the try/catch.
- Around line 1483-1487: The export currently applies
aiProcess_ConvertToLeftHanded for every non-"x" format, which incorrectly flips
right-handed formats like glTF/GLB (and Ogre); change the exportFlags logic
around the export call (the exportFlags variable used with
Assimp::Exporter::Export) to set exportFlags = 0 when formatId equals "x" OR
"gltf" OR "glb" (and any other right-handed exporters you expect), and only use
aiProcess_ConvertToLeftHanded for formats that require left-handed conversion;
update the conditional that computes exportFlags before calling exporter.Export.

In `@src/PropertiesPanelController.cpp`:
- Around line 487-519: PropertiesPanelController::exportCurrentPose is missing a
Sentry breadcrumb for the user-facing export action; add a call to
SentryReporter::addBreadcrumb("file.export", <message>) to record the export
event. Specifically, after you determine the target entity (animatedEntity) and
after you have a final outputPath (i.e., right before calling
MeshImporterExporter::exportCurrentPose), call SentryReporter::addBreadcrumb
with category "file.export" and a concise message including the entity name
(animatedEntity->getName()) and the outputPath (or "cancelled" if the user
aborted) so the export attempt and target file are logged. Ensure the breadcrumb
is added in both code paths (when path is provided and when the save dialog is
used) and keep the message clear like "Export current pose: <entity>: <path>".

---

Nitpick comments:
In `@src/CLIPipeline.cpp`:
- Around line 1331-1334: Add a mutual exclusivity check that rejects using both
--time and --count together: inside the CLI parsing/validation block where the
variables time and count are inspected (the same area that currently checks if
(time < 0.0f && count <= 0)), add a condition that detects time >= 0.0f && count
> 0 and prints an error via err() with a clear message (e.g., "Error: --time and
--count are mutually exclusive.") and return a non-zero exit code; this will
prevent the current silent precedence behavior where count wins and ensure users
are informed of the conflict.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 967f0513-31ff-4df7-a087-0a4bb73eef6f

📥 Commits

Reviewing files that changed from the base of the PR and between 6847d81 and bf57020.

📒 Files selected for processing (12)
  • CLAUDE.md
  • docker-entrypoint.sh
  • qml/PropertiesPanel.qml
  • src/CLIPipeline.cpp
  • src/CLIPipeline.h
  • src/MCPServer.cpp
  • src/MCPServer.h
  • src/MeshImporterExporter.cpp
  • src/MeshImporterExporter.h
  • src/PropertiesPanelController.cpp
  • src/PropertiesPanelController.h
  • src/main.cpp
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/MCPServer.h
  • src/PropertiesPanelController.h
  • src/MCPServer.cpp

Comment thread qml/PropertiesPanel.qml
Comment thread src/MeshImporterExporter.cpp Outdated
Comment on lines +1297 to +1450
// Request software skinning so we can read CPU-side deformed vertices
entity->addSoftwareAnimationRequest(false);
entity->_updateAnimation();

const Ogre::MeshPtr mesh = entity->getMesh();
const unsigned int numSub = mesh->getNumSubMeshes();

// Build an aiScene with deformed vertex positions, NO skeleton, NO animations
auto* scene = new aiScene();
scene->mRootNode = new aiNode();
scene->mRootNode->mName = aiString(entity->getName());
scene->mRootNode->mNumMeshes = numSub;
scene->mRootNode->mMeshes = new unsigned int[numSub];
for (unsigned int si = 0; si < numSub; ++si)
scene->mRootNode->mMeshes[si] = si;

// Materials
std::vector<Ogre::MaterialPtr> materials;
std::set<std::string, std::less<>> seen;
for (const auto* sub : entity->getSubEntities())
{
auto mat = sub->getMaterial();
if (seen.insert(mat->getName()).second)
materials.push_back(mat);
}
scene->mNumMaterials = static_cast<unsigned int>(materials.size());
scene->mMaterials = new aiMaterial*[scene->mNumMaterials];
std::map<std::string, unsigned int, std::less<>> matIndexMap;
for (unsigned int i = 0; i < scene->mNumMaterials; ++i) {
scene->mMaterials[i] = buildAiMaterialFromOgre(materials[i]);
matIndexMap[materials[i]->getName()] = i;
}

// Meshes — read deformed positions from software-skinned buffers
scene->mNumMeshes = numSub;
scene->mMeshes = numSub > 0 ? new aiMesh*[numSub] : nullptr;

for (unsigned int si = 0; si < numSub; ++si)
{
const Ogre::SubMesh* subMesh = mesh->getSubMesh(si);
Ogre::SubEntity* subEnt = entity->getSubEntity(si);

// Get the software-skinned vertex data (deformed positions/normals)
Ogre::VertexData* animData = subMesh->useSharedVertices
? entity->_getSkelAnimVertexData()
: subEnt->_getSkelAnimVertexData();

// Fall back to bind-pose vertex data if skinned data unavailable
const Ogre::VertexData* bindData = subMesh->useSharedVertices
? mesh->sharedVertexData : subMesh->vertexData;

if (!animData && !bindData) {
scene->mMeshes[si] = new aiMesh();
continue;
}

auto* aiM = new aiMesh();
scene->mMeshes[si] = aiM;
aiM->mPrimitiveTypes = aiPrimitiveType_TRIANGLE;

// Use animData for positions and normals, bindData for UVs and indices
const Ogre::VertexData* posSource = animData ? animData : bindData;
aiM->mNumVertices = static_cast<unsigned int>(posSource->vertexCount);
aiM->mVertices = new aiVector3D[aiM->mNumVertices];

// Material index
auto matIt = matIndexMap.find(subEnt->getMaterial()->getName());
aiM->mMaterialIndex = (matIt != matIndexMap.end()) ? matIt->second : 0;

// Read deformed positions
const auto* posElem = posSource->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION);
if (posElem)
{
auto vbuf = posSource->vertexBufferBinding->getBuffer(posElem->getSource());
auto* base = static_cast<const unsigned char*>(vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (unsigned int j = 0; j < aiM->mNumVertices; ++j)
{
const Ogre::Real* p;
posElem->baseVertexPointerToElement(const_cast<unsigned char*>(base + j * vbuf->getVertexSize()), &p);
aiM->mVertices[j] = aiVector3D(p[0], p[1], p[2]);
}
vbuf->unlock();
}

// Read deformed normals (from animData if available, otherwise bindData)
const auto* normElem = posSource->vertexDeclaration->findElementBySemantic(Ogre::VES_NORMAL);
if (normElem)
{
aiM->mNormals = new aiVector3D[aiM->mNumVertices];
auto vbuf = posSource->vertexBufferBinding->getBuffer(normElem->getSource());
auto* base = static_cast<const unsigned char*>(vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (unsigned int j = 0; j < aiM->mNumVertices; ++j)
{
const Ogre::Real* p;
normElem->baseVertexPointerToElement(const_cast<unsigned char*>(base + j * vbuf->getVertexSize()), &p);
aiM->mNormals[j] = aiVector3D(p[0], p[1], p[2]);
}
vbuf->unlock();
}

// Read UVs from bind-pose data (skinning doesn't affect UVs)
if (bindData)
{
const auto* tcElem = bindData->vertexDeclaration->findElementBySemantic(Ogre::VES_TEXTURE_COORDINATES);
if (tcElem)
{
aiM->mTextureCoords[0] = new aiVector3D[aiM->mNumVertices];
aiM->mNumUVComponents[0] = 2;
auto vbuf = bindData->vertexBufferBinding->getBuffer(tcElem->getSource());
auto* base = static_cast<const unsigned char*>(vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
for (unsigned int j = 0; j < aiM->mNumVertices; ++j)
{
const Ogre::Real* p;
tcElem->baseVertexPointerToElement(const_cast<unsigned char*>(base + j * vbuf->getVertexSize()), &p);
aiM->mTextureCoords[0][j] = aiVector3D(p[0], p[1], 0.0f);
}
vbuf->unlock();
}
}

// Read indices from the original submesh
const Ogre::IndexData* iData = subMesh->indexData;
if (iData && iData->indexCount > 0)
{
aiM->mNumFaces = static_cast<unsigned int>(iData->indexCount / 3);
aiM->mFaces = new aiFace[aiM->mNumFaces];
auto ibuf = iData->indexBuffer;
auto* ibase = static_cast<const unsigned char*>(ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY));
bool use32 = ibuf->getType() == Ogre::HardwareIndexBuffer::IT_32BIT;

const unsigned int indexStart = static_cast<unsigned int>(iData->indexStart);
for (unsigned int f = 0; f < aiM->mNumFaces; ++f)
{
aiM->mFaces[f].mNumIndices = 3;
aiM->mFaces[f].mIndices = new unsigned int[3];
for (unsigned int v = 0; v < 3; ++v)
{
unsigned int idx = use32
? reinterpret_cast<const uint32_t*>(ibase)[indexStart + f * 3 + v]
: reinterpret_cast<const uint16_t*>(ibase)[indexStart + f * 3 + v];
aiM->mFaces[f].mIndices[v] = idx;
}
}
ibuf->unlock();
}

compactAiMesh(aiM);
}

// No animations, no bones — this is a static mesh

// Release software animation request
entity->removeSoftwareAnimationRequest(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the pose-bake cleanup exception-safe.

Any exception during the bake/build phase before Line 1449 skips removeSoftwareAnimationRequest(false) and escapes this int-returning API. The MCP pose-export caller only disables the active animation after exportCurrentPose() returns, so a failed export can leave both the software-animation request and the animation state stuck on. Wrap the request and scene lifetime in RAII and catch around the whole bake path so failures consistently return -1.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/MeshImporterExporter.cpp` around lines 1297 - 1450, The pose-bake needs
to be exception-safe: create an RAII guard that calls
entity->addSoftwareAnimationRequest(false) in its ctor and
entity->removeSoftwareAnimationRequest(false) in its dtor (so the request is
always released), and manage the aiScene* using a smart pointer (e.g.,
std::unique_ptr<aiScene> with a deleter) so the scene is freed on exceptions;
wrap the whole bake/build block (everything after addSoftwareAnimationRequest up
to compactAiMesh calls) in a try/catch that catches exceptions, ensures the
function returns -1 on failure, and reuses entity->_updateAnimation() as before
(call it after the RAII guard is constructed) — reference the symbols
entity->addSoftwareAnimationRequest, entity->removeSoftwareAnimationRequest,
entity->_updateAnimation, aiScene, and compactAiMesh to locate where to apply
the RAII and the try/catch.

Comment thread src/MeshImporterExporter.cpp
Comment on lines +487 to +519
bool PropertiesPanelController::exportCurrentPose(const QString& path)
{
auto entities = SelectionSet::getSingleton()->getResolvedEntities();
Ogre::Entity* animatedEntity = nullptr;
for (Ogre::Entity* ent : entities)
{
if (ent->hasSkeleton()) {
animatedEntity = ent;
break;
}
}

if (!animatedEntity) return false;

QString outputPath = path;
if (outputPath.isEmpty())
{
QString filter = "STL (*.stl)";
outputPath = QFileDialog::getSaveFileName(
nullptr,
QObject::tr("Export Current Pose"),
QString::fromStdString(animatedEntity->getName()) + "_pose",
MeshImporterExporter::exportFileDialogFilter(),
&filter,
QFileDialog::DontUseNativeDialog);
if (outputPath.isEmpty()) return false;

outputPath = MeshImporterExporter::formatFileURI(outputPath, filter);
}

int result = MeshImporterExporter::exportCurrentPose(animatedEntity, outputPath);
return result == 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing Sentry breadcrumb for pose export action.

Per the newly added coding guideline in CLAUDE.md: "All user-facing actions and significant operations must be tracked with SentryReporter::addBreadcrumb(category, message)." The exportCurrentPose function is a file export operation triggered from the UI but doesn't record a breadcrumb.

As per coding guidelines: "file.import" / "file.export" for I/O operations.

Proposed fix to add Sentry breadcrumb
+#include "SentryReporter.h"
 
 bool PropertiesPanelController::exportCurrentPose(const QString& path)
 {
     auto entities = SelectionSet::getSingleton()->getResolvedEntities();
     Ogre::Entity* animatedEntity = nullptr;
     for (Ogre::Entity* ent : entities)
     {
         if (ent->hasSkeleton()) {
             animatedEntity = ent;
             break;
         }
     }
 
     if (!animatedEntity) return false;
 
     QString outputPath = path;
     if (outputPath.isEmpty())
     {
         // ... file dialog code ...
     }
 
+    SentryReporter::addBreadcrumb("file.export", 
+        QString("Export pose: %1").arg(QFileInfo(outputPath).fileName()));
+
     int result = MeshImporterExporter::exportCurrentPose(animatedEntity, outputPath);
     return result == 0;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/PropertiesPanelController.cpp` around lines 487 - 519,
PropertiesPanelController::exportCurrentPose is missing a Sentry breadcrumb for
the user-facing export action; add a call to
SentryReporter::addBreadcrumb("file.export", <message>) to record the export
event. Specifically, after you determine the target entity (animatedEntity) and
after you have a final outputPath (i.e., right before calling
MeshImporterExporter::exportCurrentPose), call SentryReporter::addBreadcrumb
with category "file.export" and a concise message including the entity name
(animatedEntity->getName()) and the outputPath (or "cancelled" if the user
aborted) so the export attempt and target file are logged. Ensure the breadcrumb
is added in both code paths (when path is provided and when the save dialog is
used) and keep the message clear like "Export current pose: <entity>: <path>".

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
qml/PropertiesPanel.qml (1)

237-241: Unused Timer — dead code.

snapRepaintTimer is defined but never started. The forceRepaint() function resets Repeater models directly and doesn't use this timer.

🧹 Remove unused Timer
-            // Timer to force QQuickWidget repaint — toggling opacity marks
-            // the entire subtree dirty in the scene graph, forcing a redraw.
-            Timer {
-                id: snapRepaintTimer
-                interval: 1; repeat: false
-                onTriggered: snapCol.opacity = 1.0
-            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@qml/PropertiesPanel.qml` around lines 237 - 241, The Timer object
snapRepaintTimer is dead code (never started and not referenced by
forceRepaint()), so remove the Timer block (id: snapRepaintTimer) and its
onTriggered handler that sets snapCol.opacity, and ensure forceRepaint()
continues to reset the Repeater models (e.g., those used by snapCol/Repeater)
without relying on the timer; search for any remaining references to
snapRepaintTimer and delete them so no unused identifier remains.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@qml/PropertiesPanel.qml`:
- Around line 251-256: The local snap state initialized in Component.onCompleted
(snapOn, activeGridIdx, activeAngleIdx, activeScaleIdx using findIdx and
PropertiesPanelController.snapEnabled/snapGridSize/snapAngleStep/snapScaleStep)
will go stale when settings change externally; add a Connections block targeting
PropertiesPanelController and connect its NOTIFY signals (snapEnabledChanged,
snapGridSizeChanged, snapAngleStepChanged, snapScaleStepChanged) to handlers
that update snapOn and recompute activeGridIdx/activeAngleIdx/activeScaleIdx
(using findIdx with the updated controller properties) so the UI stays in sync
with external changes.

---

Nitpick comments:
In `@qml/PropertiesPanel.qml`:
- Around line 237-241: The Timer object snapRepaintTimer is dead code (never
started and not referenced by forceRepaint()), so remove the Timer block (id:
snapRepaintTimer) and its onTriggered handler that sets snapCol.opacity, and
ensure forceRepaint() continues to reset the Repeater models (e.g., those used
by snapCol/Repeater) without relying on the timer; search for any remaining
references to snapRepaintTimer and delete them so no unused identifier remains.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 735da394-cd34-47d8-a085-5ebc14ea2ccf

📥 Commits

Reviewing files that changed from the base of the PR and between 7df78e1 and d45141b.

📒 Files selected for processing (2)
  • qml/PropertiesPanel.qml
  • src/mainwindow.cpp

Comment thread qml/PropertiesPanel.qml
fernandotonon and others added 20 commits April 8, 2026 20:09
- All user-facing actions must be tracked with SentryReporter::addBreadcrumb
- Version format is strictly X.Y.Z (never prepend v) — the update check
  feature compares against GitHub release tags and v prefix breaks it
- Manager::duplicateSceneNode(): clones scene node with transform,
  all attached entities, and per-sub-entity material assignments
- DuplicateCommand: undo hides clones, redo shows them (same pattern
  as DeleteCommand for safe undo/redo without node destruction)
- Ctrl+D shortcut via QAction in Edit menu (after Undo/Redo)
- MainWindow::duplicateSelected(): handles multi-selection, pushes
  undo command, auto-selects clones
- MCP tool: duplicate_entity (by name or current selection)
- Added to AI chat tool filter
- Sentry breadcrumb tracking for the UI action
- 5 unit tests for DuplicateCommand

Part of #256 (Phase 1: Scene Editing Power Tools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
duplicateSceneNode() was sharing the MeshPtr (shallow copy), so
renaming an animation on a clone also changed the original. Now uses
Mesh::clone() to create a fully independent mesh resource per duplicate
with its own skeleton and animation data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ogre 14 Skeleton has no clone() method, so manually create a new
skeleton resource and copy: all bones with initial state, bone parent
hierarchy, binding pose, all animations with their node tracks and
keyframes. This ensures renaming/editing an animation on a duplicated
entity does not affect the original.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cut breadcrumbs

DuplicateCommand now properly destroys cloned nodes on undo (removing
them from the scene tree) and re-duplicates from source nodes on redo.
The previous hide/show pattern left invisible ghost nodes in the tree.

Also adds Sentry breadcrumbs for all keyboard shortcuts (Q/W/E/R/F/X/Del)
so we can track shortcut usage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On redo, duplicateSceneNode generates the same resource names as the
original duplicate. MeshManager and SkeletonManager still held the old
entries from before undo (destroySceneNode removes the entity/node but
not the underlying resource registrations). Now removes any existing
mesh/skeleton resource by name before cloning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Grid/angle/scale snapping during gizmo drags. Hold Ctrl or enable via
the Inspector panel. Accumulator-based: sub-threshold mouse deltas
accumulate until they exceed the snap step, then a quantized transform
is applied.

Core:
- TransformOperator: snap settings (gridSize, angleStep, scaleStep),
  snap math helpers, accumulator-based drag snapping in translate/
  rotate/scale handlers, QSettings persistence
- PropertiesPanelController: QML property bridge for all snap settings
- PropertiesPanel.qml: "Snap Settings" collapsible section with
  preset buttons (0.1-5.0 grid, 5-90 angle, 0.1-0.5 scale)
- MCP tools: set_snap_settings, get_snap_settings
- 8 unit tests for snap math and settings

Part of #256 (Phase 1: Scene Editing Power Tools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Export animated models as static meshes frozen at any pose. Reads
software-skinned vertex positions from Ogre and writes a new mesh
with no skeleton or animations — ideal for 3D printing.

Core:
- MeshImporterExporter::exportCurrentPose(): request software skinning,
  read deformed positions/normals per SubEntity, build aiScene, export
  via Assimp to STL/OBJ/glTF/FBX/DAE
- GUI: "Export Pose" button in Inspector Animation section
- CLI: qtmesh pose model.fbx --animation "Walk" --time 0.5 -o posed.stl
       qtmesh pose model.fbx --animation "Dance" --count 4 -o pose_%02d.stl
- MCP tool: export_pose (entity, animation, time, output_path)
- Docker entrypoint: added pose/validate/lod to recognized subcommands
- CLAUDE.md: documented pose subcommand

Part of #256 (Phase 1: Scene Editing Power Tools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace Qt Controls CheckBox with custom themed checkbox matching the
  animation panel style (Rectangle + checkmark + MouseArea)
- Change Row to Flow with proportional widths so all presets fit and
  resize with the panel (no more overflow on narrow widths)
- Add hover highlight on preset buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The property bool active binding was not re-evaluating when the snap
setting changed. Replaced with inline bindings in the color expression
that directly reference the controller property, so only the currently
active preset shows the highlight color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QML bindings weren't re-evaluating because all 4 snap properties shared
one generic snapSettingsChanged signal. Split into dedicated signals
(snapEnabledChanged, snapGridSizeChanged, snapAngleStepChanged,
snapScaleStepChanged) so QML precisely tracks each property change
and updates button highlights reactively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QML Repeater delegates weren't re-evaluating bindings that referenced
the C++ singleton properties directly. Fixed by mirroring snap values
into local QML properties (curGridSize, curAngleStep, etc.) on the
parent Column, updated via explicit Connections handlers. Delegates
bind to the local properties which QML tracks reliably.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e colors

- Replace reactive bindings with model-reset approach: on click, the
  Repeater model is nulled and restored, destroying and recreating all
  delegates with correct colors (QQuickWidget in QDockWidget doesn't
  repaint sibling delegates on property changes)
- Change default button color from buttonColor (QPalette::Button, light
  even in dark mode on macOS) to headerColor (QPalette::Window darkened)
- Add Timer-based opacity toggle as additional repaint trigger
- Request QML scene graph frame update from C++ on snap changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Select individual sub-meshes in the scene tree and transform them
independently by modifying vertex buffer data directly.

Core:
- SubMeshTransform: translate/rotate/scale sub-mesh vertices, read/write
  vertex positions, compute centroid, recalculate mesh bounds
- SubEntityHighlight: visual tint on selected sub-entities via cloned
  material with emissive overlay, auto-updates on selection change
- SubMeshTransformCommand: full vertex snapshot undo/redo
- TransformOperator: gizmo integration — positions at sub-mesh centroid,
  applies vertex transforms on drag, pushes undo on mouse release
- MCP tool: transform_submesh (entity, submesh_index, translate/rotate/scale)
- 4 unit tests for vertex transform, centroid, read/write round-trip

Part of #256 (Phase 1: Scene Editing Power Tools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SubEntityHighlight and TransformOperator accessed sub-entity pointers
that could become stale when entities are destroyed or the scene changes.

- Copy selection list before iterating (avoids concurrent modification)
- Null-check sub->getParent() and getParentSceneNode() chains
- Wrap critical sections in try-catch to recover from stale pointers
- Clear highlight tracking map on exception

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After modifying vertex buffer data, Ogre's GPU-side buffer wasn't being
re-uploaded because needUpdate(true) only marks the scene node transform
as dirty, not the mesh data. Fixed by detaching and re-attaching the
entity from its parent scene node after each vertex modification, which
forces Ogre to rebuild its render operation and re-upload the vertex
buffer to the GPU.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… ops

The detach/re-attach approach didn't force Ogre to re-read vertex
buffer data. entity->_initialise(true) rebuilds all internal SubEntity
render operations from the mesh, picking up modified vertex positions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HBL_NORMAL lock/unlock wasn't triggering GPU buffer re-upload on all
drivers. _initialise(true) crashed by invalidating SubEntity pointers.

New approach: readData() the entire vertex buffer into local memory,
modify positions in the copy, then writeData(..., discardWholeBuffer=true)
which forces Ogre to upload the full buffer to GPU regardless of shadow
buffer state. Also refactored scale/rotate to use readPositions/writePositions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ogre renders skeletal entities from SubEntity's animation blend buffer,
not the mesh VBO. Now writes to BOTH buffers:
1. Mesh vertex buffer (bind pose) — for persistence and export
2. SubEntity::_getSkelAnimVertexData() — for immediate GPU rendering

Also upgrades static buffers to HBU_DYNAMIC on first write and uses
HBL_DISCARD lock for guaranteed GPU upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Bump SERVER_VERSION 1.2.0 → 1.3.0 for new MCP tools added in Phase 1
- Manager::duplicateSceneNode preserves UserObjectBindings (PrimitiveObject
  type etc.) and uses per-entity index in clone names to avoid collisions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@fernandotonon
fernandotonon force-pushed the feat/phase1-scene-editing-tools branch from a4a519a to e4ae735 Compare April 9, 2026 00:13
fernandotonon and others added 2 commits April 8, 2026 20:27
Review fixes:
- MCP duplicate_entity now pushes DuplicateCommand for undo support
- Snap settings validation is atomic (validate all fields before applying)
- DuplicateCommand tests use hasSceneNode() instead of dereferencing
  potentially destroyed entity pointers
- QML snap state syncs from external changes via Connections block
- Breadcrumb confirmed in PropertiesPanelController::exportCurrentPose

New tests (22 total):
- MCPServer: 18 tests for duplicate_entity, transform_submesh,
  set/get_snap_settings, export_pose (valid + error cases)
- TransformCommands: 4 tests for scaleSubMesh, rotateSubMesh,
  centroid invariance under rotation and scaling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SnapSettingsRoundTripAndEmitSignal expected setSnapEnabled(true) to
emit, but snap may already be enabled from persisted QSettings on CI.
Reset to known state (disabled, default values) before starting the
signal count.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Apr 9, 2026

Copy link
Copy Markdown

@fernandotonon
fernandotonon merged commit b19901c into master Apr 9, 2026
18 checks passed
@fernandotonon
fernandotonon deleted the feat/phase1-scene-editing-tools branch April 9, 2026 01:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant