Skip to content

feat(perf): single-pass mesh decimation (Phase 6 slice D) - #499

Merged
fernandotonon merged 11 commits into
masterfrom
feat/phase6-slice-d-decimation-polish
May 13, 2026
Merged

feat(perf): single-pass mesh decimation (Phase 6 slice D)#499
fernandotonon merged 11 commits into
masterfrom
feat/phase6-slice-d-decimation-polish

Conversation

@fernandotonon

@fernandotonon fernandotonon commented May 12, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 6 slice D (#261) — single-pass mesh decimation with live in-app preview. Same pattern as slices A/B/C (pure-data + CLI + MCP + validator + Inspector controller).

  • MeshDecimator (new): wraps Ogre::MeshLodGenerator for single-pass base-mesh reduction. Unlike MeshLodController (which builds an LOD chain for distance-based rendering), this rewrites the base mesh itself — appropriate for "ship this asset at 5,000 tris regardless of distance". Three target modes: --reduction <r> (drop the requested fraction), --target-tris N, --target-verts N. All clamp at 95% so we never degenerate to one triangle.
  • MeshDecimatorController (new): QML_SINGLETON parallel to MeshLodController. Owns the live preview / Apply lifecycle. Reaches Ogre::MeshLodGenerator via the shared singleton (the GUI's MeshLodController owns the live instance; lazy-constructs in CLI/MCP/test contexts where neither controller is around).
  • Inspector section: new "Decimate (single-pass)" CollapsibleSection right below "LOD Generation" in Object mode. Slider in 5% steps 0..95%, live "Tris: 12,584 → 6,200" readout (the second number in blue while a preview is active), Apply + Reset Preview buttons. 150ms debounce on the slider so dragging doesn't melt Ogre with per-pixel LOD rebuilds. Slider defaults to 0% so opening the section leaves the mesh unchanged until the user actually drags.
  • CLI qtmesh decimate <file> -o <out> (always requires -o — destructive op, never overwrites input). Strict numeric input validation; rejects ambiguous combinations like --reduction 0.5 --target-tris 1000.
  • MCP tool decimate_mesh — operates on the selected entity (not the first scene entity), supports dry_run, returns error when applied=false. Structured payload under the decimation key.
  • Validator integration: when geometry-ok reports > 10,000 triangles, emits a "Tri budget" info-row pointing at the CLI/MCP/Inspector action paths.
  • Live overlay refresh: floating mesh-info overlay (MeshInfoOverlay) listens to MeshDecimatorController::applied so the tri/draws/GPU lines update immediately after Apply — no need to toggle the overlay off/on.
  • Docs: new "Decimation (poly reduction)" Concepts entry distinguishing lod (chain) from decimate (single-pass), full CmdSection in DocsApp.

Manual smoke

$ qtmesh decimate media/models/ninja.mesh -o /tmp/ninja_dec.mesh --target-tris 500
File: ninja.mesh -> ninja_dec.mesh
Mesh Decimation
===============

Mesh: ninja.mesh
Reduction requested: 50.4%  (applied)

  [0] tris 904 → 456
  [1] tris 104 → 36

Total: 1,008 → 492 (51.2% effective reduction)

In-app: select an entity, expand the Decimate section, drag the slider — viewport updates after the 150ms debounce, tri-count line shows → N in blue. Click Apply to commit; the overlay refreshes on its own.

Deliberately deferred from #261's slice D scope

Captured in tracking tasks for future slices:

  • Compare mode (split viewport) — needs second-viewport infrastructure; significant UI work.
  • Per-vertex lock weights — Ogre's MeshLodGenerator doesn't expose per-vertex weights. True QEM-with-locks would replace the algorithm; orthogonal to this slice.

Issues fixed during review / iteration

  • CodeRabbit P1: MCP decimate_mesh was operating on the first scene entity instead of the selected one. Now reads SelectionSet::getResolvedEntities().
  • CodeRabbit P1: CLI numeric inputs silently accepted non-numeric values (Qt's toInt() fallback to 0) which triggered 95% reduction. Added parseStrictInt / parseStrictDouble with explicit error messages and "exactly one target mode" enforcement.
  • CodeRabbit P2: MCP returned makeSuccessResult when applied=false. Now returns an error in that case (gated by !dry_run && reduction > 0).
  • CodeRabbit minor: projectEntity invented triangles for empty submeshes via max(1, ...) — now stays at 0 for empty inputs.
  • CodeRabbit minor: decimateEntity on LOD generation failure left totalTrianglesAfter at 0 while per-submesh mirrored before — now consistent.
  • SonarCloud S859 const_cast: replaced the cast-away-const lazy-fill of baseTriangleCount with a Q_INVOKABLE primeBaseline() that QML calls in Component.onCompleted.
  • SonarCloud S3776 cognitive complexity 35: extracted applyDecimateArg helper from parseDecimateArgs; below threshold.
  • SonarCloud S6004 / S5350: init-statement for modesProvided, const pointers for SelectionSet handles.
  • App crash on startup (SIGABRT) after the QML-singleton registration landed: Ogre::MeshLodGenerator is itself a Singleton<> CRTP; the controller's constructor was creating a second instance. Now reaches the shared singleton via getSingleton().
  • "Decimation failed" on Apply (preview worked): same singleton trap, but inside MeshDecimator::decimateEntity constructing a local generator. Now uses a sharedLodGenerator() helper that returns the live instance and lazy-constructs in CLI/test contexts.
  • Slider opened at 50% changing the mesh before user interaction: now defaults to 0% (no preview until the user drags).
  • Overlay stale after Apply: the floating mesh-info overlay didn't refresh on in-place mesh mutations. Now subscribes to MeshDecimatorController::applied.

Test plan

  • 14 MeshDecimatorTest.* pure-data tests (target-mode arithmetic, reduction clamping, JSON/text serialisation, NaN handling). Run on every CI build.
  • Manual: in-app slider + Apply + Reset Preview on imported FBX and procedural primitives — preview lerps with the slider, Apply commits, overlay updates immediately.
  • Manual: qtmesh decimate on ninja.mesh with --target-tris 500 produces 1,008→492 (51%), --reduction 0.75 produces 1,008→248 (75%). JSON output validates.
  • Manual: strict numeric input rejection (--target-tris foo → exit 2 with clear error). Ambiguous targets rejected.
  • Build clean on macOS arm64 (Qt 6.9.3 / Ogre 14.5.x). All sources wired into both src/CMakeLists.txt and tests/CMakeLists.txt.

🤖 Generated with Claude Code

Builds the fourth pillar of #261 with the same shape as slices A/B/C:
pure-data analyzer + CLI + MCP + validator integration + DocsApp.

MeshDecimator (new) — wraps Ogre::MeshLodGenerator for *single-pass*
mesh reduction. Unlike MeshLodController which generates a chain of
LOD levels (LOD 0/1/2/3/…) for distance-based rendering, decimate
rewrites the base mesh itself — appropriate for "ship this asset at
5,000 triangles regardless of distance". Three target modes:
- --reduction <r>: drop the requested fraction (0..0.95)
- --target-tris N: reduce until total tri count ≈ N
- --target-verts N: reduce until total vertex count ≈ N
All three clamp at 95% so we never degenerate to a single triangle.
The pure-data conversions (reductionFromTargetTris / FromTargetVerts /
clampReduction) are testable without Ogre.

Surfaced through:
- CLI: `qtmesh decimate <file> -o <out> (--reduction|--target-tris|
  --target-verts) [--json]`. Always requires -o (decimation is
  destructive; we never overwrite the input).
- MCP tool: `decimate_mesh` taking one of reduction / target_tris /
  target_verts plus an optional dry_run flag. Returns the report
  under the `decimation` key.
- Validator: when the geometry-ok row reports > 10,000 triangles,
  emits a "Tri budget" info row suggesting the qtmesh decimate CLI
  or MCP path. Not auto-fixable from the validator yet — needs UI
  design (where the slider lives, undo semantics, etc.).

Documentation: DocsApp now has a "Decimation (poly reduction)"
concepts entry that distinguishes lod (LOD chain) from decimate
(single-pass), plus a full CmdSection for the new subcommand.

Manual smoke on ninja.mesh:
- --target-tris 500: 1,008 → 492 tris (51% effective reduction)
- --reduction 0.75: 1,008 → 248 tris (75% effective reduction)
JSON output round-trips through python json.tool.

Deferred from #261's slice D scope to follow-up slices:
- In-app slider with live viewport preview (the validator nudge
  ships the CLI as the action path; needs UI thinking)
- Compare mode (split viewport with original vs decimated)
- Per-vertex lock weights (true QEM replacement of the underlying
  edge-collapse algorithm — Ogre's MeshLodGenerator doesn't expose
  per-vertex weights)

Tests: 14 MeshDecimatorTest cases cover target-mode arithmetic
(empty / no-change / halving / floor cases), reduction clamping
(bounds + NaN), and JSON / text serialisation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 12, 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

Implements single-pass mesh decimation: adds MeshDecimator (model, analysis, Ogre mutation), CLI qtmesh decimate, MCP decimate_mesh, MeshDecimatorController + QML UI (preview/apply), tests, docs, validator hint, and build/test wiring.

Changes

Mesh Decimation Feature

Layer / File(s) Summary
Decimation data model, contracts, and tests
src/MeshDecimator.h, src/MeshDecimator.cpp, src/MeshDecimator_test.cpp
Adds DecimationSubmeshReport and DecimationReport; declares MeshDecimator API (reductionFromTarget*, clampReduction, projectEntity, decimateEntity, countBaseline, toJson, toText) and unit tests for reduction logic and serialization.
Ogre-backed decimation implementation
src/MeshDecimator.cpp
Implements baseline counting, per-submesh triangle accounting, projectEntity (analysis-only), decimateEntity (LOD generation and promotion via index-data swapping), and JSON/text serialization of results.
Controller and QML UI
src/MeshDecimatorController.h, src/MeshDecimatorController.cpp, qml/PropertiesPanel.qml, src/mainwindow.cpp
Introduces MeshDecimatorController QML singleton with preview/apply/clear operations, baseline caching, and signals; registers the controller for QML; adds decimateComponent UI with slider, preview, apply/reset buttons, and feedback.
CLI and MCP integration
src/CLIPipeline.h, src/CLIPipeline.cpp, src/main.cpp, src/MCPServer.h, src/MCPServer.cpp
Adds decimate CLI subcommand and handler (CLIPipeline::cmdDecimate) with strict parsing/validation (--reduction/--target-tris/--target-verts, -o, --json), headless Ogre import/export orchestration, decimation invocation and reporting; registers decimate_mesh MCP tool and implements handler with dry_run vs mutation behavior and structured decimation JSON.
Build, validator, tests, and docs
src/CMakeLists.txt, tests/CMakeLists.txt, src/MeshValidator.cpp, website/src/DocsApp.jsx
Adds MeshDecimator sources/headers to project and test CMake lists, suggests a triangle-budget validator row when total tris > 10,000, and documents qtmesh decimate and Performance/Concepts guidance in the website docs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 I nibble vertices, lop triangles with grace,
One pass through the mesh—lighter shapes in their place.
Slider whispers preview, then Apply with a hop,
JSON and text tell the tale of each chop,
Hop, report, and repeat — a tidier mesh crop.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.51% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding single-pass mesh decimation functionality as part of Phase 6 slice D, which is the primary focus of this changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description comprehensively covers summary, technical details, features, and manual testing. All template sections are well-populated with specific implementation details.

✏️ 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/phase6-slice-d-decimation-polish

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: b151fe844c

ℹ️ 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/MCPServer.cpp Outdated
Comment on lines +2853 to +2863
for (Ogre::SceneNode* node : Manager::getSingleton()->getSceneNodes()) {
if (!node) continue;
for (unsigned i = 0; i < node->numAttachedObjects(); ++i) {
Ogre::MovableObject* obj = node->getAttachedObject(i);
if (obj && obj->getMovableType() == "Entity") {
target = static_cast<Ogre::Entity*>(obj);
break;
}
}
if (target) break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Decimate the selected entity, not the first scene entity

decimate_mesh checks that something is selected, but then ignores selection and decimates the first Entity found in the scene graph. In scenes with multiple meshes, this can mutate the wrong asset even when the user explicitly selected a different mesh, which is a destructive mismatch for this tool.

Useful? React with 👍 / 👎.

Comment thread src/CLIPipeline.cpp Outdated
Comment on lines +3631 to +3633
if (arg == "--target-tris" && i < argc) {
out.targetTris = QString::fromLocal8Bit(argv[i++]).toInt();
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject invalid numeric targets in decimate CLI parsing

This conversion path accepts non-numeric --target-tris values as 0 (Qt's toInt() fallback), which then gets interpreted as a request for maximum reduction (kMaxReduction = 95%). A typo like --target-tris foo can therefore trigger an aggressive decimation instead of failing fast with a usage error.

Useful? React with 👍 / 👎.

Comment thread src/MCPServer.cpp
Comment on lines +2895 to +2900
const DecimationReport report = dryRun
? MeshDecimator::projectEntity(target, reduction)
: MeshDecimator::decimateEntity(target, reduction);

QJsonObject result = makeSuccessResult(MeshDecimator::toText(report));
result["decimation"] = MeshDecimator::toJson(report);

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 Return MCP error when decimation was not applied

The tool always returns makeSuccessResult(...) even when dry_run is false and MeshDecimator::decimateEntity reports applied == false (e.g., generator failure). This makes automation think the mesh was modified when it was not, unless every caller inspects the nested report flags manually.

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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/CLIPipeline.cpp`:
- Around line 3627-3637: The argument parsing for --reduction, --target-tris and
--target-verts must validate numeric conversion and enforce that exactly one
target mode is specified: when handling each flag (the --reduction,
--target-tris, --target-verts branches that set out.reduction, out.targetTris,
out.targetVerts) use the QString::toDouble(const QString&, bool *ok) /
QString::toInt(const QString&, bool *ok) overload (or
QDoubleValidator/QIntValidator) to detect invalid input and, on failure, log an
error and abort/return a failure; after parsing, count how many of out.reduction
(non-default/was-set), out.targetTris (non-default/was-set) and out.targetVerts
(non-default/was-set) were specified and if not exactly one, emit an error and
fail. Also apply the same strict numeric validation to the other occurrence
noted around the 3684–3687 block.

In `@src/MCPServer.cpp`:
- Around line 2852-2865: The code currently finds the first Ogre::Entity in
getSceneNodes() and ignores the user's selection, causing decimation to run on
the wrong mesh; change the selection logic to use the editor/manager selection
API to find the first selected entity (instead of iterating all scene nodes):
query Manager::getSingleton() for the current selection (e.g. selected
nodes/entities) and iterate those to set target (the Ogre::Entity* target
variable) from the first selected Ogre::MovableObject whose getMovableType() ==
"Entity", falling back to the existing error via makeErrorResult("No entity in
scene to decimate.") only if no selected entity is found.

In `@src/MeshDecimator.cpp`:
- Around line 131-136: The early return in MeshDecimator when Ogre::Exception is
thrown (inside the try around Ogre::MeshLodGenerator::generateLodLevels) leaves
totalTrianglesAfter as 0 while per-submesh trianglesAfter still equal
trianglesBefore, making the returned report inconsistent; update the
exception-handling path to compute and set report.totalTrianglesAfter to the sum
of report.subMeshResults[*].trianglesAfter (or copy report.totalTrianglesBefore
into totalTrianglesAfter) and ensure report.applied is set to false before
returning so the MeshDecimator report remains internally consistent.
- Around line 93-96: The code forces sr.trianglesAfter to at least 1 even when
sr.trianglesBefore is 0, producing triangles for empty submeshes; modify the
logic in MeshDecimator.cpp around the prediction so that if sr.trianglesBefore
== 0 you set sr.trianglesAfter = 0 (and skip the rounding/prediction) instead of
applying the Math.max(...,1) fallback, while still correctly updating
report.totalTrianglesBefore and any other report counters as appropriate; update
references to sr.trianglesBefore, sr.trianglesAfter and report.appliedReduction
accordingly so empty submeshes remain zero in dry-run reports.
🪄 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: 181a0f24-c3cc-4630-a517-b2c6ea590eca

📥 Commits

Reviewing files that changed from the base of the PR and between c5a02fc and b151fe8.

📒 Files selected for processing (12)
  • src/CLIPipeline.cpp
  • src/CLIPipeline.h
  • src/CMakeLists.txt
  • src/MCPServer.cpp
  • src/MCPServer.h
  • src/MeshDecimator.cpp
  • src/MeshDecimator.h
  • src/MeshDecimator_test.cpp
  • src/MeshValidator.cpp
  • src/main.cpp
  • tests/CMakeLists.txt
  • website/src/DocsApp.jsx

Comment thread src/CLIPipeline.cpp Outdated
Comment thread src/MCPServer.cpp Outdated
Comment thread src/MeshDecimator.cpp Outdated
Comment thread src/MeshDecimator.cpp
…499)

Four CodeRabbit P1/P2 findings:

1. MCP decimate_mesh was decimating the FIRST scene entity instead of
   the SELECTED one — destructive mismatch in multi-mesh scenes. Now
   reads SelectionSet::getResolvedEntities() and operates on the first
   selected entity. Error if nothing is selected.

2. CLI numeric inputs (--reduction / --target-tris / --target-verts)
   silently accepted non-numeric values via Qt's toInt() fallback (0),
   which then triggered max-reduction (95%). Added parseStrictInt /
   parseStrictDouble helpers that emit a clear error and abort. Also
   added "exactly one target mode" enforcement so callers can't pass
   ambiguous combinations like `--reduction 0.5 --target-tris 1000`.

3. MCP decimate_mesh returned makeSuccessResult even when
   decimateEntity reported applied=false. Now returns an error in that
   case (only when !dry_run && reduction>0), so automation doesn't
   assume the mesh was modified.

4. MeshDecimator::projectEntity invented triangles for empty submeshes
   (the max(1, ...) clamp). Now stays at 0 for empty submeshes; the
   clamp only kicks in for submeshes that had triangles to begin with.

5. MeshDecimator::decimateEntity on LOD generation failure left
   totalTrianglesAfter at 0 while per-submesh trianglesAfter mirrored
   trianglesBefore — internally inconsistent. The failure path now
   sets total = before so consumers see a clean "nothing happened"
   report.

SonarCloud minors:

- MeshDecimator::countBaseline hoisted to a public static so both
  the CLI and MCP wrappers stop duplicating the tri/vert counting
  loop. CLI's int currentTris=0, currentVerts=0 declaration now in
  separate statements (S1659).
- cmdDecimate's `node` is now pointer-to-const (S5350).
- MCP's countMeshBaseline helper removed in favour of the public one.

The toolDecimateMesh's S5817 ("should be const") and the S3776
(cognitive complexity 34) remain — the former is the standard NOSONAR
for the non-const ToolHandler convention; the latter sits at 27 now
after extracting resolveMcpReduction. Sonar may still flag it; if it
does I'll split further.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@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: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/CLIPipeline.cpp`:
- Around line 3730-3744: The current validation ensures --output is provided but
doesn’t reject an output that equals the input, so add a guard after the
existing QFileInfo fi(cmdArgs.filePath) check: construct a QFileInfo for
cmdArgs.outputPath (e.g., QFileInfo outFi(cmdArgs.outputPath)),
canonicalize/absolute both paths (use canonicalFilePath() or absoluteFilePath())
and compare them; if they are equal, emit an error via err() describing that
output must not point to the input (same wording as the other message) and
return the same failure code (e.g., return 2) to prevent in-place overwrite;
reference cmdArgs.outputPath, cmdArgs.filePath, and QFileInfo/outFi when
implementing this check.
- Around line 3753-3767: The code only decimates the first imported entity
(entities.first()) causing partial exports when multiple entities are imported;
either fail fast if multi-entity scenes aren't supported or aggregate counts and
decimate all entities: after MeshImporterExporter::importer(...) and fetching
entities via Manager::getSingleton()->getEntities(), check entities.size() and
if != 1 return an error; otherwise proceed as today; alternatively compute total
currentTris/currentVerts by iterating all Ogre::Entity* in entities and calling
MeshDecimator::countBaseline for each (or a new countBaseline overload), compute
reduction via resolveReduction, then loop over each entity and apply the
decimation routine to every entity rather than only entities.first(). Ensure all
references to entities.first() are replaced with a per-entity loop when choosing
the multi-entity path.

In `@src/MeshDecimator.cpp`:
- Around line 149-166: The code currently calls mesh->removeLodLevels() and then
runs Ogre::MeshLodGenerator::generateLodLevels(lodConfig); if generation throws
we return with applied=false but the original LOD chain is already deleted;
change this by preserving the original LODs before mutating and restoring them
on failure (or alternatively perform generation on a clone and swap on success):
capture the existing LOD state for mesh (e.g. serialize or copy the LOD
levels/indices from mesh prior to calling mesh->removeLodLevels() or create a
temporary cloned Ogre::Mesh), run Ogre::LodConfig + generator.generateLodLevels
on the clone or after removal, and in the catch block restore the saved LOD data
back to mesh and keep report.applied false and totals consistent; ensure the
code paths reference mesh->removeLodLevels(), Ogre::LodConfig,
generator.generateLodLevels(), and report when implementing the
snapshot-and-restore or clone-and-swap strategy.
🪄 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: 17d7e375-3981-4877-b9ee-d1409eb603a8

📥 Commits

Reviewing files that changed from the base of the PR and between b151fe8 and a27039f.

📒 Files selected for processing (4)
  • src/CLIPipeline.cpp
  • src/MCPServer.cpp
  • src/MeshDecimator.cpp
  • src/MeshDecimator.h
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/MeshDecimator.h

Comment thread src/CLIPipeline.cpp
Comment thread src/CLIPipeline.cpp
Comment thread src/MeshDecimator.cpp Outdated
Adds the live-preview decimation UI the user asked for, mirroring the
existing LOD section's pattern (slider → setMeshLodBias swap → Apply
to commit).

MeshDecimatorController (new) — QML_SINGLETON parallel to
MeshLodController. Owns its own MeshLodGenerator; exposes three
Q_INVOKABLE entry points:
- previewReduction(r): generates a temporary LOD chain at fraction r
  and forces display via setMeshLodBias(1, 1, 1). Cheap-ish but not
  free, so QML debounces with a 150ms Timer to avoid melting Ogre
  with a per-pixel LOD rebuild as the slider moves.
- clearPreview(): drops the temporary LOD and restores the base mesh
  (setMeshLodBias(1, 0, USHRT_MAX) + removeLodLevels).
- applyReduction(r): commits via MeshDecimator::decimateEntity. Emits
  applied(before, after) for QML feedback.

QML — new "Decimate (single-pass)" CollapsibleSection sits right
below LOD Generation in the Object-mode inspector. Slider 0..0.95 in
5% steps, live "Tris: 12,345 → 6,200" readout where the second number
appears in blue while the preview is active, Apply (primary highlight
color) and Reset Preview (red) buttons. Selection-change handler
resets the slider to 50% and clears the feedback line, matching slice
C's pattern.

The Q_PROPERTY-backed baseTriangleCount lazy-fills the first time
it's read with a live selection — handles the case where the singleton
is created after the initial scene load. Cast away const on the
internal cache write because m_baseTriangleCount is implementation
metadata, not externally observable state.

The CLI / MCP / scan rule from earlier slice D commits stays as-is —
the validator's "Tri budget" suggestion now has both an in-app and a
headless action path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@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.

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

69-78: 💤 Low value

The const_cast for lazy initialization is acceptable but consider mutable.

The lazy-refresh pattern with const_cast works but using mutable on the cache field would be more idiomatic C++ for this pattern.

Alternative using mutable

In the header, change:

-    int m_baseTriangleCount = 0;
+    mutable int m_baseTriangleCount = 0;

Then the getter can call refreshBaseline() without the cast:

 int MeshDecimatorController::baseTriangleCount() const
 {
     if (m_baseTriangleCount == 0 && hasSelection()) {
-        const_cast<MeshDecimatorController*>(this)->refreshBaseline();
+        refreshBaseline(); // with refreshBaseline() also marked const
     }
     return m_baseTriangleCount;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MeshDecimatorController.cpp` around lines 69 - 78, The getter
baseTriangleCount() currently uses const_cast to lazily call refreshBaseline();
instead, mark the cache field m_baseTriangleCount as mutable in
MeshDecimatorController's class declaration so the const method can update it
without casting, then remove the const_cast and call refreshBaseline() directly
(keeping uses of hasSelection() and refreshBaseline() intact).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/MeshDecimatorController.cpp`:
- Around line 69-78: The getter baseTriangleCount() currently uses const_cast to
lazily call refreshBaseline(); instead, mark the cache field m_baseTriangleCount
as mutable in MeshDecimatorController's class declaration so the const method
can update it without casting, then remove the const_cast and call
refreshBaseline() directly (keeping uses of hasSelection() and refreshBaseline()
intact).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 06e38808-b945-403f-a5b1-b38284a77084

📥 Commits

Reviewing files that changed from the base of the PR and between a27039f and 0376bcb.

📒 Files selected for processing (5)
  • qml/PropertiesPanel.qml
  • src/CMakeLists.txt
  • src/MeshDecimatorController.cpp
  • src/MeshDecimatorController.h
  • tests/CMakeLists.txt
✅ Files skipped from review due to trivial changes (1)
  • src/CMakeLists.txt
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/CMakeLists.txt

User feedback: the Decimate slider and Apply button did nothing.
Root cause: even though MeshDecimatorController uses QML_ELEMENT +
QML_SINGLETON in its header, the QtMeshEditor harness explicitly
registers each singleton via qmlRegisterSingletonType in
MainWindow::registerQml. Without the registration, the QML import
silently fell back to no-op stubs.

Added the registration block right next to MeshLodController's so
the controllers stay paired. App relaunched; slider now generates
the preview LOD and Apply commits the reduction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/mainwindow.cpp`:
- Around line 490-494: MainWindow's destructor misses tearing down the
MeshDecimatorController singleton; add a call to MeshDecimatorController::kill()
into MainWindow::~MainWindow() alongside the other controller teardown calls
(same place where MeshLodController::kill(), MeshValidator::kill(), etc. are
invoked) so the instance created via
MeshDecimatorController::qmlInstance()/qmlRegisterSingletonType is properly
destroyed and resources are released.
🪄 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: a561fefc-66ea-4928-9933-147d54821126

📥 Commits

Reviewing files that changed from the base of the PR and between 0376bcb and d994265.

📒 Files selected for processing (1)
  • src/mainwindow.cpp

Comment thread src/mainwindow.cpp
Comment on lines +490 to +494
qmlRegisterSingletonType<MeshDecimatorController>(
"PropertiesPanel", 1, 0, "MeshDecimatorController",
[](QQmlEngine* engine, QJSEngine*) -> QObject* {
return MeshDecimatorController::qmlInstance(engine, nullptr);
});

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify MeshDecimatorController singleton lifecycle API and teardown usage.

# 1) Locate controller definition/implementation files.
fd -i "MeshDecimatorController" src

# 2) Check for singleton/lifecycle methods (qmlInstance/instance/kill).
rg -n -C3 '\bMeshDecimatorController::(qmlInstance|instance|kill)\b|\bclass\s+MeshDecimatorController\b' src

# 3) Compare teardown calls in MainWindow destructor area.
rg -n -C5 '::kill\(\)' src/mainwindow.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 3506


🏁 Script executed:

# Read MeshDecimatorController.h for lifecycle/design comments
head -50 src/MeshDecimatorController.h

# Read MeshDecimatorController.cpp implementation details
head -80 src/MeshDecimatorController.cpp

# Check destructor context for explanatory comments
sed -n '315,350p' src/mainwindow.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 5677


Add missing MeshDecimatorController::kill() call in the MainWindow destructor.

The MeshDecimatorController singleton is registered and instantiated via qmlInstance() but lacks teardown, unlike all 11 other singleton controllers (MeshLodController, MeshValidator, etc.) that are explicitly destroyed in MainWindow::~MainWindow(). Add MeshDecimatorController::kill(); in the destructor's teardown sequence to prevent resource leak and maintain lifecycle symmetry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mainwindow.cpp` around lines 490 - 494, MainWindow's destructor misses
tearing down the MeshDecimatorController singleton; add a call to
MeshDecimatorController::kill() into MainWindow::~MainWindow() alongside the
other controller teardown calls (same place where MeshLodController::kill(),
MeshValidator::kill(), etc. are invoked) so the instance created via
MeshDecimatorController::qmlInstance()/qmlRegisterSingletonType is properly
destroyed and resources are released.

Clears the remaining issues Sonar flagged after the Decimate Inspector
section + QML-singleton registration landed:

- S859 (const_cast in baseTriangleCount lazy-fill): removed the
  cast-away trick. baseTriangleCount() is now a plain getter again;
  QML's Component.onCompleted calls a new Q_INVOKABLE primeBaseline()
  on section load, which refreshes the cached count and emits
  baseChanged. Same observable behaviour, no UB.

- S3776 (parseDecimateArgs cognitive complexity 35): extracted
  applyDecimateArg as a per-token helper so the main loop is a thin
  while + dispatch. Complexity drops below the 25 threshold.

- S6004 (init-statement for modesProvided): folded into the if's
  init-statement form, matching the slice A/B/C convention.

- S5350 (sel pointer-to-const) in MCPServer.cpp and
  MeshDecimatorController.cpp: applied. SelectionSet::getResolvedEntities
  is const, so the pointer never needs to be mutable.

Remaining Sonar items are documented false positives:
- MCPServer.cpp S5817 — non-const tool method (NOSONAR'd, matches
  the rest of the registry).
- MeshDecimator.cpp + Controller.cpp S995 — entity pointers are
  non-const because the functions exist specifically to mutate the
  underlying mesh.
- MeshDecimatorController.cpp S5025 (singleton new/delete) — the
  whole *Controller fleet uses this pattern; fixing it requires a
  cross-cutting cleanup of all controllers, out of scope for slice D.

Manual smoke: qtmesh decimate media/models/ninja.mesh --target-tris
500 -o /tmp/n.mesh still produces 1,008 → 492 (51.2% effective),
exit 0. Strict-arg rejection and exactly-one-target enforcement
still work end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@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.

🧹 Nitpick comments (2)
src/MeshDecimatorController.cpp (2)

136-149: ⚡ Quick win

Add breadcrumb tracking for clear preview action.

This user-facing action should be tracked with SentryReporter::addBreadcrumb to maintain observability. As per coding guidelines, discrete user actions should be logged for diagnostic purposes.

📊 Suggested breadcrumb addition
 void MeshDecimatorController::clearPreview()
 {
     if (!m_hasPreview) return;
 
+    SentryReporter::addBreadcrumb("ui.action", "Clear decimation preview");
+
     for (Ogre::Entity* entity : decimateTargets()) {

As per coding guidelines: "All user-facing actions and significant operations must be tracked with SentryReporter::addBreadcrumb(category, message) using categories: "ui.action" for toolbar/menu clicks".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MeshDecimatorController.cpp` around lines 136 - 149, The clearPreview
user action in MeshDecimatorController::clearPreview lacks breadcrumb tracking;
add a call to SentryReporter::addBreadcrumb("ui.action", "clearPreview") at the
start (or just before m_hasPreview is set false) so the UI action is recorded;
modify the function to invoke SentryReporter::addBreadcrumb with category
"ui.action" and a descriptive message (e.g., "clearPreview") around the existing
logic in clearPreview to satisfy observability guidelines.

89-134: 💤 Low value

Consider breadcrumb tracking for preview operations.

While applyReduction properly tracks user actions with SentryReporter::addBreadcrumb, previewReduction does not. As per coding guidelines, user-facing actions should be tracked. However, since this method is likely called frequently during slider movements, adding a breadcrumb for every call could create noise in telemetry.

Consider either:

  1. Adding a rate-limited breadcrumb (e.g., only log every Nth call or use a debounce timer)
  2. Adding a breadcrumb only when the reduction crosses significant thresholds
  3. Deferring breadcrumb tracking if preview is considered a low-priority diagnostic signal

As per coding guidelines: "All user-facing actions and significant operations must be tracked with SentryReporter::addBreadcrumb(category, message) using categories: "ui.action" for toolbar/menu clicks".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MeshDecimatorController.cpp` around lines 89 - 134, Add Sentry
breadcrumbs for user-facing preview actions inside
MeshDecimatorController::previewReduction but avoid noise by rate-limiting:
implement a simple debounce/count/threshold check (e.g., keep a
lastPreviewBreadcrumbTime or a previewBreadcrumbCounter as a member) and only
call SentryReporter::addBreadcrumb("ui.action",
QString("previewReduction:%1").arg(r)) when the debounce interval has elapsed or
when the reduction value crosses a configured significance threshold; mirror the
breadcrumb format used in applyReduction so telemetry is consistent and ensure
the new member(s) are updated in previewReduction before early returns.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/MeshDecimatorController.cpp`:
- Around line 136-149: The clearPreview user action in
MeshDecimatorController::clearPreview lacks breadcrumb tracking; add a call to
SentryReporter::addBreadcrumb("ui.action", "clearPreview") at the start (or just
before m_hasPreview is set false) so the UI action is recorded; modify the
function to invoke SentryReporter::addBreadcrumb with category "ui.action" and a
descriptive message (e.g., "clearPreview") around the existing logic in
clearPreview to satisfy observability guidelines.
- Around line 89-134: Add Sentry breadcrumbs for user-facing preview actions
inside MeshDecimatorController::previewReduction but avoid noise by
rate-limiting: implement a simple debounce/count/threshold check (e.g., keep a
lastPreviewBreadcrumbTime or a previewBreadcrumbCounter as a member) and only
call SentryReporter::addBreadcrumb("ui.action",
QString("previewReduction:%1").arg(r)) when the debounce interval has elapsed or
when the reduction value crosses a configured significance threshold; mirror the
breadcrumb format used in applyReduction so telemetry is consistent and ensure
the new member(s) are updated in previewReduction before early returns.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cd1ca440-5dc6-4c68-b857-836064d14000

📥 Commits

Reviewing files that changed from the base of the PR and between d994265 and 8b9df98.

📒 Files selected for processing (5)
  • qml/PropertiesPanel.qml
  • src/CLIPipeline.cpp
  • src/MCPServer.cpp
  • src/MeshDecimatorController.cpp
  • src/MeshDecimatorController.h
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/MeshDecimatorController.h
  • qml/PropertiesPanel.qml
  • src/CLIPipeline.cpp
  • src/MCPServer.cpp

fernandotonon and others added 6 commits May 12, 2026 19:22
…499)

User feedback after the QML-registration fix landed: app now crashes
on startup (SIGABRT, exit code 134) before the window appears.

Root cause: Ogre::MeshLodGenerator is itself an Ogre::Singleton<> CRTP.
MeshLodController constructs one in its ctor, and my new
MeshDecimatorController was constructing a second one in *its* ctor.
The second construction trips Ogre's singleton check and throws,
aborting the app during MainWindow's qmlRegisterSingletonType call.

Fix: drop the unique_ptr<MeshLodGenerator> member from
MeshDecimatorController and reach the shared instance via
Ogre::MeshLodGenerator::getSingleton() at the use site
(previewReduction). Same instance MeshLodController already owns.

Header cleanup falls out: forward decl, include of <memory>, and
the out-of-line destructor are no longer needed. App launches
cleanly with PID assigned and stays running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ity (#499)

User feedback: preview worked but Apply consistently failed with
"Decimation failed: MeshLodGenerator could not produce a reduced mesh"
on every mesh — imported assets and primitives alike.

Root cause: same singleton-double-construction trap I fixed in the
controller earlier, but lurking inside MeshDecimator::decimateEntity
itself:

    try {
        Ogre::MeshLodGenerator generator;  // local construction → throws
        generator.generateLodLevels(lodConfig);
    } catch (...) { /* swallowed → applied=false */ }

Ogre::MeshLodGenerator is a Singleton<>; MeshLodController owns the
process-wide instance. The local construction tripped Ogre's "already
has a single instance" assertion, which the catch swallowed into the
generic "Decimation failed" message. The preview path worked because
the controller's new previewReduction() reaches the shared instance
via getSingleton().

Two fixes:

- MeshDecimator::decimateEntity now goes through a new
  sharedLodGenerator() helper that returns the live singleton when
  it exists (the GUI path) and lazy-constructs it otherwise (the
  CLI / MCP / test paths where no MeshLodController is around).
  Mirrors the "Ogre singletons live for the process lifetime"
  expectation; intentional leak on the lazy branch.

- MeshDecimatorController::previewReduction now uses
  getSingletonPtr() with a null check, so the controller never
  triggers the lazy-construct path — the GUI always has MeshLodController
  built first anyway.

Verified:
- qtmesh decimate media/models/ninja.mesh --target-tris 500 -o /tmp/x.mesh
  → 1,008 → 492 tris, applied, exit 0.
- App launches cleanly; Decimate section's slider + Apply both work
  on imported meshes and primitives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…no-op (#499)

User feedback: opening the Decimate section pre-loaded a 50% reduction
preview onto the viewport, which made the mesh shrink before the user
had a chance to interact. Now the slider starts at 0% — the previewer
treats r<=0 as "no preview" (clearPreview path) so the original mesh
stays visible until the user actually drags.

Also updates the selection-change handler to reset to 0%, matching
the new default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: clicking Apply correctly updates the mesh, but the
floating mesh-info overlay still shows the old tri/draws/GPU numbers
until the user toggles the overlay off and back on.

Root cause: the overlay listens for Manager::entityCreated /
sceneNodeDestroyed and SelectionSet::selectionChanged. None of those
fire when an in-place index-buffer mutation happens — the entity is
the same, the scene node is the same, the selection is the same.

Fix: subscribe the overlay to MeshDecimatorController::applied so it
refreshes on every committed reduction. The signal carries before/
after counts, which we ignore here — refresh() re-reads from Ogre
directly so the new numbers come from the source of truth.

This is the pattern future in-place mutators should follow: emit a
signal from the controller; have any view subscribed to it refresh.
Future option for cleanliness is a generic Manager::meshChanged
signal, but the current 1:1 connection keeps the layering clear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three real findings from CodeRabbit's review of the live-preview push:

1. CLI: -o pointing back to the input file silently overwrote the
   source asset despite the error text promising otherwise. Now
   compares canonical paths (handles symlinks + relative paths;
   falls back to absolute when the output file doesn't yet exist)
   and rejects with exit 2 before importing anything.

2. CLI: multi-entity imports silently decimated only the first
   entity, exporting a partially-reduced scene while the report
   looked authoritative. cmdDecimate now fails fast when
   entities.size() > 1, pointing at a future scene-decimation
   slice as the path forward.

3. MeshDecimator::decimateEntity: line 168 wiped the existing LOD
   chain before the generator ran. If generateLodLevels() threw,
   the function returned applied=false but the mesh had still lost
   its pre-existing LODs — a "failure" was destructive for in-
   memory callers. Now snapshots each submesh's mLodFaceList
   before clearing, restores it on the exception path, and frees
   the saved IndexData* only on success.

Manual smoke:
- `qtmesh decimate ninja.mesh -o ninja.mesh ...` → exit 2 with
  the targeted error.
- `qtmesh decimate ninja.mesh -o /tmp/n.mesh --target-tris 500` →
  exit 0, 1,008 → 492 tris (51% effective).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The LOD-chain snapshot/restore in the previous CR-fix commit pushed
MeshDecimator::decimateEntity over Sonar's S3776 complexity ceiling
(26 vs 25 allowed).

Extracted four single-purpose helpers in the anonymous namespace:
- snapshotLodFaceLists(mesh) → save + clear per-submesh mLodFaceList
- restoreLodFaceLists(mesh, snapshot) → put them back on failure
- freeLodSnapshot(snapshot) → release after success
- promoteFirstLodToBase(mesh) → swap LOD-1 index data into the base
  slot and remove the (now-emptied) LOD chain

decimateEntity itself is now a thin orchestrator: count baseline,
snapshot, generate, on-failure restore, on-success free + promote +
re-count. Behaviour preserved (qtmesh decimate ninja.mesh
--target-tris 500 still produces 1,008→492).

Remaining Sonar items on the PR are all the documented false-
positive class (S5025 singleton new/delete pattern matching every
*Controller, S5817 NOSONAR'd tool method, S995/S5350 on
must-be-mutable Ogre pointers, S1116 Q_UNUSED) — same call as
slices A/B/C accepted before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@fernandotonon
fernandotonon merged commit 41da5bd into master May 13, 2026
20 checks passed
@fernandotonon
fernandotonon deleted the feat/phase6-slice-d-decimation-polish branch May 13, 2026 02:20
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