Skip to content

feat(quads): preserve n-gons through export (chunk 6) - #349

Merged
fernandotonon merged 4 commits into
masterfrom
feat/quads-export-and-tests
Apr 30, 2026
Merged

feat(quads): preserve n-gons through export (chunk 6)#349
fernandotonon merged 4 commits into
masterfrom
feat/quads-export-and-tests

Conversation

@fernandotonon

@fernandotonon fernandotonon commented Apr 30, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the chunk-6 acceptance criterion of #326: FBX/glTF/OBJ I/O preserves quad structure end-to-end. Both the Assimp-based exporter (glTF/OBJ/Collada/STL/etc.) and the custom binary FBX writer now emit source n-gons instead of fan-triangulated triangles.

Approach: cache n-gons on the Ogre::Mesh

`Ogre::Mesh::UserObjectBindings` was already the chunk-3/4 carrier for `qtme.source_path` / `qtme.source_convert_lh` / `qtme.source_up_axis`. Add a fourth: per-submesh `qtme.faces.` holding `std::vector<std::vector>` of polygon vertex indices.

  • Import (`MeshImporterExporter::meshImporter`): re-runs a one-shot `EditableMesh::loadFromAssimpFile` and writes the binding right next to the existing source-path cache. Adds ~10 LOC.
  • Edit-mode commit (`EditableMesh::commitToEntity` / `resizeEntityBuffers`): refresh the binding from current `EditableSubMesh::faces` so post-edit n-gon structure travels with the live mesh even after `qtme.source_path` is wiped.
  • Assimp exporter (`readSubmeshGeometry`): when the binding is present, override `aiPrimitiveType` to `POLYGON` and emit one `aiFace` per polygon with N indices.
  • Custom FBX exporter (`writeGeometryObjects`): emit n-gon `PolygonVertexIndex` with the FBX last-index-bitwise-NOT polygon-end convention. Per-PV layers (Normal/UV/Color) now consume a single `polyVertexOrder` vector instead of independently re-walking the triangle index buffer — net -80 LOC of duplicated triangle-walking code, and the n-gon path falls out for free.

What's NOT in this PR

  • Ogre `.mesh` exporter — the .mesh format only stores triangle index buffers, no native polygon support. Skipped per the design discussion. Re-imports of .mesh files keep losing n-gons until we move the editor away from Ogre's serializer (out of scope for chunk 6).

Test plan

  • +4 Ogre-bound unit tests in `EditableMesh_test.cpp` — helper round-trip, tri-only flip erases binding, absent binding returns false, commit refreshes binding. Skipped on macOS (Ogre init bypass per CLAUDE.md), runs on Linux CI.
  • All 232 standalone topology + EditableMesh tests still pass on macOS
  • Build clean across both targets
  • CI green on Linux (will validate via this PR)
  • Manual smoke: import quad cube → export glTF → re-import → assert 6 quads in `EditableSubMesh::faces` (deferred to local follow-up)

Acceptance criterion status (#326)

  • EditableMesh round-trips n-gon faces losslessly through HalfEdgeMesh
  • FBX/glTF/OBJ I/O preserves quad structure end-to-endthis PR
  • Catmull-Clark subdivide path documented and tested
  • Loop cut implemented and bound to Ctrl+R
  • No regression on triangle-only assets
  • Every existing topology op has a quad-mesh unit test (audit pending — separate follow-up PR)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • FBX export now preserves n-gon (non-triangle) polygon structures.
    • Importer writes and restores polygon face data so original polygon layout survives edit/export cycles.
  • Bug Fixes

    • Restores cached polygon topology after committing edits, preventing unintended triangulation.
  • Tests

    • Added/updated unit tests to validate n-gon caching, restore, and export behavior.
  • Chores

    • Project version bumped to 2.33.0.

Stash per-submesh n-gon face data on the live Ogre::Mesh via
UserObjectBindings (qtme.faces.<i>) so exporters recover the source
polygon structure without re-reading the file — even after edits
clear the qtme.source_path tag.

- writeNgonFacesToMesh / readNgonFacesFromMesh helpers in
  EditableMesh.{h,cpp} marshal a per-submesh
  std::vector<std::vector<unsigned int>> through Ogre::Any.
- MeshImporterExporter import path runs a one-shot
  EditableMesh::loadFromAssimpFile to extract source faces and write
  the binding alongside the existing chunk-3 source_path tag.
- commitToEntity / resizeEntityBuffers refresh the binding on every
  edit so the post-edit n-gon structure persists.
- Assimp scene builder (readSubmeshGeometry) reads the binding and
  emits aiFace with N indices when present; falls back to the
  triangle index buffer for legacy .mesh assets.
- Custom FBX exporter (writeGeometryObjects) reads the binding,
  emits n-gon PolygonVertexIndex with the FBX last-index-bitwise-NOT
  end marker, and per-PV layers (Normal / UV / Color) now expand via
  a single polyVertexOrder traversal that supports both n-gon and tri
  paths — eliminates ~80 LOC of duplicated triangle-walking code.

+4 Ogre-bound unit tests covering helper round-trip, binding-erase
on tri-only flip, missing-binding read, and commit-time refresh.
Skipped on macOS (Ogre init bypass per CLAUDE.md), exercised on
Linux CI.

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

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Persists per-submesh N-gon face topology onto live Ogre::Mesh user bindings (qtme.faces.<i>), restores that topology during mesh load, and updates importer/exporter paths (including FBX) to prefer cached polygon data over triangle fallbacks; also extends EditableMesh::loadFromAssimpFile with an additionalFlags parameter.

Changes

Cohort / File(s) Summary
EditableMesh API
src/EditableMesh.h, src/EditableMesh.cpp
Added writeNgonFacesToMesh and readNgonFacesFromMesh functions and changed EditableMesh::loadFromAssimpFile signature to accept unsigned int additionalFlags. Persists per-submesh polygon index lists into qtme.faces.<i> bindings and rehydrates EditableSubMesh::faces from them.
EditableMesh Tests
src/EditableMesh_test.cpp
New unit tests for n-gon binding write/read round-trip, binding cleanup when faces are empty, missing-binding behavior, and commit-to-entity cache refresh.
FBX Exporter
src/FBX/FBXExporter.cpp
Consumes cached n-gon faces when available to build FBX polygon indices (uses negative polygon-end markers with reversed winding), validates face bounds, falls back to triangle path on invalid data, and unifies attribute expansion using a shared polyVertexOrder.
Mesh Importer/Exporter
src/MeshImporterExporter.cpp
readSubmeshGeometry now prefers cached n-gon lists (emits aiFace per polygon and sets primitive types accordingly); Assimp-based import path now probes with EditableMesh and writes n-gon metadata back onto resulting Ogre mesh.
Edit-mode tests
src/EditModeController_test.cpp
Updated test expectations to assert that submesh faces are preserved after commit/re-entering edit mode due to n-gon rehydration from bindings.
Build metadata
CMakeLists.txt
Bumped project version from 2.32.1 to 2.33.0.

Sequence Diagram(s)

sequenceDiagram
    participant Assimp as Assimp Importer
    participant Editable as EditableMesh (probe)
    participant Ogre as Ogre::Mesh
    participant Exporter as Exporter (FBX/Assimp)
    participant File as Output File

    Assimp->>Editable: loadFromAssimpFile(path, flags...)
    Editable->>Ogre: writeNgonFacesToMesh(qtme.faces.<i> = polygons)
    Note over Ogre: qtme.faces.0, qtme.faces.1, ...

    Exporter->>Ogre: readNgonFacesFromMesh(subMeshIndex)
    alt ngon data present
        Ogre-->>Exporter: polygon vertex lists
        Exporter->>Exporter: build PolygonVertexIndex (reversed winding)
        Exporter->>Exporter: generate polyVertexOrder, expand normals/UVs/colors
    else fallback to triangles
        Ogre-->>Exporter: triangle index buffers
        Exporter->>Exporter: build triangle-based polyVertexOrder and expand attributes
    end
    Exporter->>File: write geometry (polygons or triangles)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I cached each polygon, snug in a seam,
qtme.faces humming softly in dream.
Exporters nibble the polygons found,
No more triangles tumbling around. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.32% 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 describes the main objective: preserving n-gons (quads) through export as part of chunk 6 implementation.
Description check ✅ Passed The description is comprehensive, following the template structure with clear Summary and Technical Details sections covering features, approach, test plan, and acceptance criterion status.
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.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quads-export-and-tests

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
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

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: 5ca71f67e2

ℹ️ 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 on lines +1228 to +1231
if (ngonProbe.loadFromAssimpFile(
sourcePath, convertLH,
importer.getSceneUpAxis() == 2)) {
writeNgonFacesToMesh(mesh.get(), ngonProbe.subMeshes());

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 Generate n-gon cache from the same Assimp pipeline

Build the qtme.faces.<i> cache from the exact mesh-processing path used to create mesh, not a second EditableMesh::loadFromAssimpFile pass. AssimpToOgreImporter::loadModel uses a broader/different post-process flag set (including aiProcess_SortByPType, aiProcess_OptimizeMeshes, and additionalFlags), while this probe call does not, so submesh ordering and vertex indices can diverge. When exporters later trust the cached polygons, they can emit wrong topology (and in downstream per-vertex expansion paths, potentially index past valid vertex arrays).

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 the current code and only fix it if needed.

Inline comments:
In `@src/EditableMesh.cpp`:
- Around line 757-762: loadFromMesh()/loadFromEntity() currently rebuild
submeshes solely from the triangle index buffer and thus discards the ngons
cached by writeNgonFacesToMesh() once qtme.source_path is cleared; modify
loadFromMesh() (and loadFromEntity() if it delegates) to call
readNgonFacesFromMesh() after loading the basic mesh, iterate each
EditableSubMesh and if readNgonFacesFromMesh() returned faces for that submesh,
restore EditableSubMesh::faces from that data and regenerate/“fan” its triangles
buffer from the restored faces so subsequent edit sessions preserve n-gon
structure.

In `@src/FBX/FBXExporter.cpp`:
- Around line 868-923: readNgonFacesFromMesh only guarantees binding/type; you
must validate all indices before using polyVertexOrder/polyIndices to avoid
out-of-bounds reads. After calling readNgonFacesFromMesh (in the block that
fills ngonFaces) iterate every polygon and every vertex index and if any vi >=
vData->vertexCount (or otherwise invalid) treat the ngon data as unusable: skip
using ngonFaces and fall back to the triangle/index-buffer path (the else branch
that reads iData/indexBuffer). Update the logic around readNgonFacesFromMesh,
polyVertexOrder, and polyIndices so the fallback happens per-submesh when any
invalid index is found; apply the same index-range validation anywhere else you
consume polygon indices before indexing normals/uvs/colors.

In `@src/MeshImporterExporter.cpp`:
- Around line 353-364: The code unconditionally sets aiM->mPrimitiveTypes =
aiPrimitiveType_POLYGON even though ngonFaces can contain triangles and mixed
faces; compute the bitmask instead by scanning the emitted faces (ngonFaces) and
OR-ing aiPrimitiveType_TRIANGLE when any face has 3 indices and
aiPrimitiveType_POLYGON when any face has !=3 indices, then assign that combined
bitmask to aiM->mPrimitiveTypes before allocating aiM->mFaces; keep the existing
face allocation/assignment (aiM->mNumFaces, aiM->mFaces, aiFace.mNumIndices,
aiFace.mIndices) but ensure the computed bitmask reflects both triangle and
polygon presence.
- Around line 1227-1231: The ngonProbe is being re-imported without the same
import flags, so update calls to EditableMesh::loadFromAssimpFile (used on
ngonProbe) to accept and forward the same additionalFlags passed to
importer.loadModel; then propagate that additionalFlags parameter into
EditableMesh::loadFromAssimpFile implementation so topology-affecting flags
(e.g., aiProcess_FindDegenerates, aiProcess_SortByPType) are applied
consistently, ensuring writeNgonFacesToMesh uses subMeshes() that match the
actual imported mesh topology.
🪄 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: e3b813b0-db77-4fa2-bade-7e2881399f65

📥 Commits

Reviewing files that changed from the base of the PR and between 3e8d507 and 5ca71f6.

📒 Files selected for processing (5)
  • src/EditableMesh.cpp
  • src/EditableMesh.h
  • src/EditableMesh_test.cpp
  • src/FBX/FBXExporter.cpp
  • src/MeshImporterExporter.cpp

Comment thread src/EditableMesh.cpp
Comment thread src/FBX/FBXExporter.cpp
Comment thread src/MeshImporterExporter.cpp Outdated
Comment thread src/MeshImporterExporter.cpp
fernandotonon and others added 3 commits April 30, 2026 02:49
Four CodeRabbit Major findings on the n-gon export PR. All real:

1. Rehydrate cached faces in loadFromMesh (EditableMesh.cpp).
   Once qtme.source_path is wiped after the first commit, the
   qtme.faces.<i> binding is the only carrier for the n-gon
   structure. loadFromMesh ignored the binding and rebuilt
   submeshes triangle-only — the next commit would then re-erase
   the cache, losing n-gons forever. Now reads the binding into
   EditableSubMesh::faces and resyncs the triangle mirror.

2. Validate cached polygon indices in FBX exporter
   (FBX/FBXExporter.cpp). readNgonFacesFromMesh only checks the
   payload type, not index ranges. Stale or out-of-range indices
   would cause OOB reads when the per-PV layers expand
   normals/uvs/colors via polyVertexOrder. Now reject any polygon
   with size < 3 or vi >= vData->vertexCount and fall back to the
   triangle path for that submesh.

3. Compute mPrimitiveTypes as a bitmask (MeshImporterExporter.cpp).
   Hard-coded aiPrimitiveType_POLYGON was wrong: Assimp's
   mPrimitiveTypes is a bitmask of all kinds present, and a mixed
   tri+quad submesh must declare both bits or downstream Assimp
   exporters mis-gate format-specific emission (e.g. STL drops
   non-triangle primitives by checking the bitmask).

4. Pass additionalFlags to the n-gon probe
   (EditableMesh.{h,cpp} + MeshImporterExporter.cpp). The probe
   used a hardcoded flag set while the rendered import passes
   `additionalFlags` through. Topology-affecting flags like
   aiProcess_FindDegenerates / aiProcess_SortByPType can split
   submeshes — without matching flags, qtme.faces.<i> would attach
   to a layout that doesn't match the live Ogre mesh and exporter
   lookups by submesh index would silently mis-target.

All 232 standalone tests pass. Ogre-bound chunk-6 tests skipped on
macOS, exercised on Linux CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EnterEditModeAfterEditDoesNotReimport asserted that re-entering Edit
Mode after a commit produced an empty .faces (legacy triangle-only
path). Pre-chunk-6 that was correct: commit erased qtme.source_path
and loadFromMesh had no n-gon source.

Chunk 6 wrote qtme.faces.<i> on commit and rehydrates from it on
loadFromMesh. The new contract: n-gon faces SURVIVE commit → re-enter,
so n-gon-aware ops keep working post-edit.

Flip the assertion (faces.empty() → !faces.empty()) and explain
the contract change in the comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #349 (chunk 6) introduces user-visible quad preservation through
the FBX and OBJ export paths — that's a minor version bump.

Also documented the per-format n-gon support matrix on
writeNgonFacesToMesh: FBX and OBJ preserve n-gons end-to-end, while
glTF/GLB triangulate at export because the spec only allows
TRIANGLES/POINTS/LINES (no native polygon primitive). Preserving
quads through glTF requires the FB_ngon_encoding vendor extension,
which Assimp doesn't support — out of scope for chunk 6, deferred.
.mesh and other Assimp formats follow the same triangulation rule
they always have.

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

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

2067-2070: ⚡ Quick win

Assert the preserved face arity on re-entry.

Right now this only proves that some face data came back. A regression that rehydrates the mesh as triangle faces would still pass, even though chunk 6 is specifically about preserving the original quad/n-gon structure.

Suggested assertion
     ASSERT_TRUE(ctrl->enterEditMode());
     EXPECT_FALSE(ctrl->currentMesh()->subMeshes()[0].faces.empty())
         << "second entry (post-edit) should rehydrate n-gons from "
            "the qtme.faces.<i> binding";
+    ASSERT_EQ(ctrl->currentMesh()->subMeshes()[0].faces[0].indices.size(), 4u)
+        << "post-edit re-entry should preserve the original quad, not just any face list";

As per coding guidelines, src/**/*_test.cpp: Add Google Test unit tests for new functionality.

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

In `@src/EditModeController_test.cpp` around lines 2067 - 2070, The test currently
only verifies that faces are non-empty after re-entry
(ASSERT_TRUE(ctrl->enterEditMode()) and
EXPECT_FALSE(ctrl->currentMesh()->subMeshes()[0].faces.empty())), but must
assert that face arity is preserved (i.e. quads/n-gons, not all triangles).
After calling ctrl->enterEditMode(), inspect
ctrl->currentMesh()->subMeshes()[0].faces and add assertions that at least one
face has the expected number of indices (e.g. size()==4 for the quad) or that
the set of faceSizes contains a value >3; use these checks instead of or in
addition to the non-empty assertion so the test fails if faces were rehydrated
as triangles.
🤖 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/MeshImporterExporter.cpp`:
- Around line 348-399: The ngon export path must validate cached polygon
bindings before emitting aiFace arrays: in the branch handling
readNgonFacesFromMesh (symbols: ngonFaces, aiM, readNgonFacesFromMesh, and the
later compactAiMesh reference), check each poly has size() >= 3 and each vertex
index < aiM->mNumVertices before allocating/assigning mIndices; if any polygon
is invalid (too few verts or an out-of-range index) fall back to the
triangle/index buffer path used in the non-ngon branch (reuse the logic that
reads Ogre::IndexData) instead of emitting the malformed aiFace, and ensure you
unlock/free any allocated buffers on early exit.

---

Nitpick comments:
In `@src/EditModeController_test.cpp`:
- Around line 2067-2070: The test currently only verifies that faces are
non-empty after re-entry (ASSERT_TRUE(ctrl->enterEditMode()) and
EXPECT_FALSE(ctrl->currentMesh()->subMeshes()[0].faces.empty())), but must
assert that face arity is preserved (i.e. quads/n-gons, not all triangles).
After calling ctrl->enterEditMode(), inspect
ctrl->currentMesh()->subMeshes()[0].faces and add assertions that at least one
face has the expected number of indices (e.g. size()==4 for the quad) or that
the set of faceSizes contains a value >3; use these checks instead of or in
addition to the non-empty assertion so the test fails if faces were rehydrated
as triangles.
🪄 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: fcd15c8f-7cc1-46dd-802e-519c1f51d9d4

📥 Commits

Reviewing files that changed from the base of the PR and between 5ca71f6 and 5e6bf8c.

📒 Files selected for processing (6)
  • CMakeLists.txt
  • src/EditModeController_test.cpp
  • src/EditableMesh.cpp
  • src/EditableMesh.h
  • src/FBX/FBXExporter.cpp
  • src/MeshImporterExporter.cpp
✅ Files skipped from review due to trivial changes (1)
  • CMakeLists.txt
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/EditableMesh.cpp
  • src/FBX/FBXExporter.cpp

Comment on lines +348 to 399
std::vector<std::vector<unsigned int>> ngonFaces;
const bool hasNgon = readNgonFacesFromMesh(
entity->getMesh().get(), subIndex, ngonFaces);
if (hasNgon)
{
aiM->mNumFaces = static_cast<unsigned int>(iData->indexCount / 3);
// Assimp's mPrimitiveTypes is a bitmask of all primitive kinds
// present. A submesh with mixed quads + triangles must declare
// both bits — Assimp's exporters check the bitmask to gate
// format-specific emission (e.g. STL emits TRIANGLE only).
// (CodeRabbit follow-up on PR #349.)
aiM->mPrimitiveTypes = 0;
aiM->mNumFaces = static_cast<unsigned int>(ngonFaces.size());
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)
const auto& poly = ngonFaces[f];
aiM->mPrimitiveTypes |= (poly.size() == 3)
? aiPrimitiveType_TRIANGLE
: aiPrimitiveType_POLYGON;
aiM->mFaces[f].mNumIndices = static_cast<unsigned int>(poly.size());
aiM->mFaces[f].mIndices = new unsigned int[poly.size()];
for (size_t v = 0; v < poly.size(); ++v) {
aiM->mFaces[f].mIndices[v] = poly[v];
}
}
}
else
{
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)
{
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;
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();
}

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 | ⚡ Quick win

Validate cached polygon bindings before emitting aiFaces.

Line 351 trusts qtme.faces.<i> as-is, but compactAiMesh() later remaps every face index without bounds checks. A stale binding with a <3 vertex polygon or an index >= aiM->mNumVertices can therefore turn into an out-of-bounds access or a corrupt export. The FBX path already defends against this; this Assimp path needs the same validation and should fall back to the triangle buffer when the cache is invalid.

Suggested guard
     std::vector<std::vector<unsigned int>> ngonFaces;
     const bool hasNgon = readNgonFacesFromMesh(
         entity->getMesh().get(), subIndex, ngonFaces);
-    if (hasNgon)
+    const bool hasValidNgon =
+        hasNgon
+        && !ngonFaces.empty()
+        && std::all_of(ngonFaces.begin(), ngonFaces.end(),
+            [aiM](const std::vector<unsigned int>& poly) {
+                if (poly.size() < 3) return false;
+                for (unsigned int idx : poly) {
+                    if (idx >= aiM->mNumVertices) return false;
+                }
+                return true;
+            });
+    if (hasValidNgon)
     {
         // Assimp's mPrimitiveTypes is a bitmask of all primitive kinds
         // present. A submesh with mixed quads + triangles must declare
         // both bits — Assimp's exporters check the bitmask to gate
         // format-specific emission (e.g. STL emits TRIANGLE only).
@@
-    else
+    else
     {
         const Ogre::IndexData* iData = subMesh->indexData;
         if (iData && iData->indexCount > 0)
         {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/MeshImporterExporter.cpp` around lines 348 - 399, The ngon export path
must validate cached polygon bindings before emitting aiFace arrays: in the
branch handling readNgonFacesFromMesh (symbols: ngonFaces, aiM,
readNgonFacesFromMesh, and the later compactAiMesh reference), check each poly
has size() >= 3 and each vertex index < aiM->mNumVertices before
allocating/assigning mIndices; if any polygon is invalid (too few verts or an
out-of-range index) fall back to the triangle/index buffer path used in the
non-ngon branch (reuse the logic that reads Ogre::IndexData) instead of emitting
the malformed aiFace, and ensure you unlock/free any allocated buffers on early
exit.

@sonarqubecloud

Copy link
Copy Markdown

@fernandotonon
fernandotonon merged commit 223191e into master Apr 30, 2026
35 checks passed
@fernandotonon
fernandotonon deleted the feat/quads-export-and-tests branch April 30, 2026 14:27
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