From 20a272f490adbd24b4f282f3a84272cec88d7577 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 02:40:31 -0400 Subject: [PATCH 01/34] ci: also trigger deploy.yml on feat/quads The quad migration (#326) lands chunked PRs onto a long-lived `feat/quads` branch before the final merge to master. Without including `feat/quads` in the workflow's branch list, those chunked PRs run no build/test CI and the rollout has no safety net. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deploy.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ab5a33e2c..cddfe0c38 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,14 @@ name: Deploy -on: +on: push: - branches: [ "master" ] + branches: [ "master", "feat/quads" ] pull_request: - branches: [ "master" ] + # `master` covers shipping work; `feat/quads` is the long-lived + # quad-migration integration branch (#326) that chunked PRs land + # on before the final merge to master. Without it the chunked PRs + # would have no CI safety net. + branches: [ "master", "feat/quads" ] release: types: [published] From 0c13695522d3cba7b6501414a9509cc0fd52f64a Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 02:48:32 -0400 Subject: [PATCH 02/34] ci: extend deploy.yml triggers to cover stacked feat/quads-* PRs Chunk 2 (#328) targets feat/quads-1-foundation (chunk 1) so the diff shows just chunk 2's own work rather than stacking onto the previous chunk's noise. That bypasses the workflow's branch trigger because the previous list only allowed `master` and `feat/quads`. Add the `feat/quads-*` glob to cover stacked chunk PRs. Towards #326. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deploy.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cddfe0c38..b71ecff65 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,13 +2,15 @@ name: Deploy on: push: - branches: [ "master", "feat/quads" ] + branches: [ "master", "feat/quads", "feat/quads-*" ] pull_request: # `master` covers shipping work; `feat/quads` is the long-lived # quad-migration integration branch (#326) that chunked PRs land - # on before the final merge to master. Without it the chunked PRs - # would have no CI safety net. - branches: [ "master", "feat/quads" ] + # on before the final merge to master. The `feat/quads-*` glob + # covers chunked PRs that target a previous chunk's branch + # (stacked PRs). Without these, chunked PRs would have no CI + # safety net. + branches: [ "master", "feat/quads", "feat/quads-*" ] release: types: [published] From ba49a76c2629fa39664cfa3ed05182b4eda7e999 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 02:38:52 -0400 Subject: [PATCH 03/34] =?UTF-8?q?feat(quads):=20chunk=201=20=E2=80=94=20Ed?= =?UTF-8?q?itableFace=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the n-gon (quad-aware) data model that subsequent chunks will wire through GPU upload, importers, topology ops, and exporters. No behavior change for triangle-only meshes — every existing test still passes. Data model: - New EditableFace struct (n-vertex polygon) with isValid() guard. - EditableSubMesh gains a `faces` field alongside the existing `triangles`. Invariant: when `faces` is non-empty it is the canonical face storage and `triangles` mirrors it as a fan-triangulation; when `faces` is empty, `triangles` is canonical (legacy triangle-only mode). - Free helpers `triangulateFaces(sub)` and `promoteTrianglesToFaces(sub)` keep the two representations in sync. Documented with the same fan-triangulation rule HalfEdgeMesh::appendFace uses, so HE round-trips don't change face shape. HalfEdgeMesh: - buildFromEditableMesh prefers `faces` when non-empty; falls back to `triangles` for legacy submeshes. Out-of-range and duplicate-vertex inputs are silently skipped. - toEditableMesh writes any HE n-gon face into both `faces` and a fan-triangulated `triangles`. Submeshes that turn out all-triangle leave `faces` empty so legacy consumers see no diff. - validate() relaxed from "exactly 3 half-edges per face" to "at least 3"; n-gons are now first-class. Tests (+11): - Quad EditableFace round-trips as a single 4-valence HE face. - toEditableMesh preserves quad in `faces` and emits 2 fan triangles in `triangles`. - Triangle-only meshes leave `faces` empty (legacy invariant). - Mixed tri+quad submesh handling. - EditableFace::isValid guard cases. - Out-of-range index in EditableFace silently skipped. - triangulateFaces / promoteTrianglesToFaces unit coverage. Towards #326. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EditableMesh.cpp | 39 ++++++ src/EditableMesh.h | 84 ++++++++++++- src/HalfEdgeMesh.cpp | 137 ++++++++++++++++----- src/HalfEdgeMesh_test.cpp | 242 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 470 insertions(+), 32 deletions(-) diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 5dfd31f52..ea2b2344d 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -35,6 +35,45 @@ THE SOFTWARE. #include #include +void triangulateFaces(EditableSubMesh& sub) +{ + sub.triangles.clear(); + if (sub.faces.empty()) return; + + // Best-effort capacity hint: every face emits N - 2 triangles, so + // for a quad-dominant mesh the average is ~2× the face count. The + // caller may have any mix of polygon sizes, so we don't try to be + // exact here. + sub.triangles.reserve(sub.faces.size() * 2); + + for (const auto& face : sub.faces) { + if (!face.isValid()) continue; + const auto& idx = face.indices; + for (size_t i = 1; i + 1 < idx.size(); ++i) { + EditableTriangle t; + t.indices[0] = idx[0]; + t.indices[1] = idx[i]; + t.indices[2] = idx[i + 1]; + sub.triangles.push_back(t); + } + } +} + +void promoteTrianglesToFaces(EditableSubMesh& sub) +{ + sub.faces.clear(); + sub.faces.reserve(sub.triangles.size()); + for (const auto& tri : sub.triangles) { + EditableFace f; + f.indices = {tri.indices[0], tri.indices[1], tri.indices[2]}; + sub.faces.push_back(std::move(f)); + } + // Note: we deliberately do NOT call triangulateFaces() here. The + // existing `triangles` already mirrors what `faces` would generate + // (each face is a single triangle), so the canonical-faces invariant + // already holds. +} + bool EditableMesh::loadFromEntity(Ogre::Entity* entity) { if (!entity) diff --git a/src/EditableMesh.h b/src/EditableMesh.h index b547086a2..7a38f8926 100644 --- a/src/EditableMesh.h +++ b/src/EditableMesh.h @@ -68,19 +68,99 @@ struct EditableTriangle { unsigned int indices[3] = {0, 0, 0}; }; +/** + * @brief A polygonal face referencing N >= 3 vertices by index. + * + * Used for the n-gon (quad-aware) representation that complements the + * legacy triangle-only path. While the editor is being migrated to + * quads, both `EditableSubMesh::faces` and `EditableSubMesh::triangles` + * coexist: + * + * - When `faces` is empty, the submesh is in legacy triangle-only mode + * and `triangles` is the canonical storage. + * - When `faces` is non-empty, `faces` is canonical and `triangles` is + * a fan-triangulated mirror that downstream code (GPU upload, render, + * legacy topology ops) consumes. Helper utilities keep the two + * representations in sync. + * + * The fan-triangulation rule is `[v0, vi, vi+1]` for `i in [1, N-1)`, + * matching what `HalfEdgeMesh::appendFace` and `fillSelection` already + * produce. Convex polygons (the only case the importer is expected to + * produce) survive that rule cleanly; concave n-gons need a future + * ear-clip pass. + */ +struct EditableFace { + std::vector indices; + + /// @return true if this face has at least 3 vertices and no + /// consecutive duplicate index pair (naive sanity check). + bool isValid() const + { + if (indices.size() < 3) return false; + for (size_t i = 0; i < indices.size(); ++i) { + if (indices[i] == indices[(i + 1) % indices.size()]) return false; + } + return true; + } + + /// @return Number of vertices on this face (>= 3 when valid). + size_t vertexCount() const { return indices.size(); } +}; + /** * @brief An editable copy of a single Ogre SubMesh. * - * Contains all vertices, triangles, and the material name. Tracks whether - * the original SubMesh used shared vertex data. + * Contains all vertices, triangles, faces, and the material name. Tracks + * whether the original SubMesh used shared vertex data. + * + * The `faces` (n-gon) array is the canonical face storage when it is + * non-empty; `triangles` then mirrors it as a fan-triangulation kept in + * sync by `triangulateFaces()`. When `faces` is empty, `triangles` is + * canonical and the submesh is in legacy triangle-only mode. */ struct EditableSubMesh { std::vector vertices; std::vector triangles; + std::vector faces; std::string materialName; bool usesSharedVertices = false; }; +/** + * @brief Fan-triangulate every face in `faces` into `triangles`. + * + * Triangulation rule: each face emits `n - 2` triangles fanned from + * `indices[0]`, i.e. `(v0, v_i, v_{i+1})` for `i in [1, n-1)`. This + * matches `HalfEdgeMesh::appendFace` and `fillSelection` so a face that + * survives an HE round-trip recovers the same triangulation it started + * with. + * + * Caller's responsibility: call this whenever `faces` is mutated to + * keep `triangles` in sync. Code paths that don't yet understand n-gons + * (GPU upload, legacy topology ops) consume `triangles`. + * + * Skips invalid faces (`vertexCount < 3`) silently. Replaces any + * existing contents of `triangles`. + * + * @param sub The submesh whose `faces` will be triangulated. After + * return, `sub.triangles` contains the fan-triangulation. + */ +void triangulateFaces(EditableSubMesh& sub); + +/** + * @brief Build trivial single-triangle faces from existing `triangles`. + * + * Inverse of `triangulateFaces` — used when a legacy triangle-only + * submesh needs to be promoted into the n-gon representation. After the + * call, every triangle has a corresponding 3-index `EditableFace`. The + * existing `triangles` array is preserved (the result still satisfies + * the canonical-faces invariant: `triangles[i] == fan(faces[i])`). + * + * @param sub The submesh to promote. Existing contents of `sub.faces` + * are replaced. + */ +void promoteTrianglesToFaces(EditableSubMesh& sub); + /** * @brief Indexed mesh representation for topology queries and editing. * diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index a70928e15..b12a52ace 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -90,27 +90,65 @@ bool HalfEdgeMesh::buildFromEditableMesh(const EditableMesh& editableMesh) } } - // Phase 2: Append a face/3-HE triangle for each input triangle, skipping - // degenerates. Vertex outgoing-HE pointers are set during this loop. + // Phase 2: Append a face for each input face. Each submesh prefers + // `faces` (n-gon canonical storage) when non-empty; otherwise it + // falls back to `triangles` (legacy storage). The two-stream rule + // mirrors EditableSubMesh's invariant: `faces` is canonical iff it + // is non-empty. Skips degenerates and sets vertex outgoing-HE + // pointers as faces are added. + auto registerVertexHE = [this](int vGlobal, int heStart) { + if (m_vertices[vGlobal].halfEdge == -1) { + m_vertices[vGlobal].halfEdge = heStart; + } + }; + for (int s = 0; s < m_subMeshCount; ++s) { const auto& sub = subMeshes[s]; - for (const auto& tri : sub.triangles) { - int v0 = vertexMap[s][tri.indices[0]]; - int v1 = vertexMap[s][tri.indices[1]]; - int v2 = vertexMap[s][tri.indices[2]]; - // Skip degenerate triangles (any two vertices identical) - if (v0 == v1 || v1 == v2 || v0 == v2) - continue; + if (!sub.faces.empty()) { + for (const auto& face : sub.faces) { + if (!face.isValid()) continue; + std::vector verts; + verts.reserve(face.indices.size()); + bool degenerate = false; + for (unsigned int local : face.indices) { + if (local >= vertexMap[s].size()) { + degenerate = true; + break; + } + verts.push_back(vertexMap[s][local]); + } + if (degenerate) continue; + // Reject faces with any duplicated vertex (would create + // a zero-area corner). Cheap O(N²) check is fine for + // typical N <= 8. + for (size_t i = 0; i < verts.size() && !degenerate; ++i) { + for (size_t j = i + 1; j < verts.size(); ++j) { + if (verts[i] == verts[j]) { degenerate = true; break; } + } + } + if (degenerate) continue; + + int he0 = static_cast(m_halfEdges.size()); + appendFace(verts, s); + for (size_t i = 0; i < verts.size(); ++i) { + registerVertexHE(verts[i], he0 + static_cast(i)); + } + } + } else { + for (const auto& tri : sub.triangles) { + int v0 = vertexMap[s][tri.indices[0]]; + int v1 = vertexMap[s][tri.indices[1]]; + int v2 = vertexMap[s][tri.indices[2]]; + + if (v0 == v1 || v1 == v2 || v0 == v2) continue; - int he0 = static_cast(m_halfEdges.size()); - appendTriangle(v0, v1, v2, s); + int he0 = static_cast(m_halfEdges.size()); + appendTriangle(v0, v1, v2, s); - // Set vertex outgoing half-edge pointers (first wins) - int verts[3] = {v0, v1, v2}; - for (int i = 0; i < 3; ++i) { - if (m_vertices[verts[i]].halfEdge == -1) { - m_vertices[verts[i]].halfEdge = he0 + i; + int verts[3] = {v0, v1, v2}; + for (int i = 0; i < 3; ++i) { + registerVertexHE(verts[i], he0 + i); } } } @@ -392,23 +430,34 @@ bool HalfEdgeMesh::toEditableMesh(EditableMesh& editableMesh) const // Per-submesh: map from HE vertex index -> local vertex index std::vector> vertexRemap(m_subMeshCount); + // Track whether any submesh ended up with a non-triangle face. If so, + // we mirror the face list into `EditableSubMesh::faces` so downstream + // n-gon-aware code (chunks 2+) sees the polygon structure. If every + // face is a triangle, we leave `faces` empty to keep the legacy path + // canonical. + std::vector hasNonTriangleFace(m_subMeshCount, false); + for (int f = 0; f < static_cast(m_faces.size()); ++f) { const auto& heFace = m_faces[f]; int subIdx = heFace.subMeshIndex; auto& outSub = outSubMeshes[subIdx]; auto verts = faceVertices(f); - if (verts.size() != 3) - continue; - - EditableTriangle tri; - for (int i = 0; i < 3; ++i) { - int heVertIdx = verts[i]; - + // Accept any face with 3+ vertices. n-gons (4+) get fan- + // triangulated into the legacy `triangles` array AND mirrored + // into `faces` so the n-gon structure survives the round-trip. + // Faces with < 3 vertices are degenerate — silently skip. + if (verts.size() < 3) continue; + + // Map every face vertex into the submesh's local vertex array, + // collecting the local indices in `localIdxs` for later. + std::vector localIdxs; + localIdxs.reserve(verts.size()); + for (int heVertIdx : verts) { auto it = vertexRemap[subIdx].find(heVertIdx); + int localIdx; if (it == vertexRemap[subIdx].end()) { - // Add this vertex to the submesh - int localIdx = static_cast(outSub.vertices.size()); + localIdx = static_cast(outSub.vertices.size()); vertexRemap[subIdx][heVertIdx] = localIdx; EditableVertex ev; @@ -431,13 +480,39 @@ bool HalfEdgeMesh::toEditableMesh(EditableMesh& editableMesh) const } outSub.vertices.push_back(std::move(ev)); - tri.indices[i] = localIdx; } else { - tri.indices[i] = it->second; + localIdx = it->second; } + localIdxs.push_back(static_cast(localIdx)); + } + + // Fan-triangulate the face into `triangles` (legacy storage): + // tris = (v0, v_i, v_{i+1}) for i in [1, N-1) + for (size_t i = 1; i + 1 < localIdxs.size(); ++i) { + EditableTriangle tri; + tri.indices[0] = localIdxs[0]; + tri.indices[1] = localIdxs[i]; + tri.indices[2] = localIdxs[i + 1]; + outSub.triangles.push_back(tri); } - outSub.triangles.push_back(tri); + // Always record the face in `EditableSubMesh::faces` so the n-gon + // round-trip is information-preserving. We finalise below by + // clearing `faces` on submeshes that turned out to be all + // triangles (legacy invariant: faces is non-empty only when + // there's actually polygonal information to preserve). + EditableFace face; + face.indices = std::move(localIdxs); + if (face.indices.size() > 3) hasNonTriangleFace[subIdx] = true; + outSub.faces.push_back(std::move(face)); + } + + // Finalise: drop `faces` on triangle-only submeshes. This keeps the + // legacy invariant that `faces` is empty unless the submesh really + // contains n-gons, so downstream code that doesn't yet understand + // n-gons (every consumer of EditableSubMesh today) keeps working. + for (int s = 0; s < m_subMeshCount; ++s) { + if (!hasNonTriangleFace[s]) outSubMeshes[s].faces.clear(); } return true; @@ -4833,8 +4908,10 @@ bool HalfEdgeMesh::validate() const return false; } while (he != startHE); - // Triangles should have exactly 3 half-edges - if (count != 3) + // Polygonal faces have at least 3 half-edges. Larger N-gons + // (quads etc.) are allowed by the data model — chunks 1+ of the + // quad migration drop the triangle-only assumption. + if (count < 3) return false; } diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index f734acdc5..8f984189b 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -4574,3 +4574,245 @@ TEST(HalfEdgeMeshStandalone, FillSelectionUndoRoundTrip) { EXPECT_TRUE(he2.validate()); EXPECT_EQ(activeFaceCount(he2), 2); } + +// =========================================================================== +// EditableFace (n-gon) round-trip — chunk 1 of the quad migration +// =========================================================================== + +TEST(HalfEdgeMeshStandalone, BuildFromQuadFacePopulatesSingleHEFace) { + // A submesh with a single 4-vertex EditableFace (no triangles) + // should produce ONE HalfEdgeMesh face of valence 4. + EditableMesh mesh; + EditableSubMesh sub; + sub.materialName = "QuadMat"; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + v.uv = Ogre::Vector2(x, y); v.hasUV = true; + return v; + }; + sub.vertices = { mkV(0, 0), mkV(1, 0), mkV(1, 1), mkV(0, 1) }; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + // Note: triangles intentionally left empty — when faces is non-empty + // it's canonical, and HE should read faces directly. + mesh.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(mesh)); + EXPECT_EQ(activeFaceCount(he), 1); + EXPECT_EQ(he.faceVertices(0).size(), 4u) + << "quad EditableFace must produce a 4-valence HE face, not 2 triangles"; + EXPECT_TRUE(he.validate()); +} + +TEST(HalfEdgeMeshStandalone, ToEditableMeshPreservesQuadInFaces) { + // Round-trip: build HE from a quad face, write back, verify the + // EditableSubMesh has a 4-vertex face (and triangles is the fan + // triangulation). + EditableMesh mesh; + EditableSubMesh sub; + sub.materialName = "QuadMat"; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { mkV(0, 0), mkV(1, 0), mkV(1, 1), mkV(0, 1) }; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + mesh.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(mesh)); + + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + ASSERT_EQ(back.subMeshes().size(), 1u); + const auto& outSub = back.subMeshes()[0]; + + // n-gon faces preserved + ASSERT_EQ(outSub.faces.size(), 1u) + << "quad must round-trip as a single 4-vertex face"; + EXPECT_EQ(outSub.faces[0].indices.size(), 4u); + + // triangles is the fan triangulation (2 tris from a quad) + EXPECT_EQ(outSub.triangles.size(), 2u) + << "fan-triangulation: a quad emits N-2 = 2 triangles"; + + // Vertex count preserved + EXPECT_EQ(outSub.vertices.size(), 4u); +} + +TEST(HalfEdgeMeshStandalone, ToEditableMeshLeavesFacesEmptyWhenAllTris) { + // The legacy invariant: if every face in the HE mesh is a triangle, + // `EditableSubMesh::faces` should be left empty so existing + // triangle-only consumers don't have to learn the new representation. + auto em = makeQuadMesh(); // two triangles + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + ASSERT_EQ(back.subMeshes().size(), 1u); + EXPECT_TRUE(back.subMeshes()[0].faces.empty()) + << "all-triangle submeshes must NOT populate faces (legacy invariant)"; + EXPECT_EQ(back.subMeshes()[0].triangles.size(), 2u); +} + +TEST(HalfEdgeMeshStandalone, BuildFromMixedTriAndQuadSubMeshes) { + // Two submeshes: one triangle-only (legacy path), one quad-only + // (n-gon path). buildFromEditableMesh should produce 3 HE faces + // total (1 tri + 1 quad ... wait, 1 tri + 1 quad = 2 faces). + EditableMesh mesh; + auto mkV = [](float x, float y, float z) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, z); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + + { + EditableSubMesh sub; + sub.materialName = "TriMat"; + sub.vertices = { mkV(0, 0, 0), mkV(1, 0, 0), mkV(0, 1, 0) }; + EditableTriangle t; + t.indices[0] = 0; t.indices[1] = 1; t.indices[2] = 2; + sub.triangles.push_back(t); + mesh.subMeshes().push_back(std::move(sub)); + } + { + EditableSubMesh sub; + sub.materialName = "QuadMat"; + sub.vertices = { mkV(0, 0, 1), mkV(1, 0, 1), mkV(1, 1, 1), mkV(0, 1, 1) }; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + mesh.subMeshes().push_back(std::move(sub)); + } + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(mesh)); + EXPECT_EQ(activeFaceCount(he), 2); + EXPECT_TRUE(he.validate()); + + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + ASSERT_EQ(back.subMeshes().size(), 2u); + EXPECT_TRUE(back.subMeshes()[0].faces.empty()) // triangle submesh stays legacy + << "triangle submesh must not populate faces"; + EXPECT_EQ(back.subMeshes()[1].faces.size(), 1u) // quad submesh has 1 face + << "quad submesh must populate faces with the polygon"; + EXPECT_EQ(back.subMeshes()[1].faces[0].indices.size(), 4u); +} + +TEST(HalfEdgeMeshStandalone, EditableFaceIsValidGuardsBadInputs) { + EditableFace empty; + EXPECT_FALSE(empty.isValid()); + + EditableFace pair; + pair.indices = {0, 1}; + EXPECT_FALSE(pair.isValid()); + + EditableFace dup; + dup.indices = {0, 1, 1, 2}; + EXPECT_FALSE(dup.isValid()) << "consecutive duplicate index pair must fail"; + + EditableFace okQuad; + okQuad.indices = {0, 1, 2, 3}; + EXPECT_TRUE(okQuad.isValid()); + + EditableFace okTri; + okTri.indices = {0, 1, 2}; + EXPECT_TRUE(okTri.isValid()); +} + +TEST(HalfEdgeMeshStandalone, BuildFromQuadFaceRejectsInvalidIndex) { + // An out-of-range index in EditableFace::indices must not crash — + // the face is silently skipped. + EditableMesh mesh; + EditableSubMesh sub; + sub.materialName = "Bad"; + EditableVertex v; + v.position = Ogre::Vector3::ZERO; + sub.vertices = {v, v, v}; // 3 verts available + EditableFace f; + f.indices = {0, 1, 99}; // 99 is out of range + sub.faces.push_back(std::move(f)); + mesh.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + EXPECT_TRUE(he.buildFromEditableMesh(mesh)); + EXPECT_EQ(activeFaceCount(he), 0) + << "out-of-range face must be skipped, not crash"; +} + +// =========================================================================== +// triangulateFaces / promoteTrianglesToFaces helpers +// =========================================================================== + +TEST(HalfEdgeMeshStandalone, TriangulateFacesQuadProducesTwoTriangles) { + EditableSubMesh sub; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + + triangulateFaces(sub); + ASSERT_EQ(sub.triangles.size(), 2u); + // Fan from vertex 0: (0, 1, 2) and (0, 2, 3). + EXPECT_EQ(sub.triangles[0].indices[0], 0u); + EXPECT_EQ(sub.triangles[0].indices[1], 1u); + EXPECT_EQ(sub.triangles[0].indices[2], 2u); + EXPECT_EQ(sub.triangles[1].indices[0], 0u); + EXPECT_EQ(sub.triangles[1].indices[1], 2u); + EXPECT_EQ(sub.triangles[1].indices[2], 3u); +} + +TEST(HalfEdgeMeshStandalone, TriangulateFacesPentagonProducesThreeTriangles) { + EditableSubMesh sub; + EditableFace f; + f.indices = {10, 20, 30, 40, 50}; // arbitrary indices + sub.faces.push_back(std::move(f)); + + triangulateFaces(sub); + EXPECT_EQ(sub.triangles.size(), 3u) << "N=5 fan emits N-2 = 3 triangles"; +} + +TEST(HalfEdgeMeshStandalone, TriangulateFacesSkipsInvalidFaces) { + EditableSubMesh sub; + { + EditableFace bad; // <3 indices, isValid() false + bad.indices = {0, 1}; + sub.faces.push_back(std::move(bad)); + } + { + EditableFace good; + good.indices = {0, 1, 2}; + sub.faces.push_back(std::move(good)); + } + triangulateFaces(sub); + EXPECT_EQ(sub.triangles.size(), 1u) << "invalid face skipped"; +} + +TEST(HalfEdgeMeshStandalone, PromoteTrianglesToFacesProducesMatchingFaces) { + EditableSubMesh sub; + EditableTriangle t1, t2; + t1.indices[0] = 0; t1.indices[1] = 1; t1.indices[2] = 2; + t2.indices[0] = 1; t2.indices[1] = 3; t2.indices[2] = 2; + sub.triangles = {t1, t2}; + + promoteTrianglesToFaces(sub); + ASSERT_EQ(sub.faces.size(), 2u); + EXPECT_EQ(sub.faces[0].indices.size(), 3u); + EXPECT_EQ(sub.faces[0].indices[0], 0u); + EXPECT_EQ(sub.faces[0].indices[1], 1u); + EXPECT_EQ(sub.faces[0].indices[2], 2u); + // triangles should still match the canonical faces invariant + // (every face is a triangle, so triangulateFaces would produce + // identical content) + EXPECT_EQ(sub.triangles.size(), 2u); +} From e28245d1a83bea7442f32c0b601b236864927d1c Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 02:46:26 -0400 Subject: [PATCH 04/34] =?UTF-8?q?feat(quads):=20chunk=202=20=E2=80=94=20GP?= =?UTF-8?q?U=20upload=20n-gon=20triangulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the EditableSubMesh::faces (n-gon canonical) field through the GPU upload path so quad-bearing meshes render correctly via fan- triangulation. Triangle-only meshes are byte-identical before/after. Changes: - buildSubMeshBuffers (the GPU upload entry point used by resizeEntityBuffers and createNewMesh) re-triangulates faces into triangles defensively when faces is non-empty, so the index buffer always matches the live face data even if the caller forgot to sync. Triangle-only submeshes pay zero overhead — no copy, no extra work. - recalculateNormals / recalculateNormalsFlat call triangulateFaces first when faces is canonical, so vertex normals always reflect the current polygon geometry. - New EditableMesh::syncTriangulation() helper for callers that mutate faces directly and want to publish the change to triangles. - New EditableMesh::totalFaceCount() for the n-gon-aware caller — reports faces.size() when n-gons are present, falls back to triangles.size() for legacy submeshes. Tests (+5): - syncTriangulation fan-triangulates a quad face into 2 triangles. - syncTriangulation is a no-op on triangle-only submeshes. - totalFaceCount falls back to triangle count for legacy submeshes and reports n-gon count when faces is canonical. - recalculateNormals re-syncs triangles from faces and produces correct vertex normals on a quad-only mesh. No behavior change for triangle-only meshes — chunk 1 + chunk 2 keep the existing path byte-identical. Quad meshes can now render correctly end-to-end (limited by importers, which still triangulate at load — that's chunk 3). Towards #326. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EditableMesh.cpp | 71 ++++++++++++++++++++----- src/EditableMesh.h | 11 ++++ src/EditableMesh_test.cpp | 108 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 14 deletions(-) diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index ea2b2344d..34bda04bc 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -323,16 +323,36 @@ bool EditableMesh::commitToEntity(Ogre::Entity* entity) } void EditableMesh::buildSubMeshBuffers(Ogre::SubMesh* subMesh, - const EditableSubMesh& editSub) + const EditableSubMesh& editSubIn) { - if (!subMesh || editSub.vertices.empty() || editSub.triangles.empty()) - return; + if (!subMesh || editSubIn.vertices.empty()) return; + + // n-gon synchronisation: if the caller populated `faces`, that's + // canonical and `triangles` is meant to be a fan-triangulation + // mirror. Re-triangulate defensively here so the GPU buffer always + // matches the live face data even if the caller forgot to call + // `triangulateFaces()` after mutating `faces`. + // + // We work on a local copy when re-triangulating is needed so the + // input EditableSubMesh stays untouched (this method takes the + // submesh by const&). Triangle-only submeshes incur no copy. + EditableSubMesh local; + const EditableSubMesh* editSub = &editSubIn; + if (!editSubIn.faces.empty()) { + local.vertices = editSubIn.vertices; // shallow-but-fine — we don't write + local.faces = editSubIn.faces; + local.materialName = editSubIn.materialName; + local.usesSharedVertices = editSubIn.usesSharedVertices; + triangulateFaces(local); + editSub = &local; + } + if (editSub->triangles.empty()) return; // Replace any existing vertex data with a fresh one. if (subMesh->vertexData) delete subMesh->vertexData; subMesh->useSharedVertices = false; subMesh->vertexData = new Ogre::VertexData(); - subMesh->vertexData->vertexCount = editSub.vertices.size(); + subMesh->vertexData->vertexCount = editSub->vertices.size(); auto* decl = subMesh->vertexData->vertexDeclaration; auto* binding = subMesh->vertexData->vertexBufferBinding; @@ -342,19 +362,19 @@ void EditableMesh::buildSubMeshBuffers(Ogre::SubMesh* subMesh, decl->addElement(0, offset, Ogre::VET_FLOAT3, Ogre::VES_POSITION); offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); - bool hasNormals = editSub.vertices[0].hasNormal; + bool hasNormals = editSub->vertices[0].hasNormal; if (hasNormals) { decl->addElement(0, offset, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); } - bool hasUVs = editSub.vertices[0].hasUV; + bool hasUVs = editSub->vertices[0].hasUV; if (hasUVs) { decl->addElement(0, offset, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT2); } - bool hasTangents = editSub.vertices[0].hasTangent; + bool hasTangents = editSub->vertices[0].hasTangent; if (hasTangents) { decl->addElement(0, offset, Ogre::VET_FLOAT4, Ogre::VES_TANGENT); offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT4); @@ -363,11 +383,11 @@ void EditableMesh::buildSubMeshBuffers(Ogre::SubMesh* subMesh, // Create interleaved vertex buffer. size_t vertSize = decl->getVertexSize(0); auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( - vertSize, editSub.vertices.size(), + vertSize, editSub->vertices.size(), Ogre::HardwareBuffer::HBU_DYNAMIC_WRITE_ONLY, true); auto* dest = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); - for (const auto& v : editSub.vertices) { + for (const auto& v : editSub->vertices) { *dest++ = v.position.x; *dest++ = v.position.y; *dest++ = v.position.z; @@ -386,22 +406,22 @@ void EditableMesh::buildSubMeshBuffers(Ogre::SubMesh* subMesh, binding->setBinding(0, vbuf); // Create index buffer (16-bit if possible, else 32-bit). - bool use32bit = editSub.vertices.size() > 65535; + bool use32bit = editSub->vertices.size() > 65535; auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( use32bit ? Ogre::HardwareIndexBuffer::IT_32BIT : Ogre::HardwareIndexBuffer::IT_16BIT, - editSub.triangles.size() * 3, + editSub->triangles.size() * 3, Ogre::HardwareBuffer::HBU_DYNAMIC_WRITE_ONLY, true); if (use32bit) { auto* idx = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); - for (const auto& tri : editSub.triangles) { + for (const auto& tri : editSub->triangles) { *idx++ = tri.indices[0]; *idx++ = tri.indices[1]; *idx++ = tri.indices[2]; } } else { auto* idx = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); - for (const auto& tri : editSub.triangles) { + for (const auto& tri : editSub->triangles) { *idx++ = static_cast(tri.indices[0]); *idx++ = static_cast(tri.indices[1]); *idx++ = static_cast(tri.indices[2]); @@ -410,7 +430,7 @@ void EditableMesh::buildSubMeshBuffers(Ogre::SubMesh* subMesh, ibuf->unlock(); subMesh->indexData->indexBuffer = ibuf; - subMesh->indexData->indexCount = editSub.triangles.size() * 3; + subMesh->indexData->indexCount = editSub->triangles.size() * 3; subMesh->indexData->indexStart = 0; } @@ -543,6 +563,22 @@ size_t EditableMesh::totalTriangleCount() const return total; } +size_t EditableMesh::totalFaceCount() const +{ + size_t total = 0; + for (const auto& sub : m_subMeshes) { + total += sub.faces.empty() ? sub.triangles.size() : sub.faces.size(); + } + return total; +} + +void EditableMesh::syncTriangulation() +{ + for (auto& sub : m_subMeshes) { + if (!sub.faces.empty()) triangulateFaces(sub); + } +} + void EditableMesh::setVertexPosition(size_t subMeshIndex, size_t vertexIndex, const Ogre::Vector3& pos) { if (subMeshIndex < m_subMeshes.size() && vertexIndex < m_subMeshes[subMeshIndex].vertices.size()) @@ -589,6 +625,11 @@ Ogre::Vector2 EditableMesh::getVertexUV(size_t subMeshIndex, size_t vertexIndex) void EditableMesh::recalculateNormals() { for (auto& sub : m_subMeshes) { + // n-gon sync: when faces is canonical, refresh the triangle + // mirror so the normal-accumulation loop below sees the actual + // current geometry. Triangle-only submeshes pay nothing here. + if (!sub.faces.empty()) triangulateFaces(sub); + // Zero out all normals for (auto& v : sub.vertices) { v.normal = Ogre::Vector3::ZERO; @@ -626,6 +667,8 @@ void EditableMesh::recalculateNormals() void EditableMesh::recalculateNormalsFlat() { for (auto& sub : m_subMeshes) { + if (!sub.faces.empty()) triangulateFaces(sub); + // Zero out all normals for (auto& v : sub.vertices) { v.normal = Ogre::Vector3::ZERO; diff --git a/src/EditableMesh.h b/src/EditableMesh.h index 7a38f8926..6adc8ea60 100644 --- a/src/EditableMesh.h +++ b/src/EditableMesh.h @@ -288,6 +288,17 @@ class EditableMesh size_t subMeshCount() const { return m_subMeshes.size(); } size_t totalVertexCount() const; size_t totalTriangleCount() const; + /// Total polygonal-face count across all submeshes. Returns the + /// n-gon face count (`faces.size()`) when a submesh has been + /// promoted to the quad-aware representation; falls back to + /// `triangles.size()` for legacy triangle-only submeshes. + size_t totalFaceCount() const; + + /// Synchronise every submesh's `triangles` with its `faces` array. + /// Call after mutating `faces` so downstream consumers (GPU upload, + /// normal recalculation, legacy topology ops) see the up-to-date + /// fan-triangulation. No-op for legacy triangle-only submeshes. + void syncTriangulation(); /// @} /// @name Vertex manipulation diff --git a/src/EditableMesh_test.cpp b/src/EditableMesh_test.cpp index 83c5462f9..6d95c62a1 100644 --- a/src/EditableMesh_test.cpp +++ b/src/EditableMesh_test.cpp @@ -804,3 +804,111 @@ TEST_F(EditModeControllerTest, SoftSelectionWeightsInEditMode) { ctrl->exitEditMode(false); Manager::getSingleton()->destroySceneNode("EditMode_soft_weights_node"); } + +// =========================================================================== +// EditableFace + n-gon helpers (chunk 1) and triangulation sync (chunk 2) +// =========================================================================== + +TEST(EditableMeshStandalone, SyncTriangulationFanTriangulatesQuadFaces) { + EditableMesh mesh; + EditableSubMesh sub; + EditableVertex v; + sub.vertices = {v, v, v, v}; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + // triangles deliberately stale (empty); syncTriangulation must + // populate it from faces. + mesh.subMeshes().push_back(std::move(sub)); + + mesh.syncTriangulation(); + ASSERT_EQ(mesh.subMeshes()[0].triangles.size(), 2u) + << "quad must yield 2 fan triangles after syncTriangulation"; +} + +TEST(EditableMeshStandalone, SyncTriangulationLeavesTriOnlySubmeshAlone) { + EditableMesh mesh; + EditableSubMesh sub; + EditableVertex v; + sub.vertices = {v, v, v}; + EditableTriangle t; + t.indices[0] = 0; t.indices[1] = 1; t.indices[2] = 2; + sub.triangles.push_back(t); + // faces intentionally empty — triangle-only legacy submesh. + mesh.subMeshes().push_back(std::move(sub)); + + mesh.syncTriangulation(); + EXPECT_EQ(mesh.subMeshes()[0].triangles.size(), 1u); + EXPECT_TRUE(mesh.subMeshes()[0].faces.empty()); +} + +TEST(EditableMeshStandalone, TotalFaceCountFallsBackToTriangleCountForLegacy) { + EditableMesh mesh; + EditableSubMesh sub; + EditableVertex v; + sub.vertices = {v, v, v, v, v}; + EditableTriangle t; + t.indices[0] = 0; t.indices[1] = 1; t.indices[2] = 2; + sub.triangles.push_back(t); + t.indices[0] = 0; t.indices[1] = 2; t.indices[2] = 3; + sub.triangles.push_back(t); + t.indices[0] = 0; t.indices[1] = 3; t.indices[2] = 4; + sub.triangles.push_back(t); + mesh.subMeshes().push_back(std::move(sub)); + + EXPECT_EQ(mesh.totalFaceCount(), 3u) + << "legacy submesh: face count == triangle count"; +} + +TEST(EditableMeshStandalone, TotalFaceCountReportsNGonsWhenPresent) { + EditableMesh mesh; + EditableSubMesh sub; + EditableVertex v; + sub.vertices = {v, v, v, v, v}; + EditableFace pent; + pent.indices = {0, 1, 2, 3, 4}; + sub.faces.push_back(std::move(pent)); + triangulateFaces(sub); // produces 3 fan tris + mesh.subMeshes().push_back(std::move(sub)); + + EXPECT_EQ(mesh.totalFaceCount(), 1u) + << "pentagon: 1 face (n-gon canonical), not 3 (triangle mirror)"; + EXPECT_EQ(mesh.totalTriangleCount(), 3u); +} + +TEST(EditableMeshStandalone, RecalculateNormalsResyncsTrianglesFromFaces) { + // Build a quad mesh where `triangles` is intentionally stale — + // recalculateNormals should triangulate from `faces` first so the + // resulting normals reflect the live geometry, not the stale tris. + EditableMesh mesh; + EditableSubMesh sub; + auto mkV = [](float x, float y, float z) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, z); + v.hasNormal = true; + v.normal = Ogre::Vector3::UNIT_Z; + return v; + }; + // Quad in the XY plane with normal +Z. + sub.vertices = { + mkV(0, 0, 0), mkV(1, 0, 0), mkV(1, 1, 0), mkV(0, 1, 0), + }; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + // triangles starts empty; recalculateNormals must sync it before + // accumulating normals. + mesh.subMeshes().push_back(std::move(sub)); + + mesh.recalculateNormals(); + + // After recalc, triangles must be the fan triangulation (2 tris). + EXPECT_EQ(mesh.subMeshes()[0].triangles.size(), 2u); + + // Every vertex should end up with normal == +Z (within tolerance). + for (const auto& v : mesh.subMeshes()[0].vertices) { + EXPECT_NEAR(v.normal.z, 1.0f, 1e-4f); + EXPECT_NEAR(v.normal.x, 0.0f, 1e-4f); + EXPECT_NEAR(v.normal.y, 0.0f, 1e-4f); + } +} From f9d167466813c1879f12d3d32b265815b3817567 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 03:22:39 -0400 Subject: [PATCH 05/34] fix(quads): address SonarCloud Quality Gate failures (chunk 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/EditableMesh.cpp:355,433: explicit `static_cast` for `size_t` → Ogre's `vertexCount` / `indexCount` (uint32). The conversion was implicit pre-existing, but Sonar treats my edits to those lines as new and flags the precision-loss warning. - src/EditableMesh.h: convert the new `totalFaceCount()` and `syncTriangulation()` from member methods to free functions taking `std::vector&`. The class hit Sonar's 35-method ceiling; free functions are the natural pattern here anyway since they mirror the existing `triangulateFaces(EditableSubMesh&)` and `promoteTrianglesToFaces(EditableSubMesh&)` helpers. - Tests updated to call the free-function versions. No behavior change; same 191 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EditableMesh.cpp | 12 ++++++------ src/EditableMesh.h | 38 +++++++++++++++++++++++++++----------- src/EditableMesh_test.cpp | 8 ++++---- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 34bda04bc..67b0a66a1 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -352,7 +352,7 @@ void EditableMesh::buildSubMeshBuffers(Ogre::SubMesh* subMesh, if (subMesh->vertexData) delete subMesh->vertexData; subMesh->useSharedVertices = false; subMesh->vertexData = new Ogre::VertexData(); - subMesh->vertexData->vertexCount = editSub->vertices.size(); + subMesh->vertexData->vertexCount = static_cast(editSub->vertices.size()); auto* decl = subMesh->vertexData->vertexDeclaration; auto* binding = subMesh->vertexData->vertexBufferBinding; @@ -430,7 +430,7 @@ void EditableMesh::buildSubMeshBuffers(Ogre::SubMesh* subMesh, ibuf->unlock(); subMesh->indexData->indexBuffer = ibuf; - subMesh->indexData->indexCount = editSub->triangles.size() * 3; + subMesh->indexData->indexCount = static_cast(editSub->triangles.size() * 3); subMesh->indexData->indexStart = 0; } @@ -563,18 +563,18 @@ size_t EditableMesh::totalTriangleCount() const return total; } -size_t EditableMesh::totalFaceCount() const +size_t totalFaceCount(const std::vector& subMeshes) { size_t total = 0; - for (const auto& sub : m_subMeshes) { + for (const auto& sub : subMeshes) { total += sub.faces.empty() ? sub.triangles.size() : sub.faces.size(); } return total; } -void EditableMesh::syncTriangulation() +void syncTriangulation(std::vector& subMeshes) { - for (auto& sub : m_subMeshes) { + for (auto& sub : subMeshes) { if (!sub.faces.empty()) triangulateFaces(sub); } } diff --git a/src/EditableMesh.h b/src/EditableMesh.h index 6adc8ea60..91cd7e359 100644 --- a/src/EditableMesh.h +++ b/src/EditableMesh.h @@ -161,6 +161,33 @@ void triangulateFaces(EditableSubMesh& sub); */ void promoteTrianglesToFaces(EditableSubMesh& sub); +/** + * @brief Re-triangulate every submesh whose `faces` is non-empty. + * + * Convenience over `triangulateFaces(sub)` for a whole mesh: walks the + * submesh array and resyncs each one whose canonical face storage has + * changed. No-op for legacy triangle-only submeshes. + * + * Free function rather than a method on `EditableMesh` to keep the + * class size below SonarQube's 35-method ceiling. + * + * @param subMeshes The submesh vector to sync (typically `mesh.subMeshes()`). + */ +void syncTriangulation(std::vector& subMeshes); + +/** + * @brief Total polygonal-face count across a submesh vector. + * + * For each submesh, returns `faces.size()` when n-gons are canonical, + * else `triangles.size()`. The `EditableMesh::totalTriangleCount()` + * counterpart still reports the fan-triangulation count regardless of + * representation. + * + * Free function for the same reason as `syncTriangulation` — keeps + * `EditableMesh` below the class-method limit. + */ +size_t totalFaceCount(const std::vector& subMeshes); + /** * @brief Indexed mesh representation for topology queries and editing. * @@ -288,17 +315,6 @@ class EditableMesh size_t subMeshCount() const { return m_subMeshes.size(); } size_t totalVertexCount() const; size_t totalTriangleCount() const; - /// Total polygonal-face count across all submeshes. Returns the - /// n-gon face count (`faces.size()`) when a submesh has been - /// promoted to the quad-aware representation; falls back to - /// `triangles.size()` for legacy triangle-only submeshes. - size_t totalFaceCount() const; - - /// Synchronise every submesh's `triangles` with its `faces` array. - /// Call after mutating `faces` so downstream consumers (GPU upload, - /// normal recalculation, legacy topology ops) see the up-to-date - /// fan-triangulation. No-op for legacy triangle-only submeshes. - void syncTriangulation(); /// @} /// @name Vertex manipulation diff --git a/src/EditableMesh_test.cpp b/src/EditableMesh_test.cpp index 6d95c62a1..bd5f2acc1 100644 --- a/src/EditableMesh_test.cpp +++ b/src/EditableMesh_test.cpp @@ -821,7 +821,7 @@ TEST(EditableMeshStandalone, SyncTriangulationFanTriangulatesQuadFaces) { // populate it from faces. mesh.subMeshes().push_back(std::move(sub)); - mesh.syncTriangulation(); + syncTriangulation(mesh.subMeshes()); ASSERT_EQ(mesh.subMeshes()[0].triangles.size(), 2u) << "quad must yield 2 fan triangles after syncTriangulation"; } @@ -837,7 +837,7 @@ TEST(EditableMeshStandalone, SyncTriangulationLeavesTriOnlySubmeshAlone) { // faces intentionally empty — triangle-only legacy submesh. mesh.subMeshes().push_back(std::move(sub)); - mesh.syncTriangulation(); + syncTriangulation(mesh.subMeshes()); EXPECT_EQ(mesh.subMeshes()[0].triangles.size(), 1u); EXPECT_TRUE(mesh.subMeshes()[0].faces.empty()); } @@ -856,7 +856,7 @@ TEST(EditableMeshStandalone, TotalFaceCountFallsBackToTriangleCountForLegacy) { sub.triangles.push_back(t); mesh.subMeshes().push_back(std::move(sub)); - EXPECT_EQ(mesh.totalFaceCount(), 3u) + EXPECT_EQ(totalFaceCount(mesh.subMeshes()), 3u) << "legacy submesh: face count == triangle count"; } @@ -871,7 +871,7 @@ TEST(EditableMeshStandalone, TotalFaceCountReportsNGonsWhenPresent) { triangulateFaces(sub); // produces 3 fan tris mesh.subMeshes().push_back(std::move(sub)); - EXPECT_EQ(mesh.totalFaceCount(), 1u) + EXPECT_EQ(totalFaceCount(mesh.subMeshes()), 1u) << "pentagon: 1 face (n-gon canonical), not 3 (triangle mirror)"; EXPECT_EQ(mesh.totalTriangleCount(), 3u); } From a8865acb6a4e17d6506242c87ac6cf773ffaf70a Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 09:09:16 -0400 Subject: [PATCH 06/34] =?UTF-8?q?feat(quads):=20chunk=203=20=E2=80=94=20im?= =?UTF-8?q?porter=20quad=20detection=20(option=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the n-gon-aware re-import path discussed on #326. The existing AssimpToOgreImporter pipeline is untouched; this chunk only: 1. Caches the source file path on the imported Ogre::Mesh via UserObjectBindings("qtme.source_path"). MeshImporterExporter attaches it after every Assimp-backed import. 2. Adds EditableMesh::loadFromAssimpFile(path) which spins up an independent Assimp::Importer with aiProcess_Triangulate deliberately OFF, so source quads survive into aiMesh::mFaces and get recorded as EditableFace entries. Vertex attributes (positions/normals/UVs/colors/bone weights) are read out of the same scene; skeleton, animation, material, and tangent processing are skipped — Edit Mode operates on geometry only. Notes & deferred work: - Not yet wired into EditModeController::enterEditMode. Doing so safely needs a "user has modified this mesh" flag so re-importing doesn't discard prior edits — that's a follow-up chunk. - Re-import cost (~10–100ms typical, more on huge FBX) is acceptable as a one-time Tab-into-Edit-Mode cost. Tests (+6, standalone): - empty path / missing file rejected - OBJ quad → 1 EditableFace (4 verts), 2 fan triangles - triangle-only OBJ leaves `faces` empty (chunk-1 invariant) - mixed tri+quad OBJ produces both face types - non-empty mesh is replaced 197 standalone tests green (was 191; +6). Towards #326. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EditableMesh.cpp | 129 +++++++++++++++++++++++++++++++++ src/EditableMesh.h | 33 +++++++++ src/EditableMesh_test.cpp | 135 +++++++++++++++++++++++++++++++++++ src/MeshImporterExporter.cpp | 14 +++- 4 files changed, 310 insertions(+), 1 deletion(-) diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 67b0a66a1..89e4dec08 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -35,6 +35,13 @@ THE SOFTWARE. #include #include +// Assimp re-import path (loadFromAssimpFile) — drops aiProcess_Triangulate +// so source n-gons survive into EditableSubMesh::faces. Quad migration #326, +// chunk 3. +#include +#include +#include + void triangulateFaces(EditableSubMesh& sub) { sub.triangles.clear(); @@ -139,6 +146,128 @@ bool EditableMesh::loadFromMesh(const Ogre::MeshPtr& meshPtr) return true; } +bool EditableMesh::loadFromAssimpFile(const std::string& path) +{ + if (path.empty()) return false; + + // Spin up an independent Assimp::Importer so we don't disturb the + // existing AssimpToOgreImporter pipeline. Drop aiProcess_Triangulate + // so source quads survive into aiMesh::mFaces. Keep the rest of the + // post-processing aligned with the rendering importer so vertex + // attributes don't drift between the two views. + Assimp::Importer importer; + const unsigned int flags = + aiProcess_JoinIdenticalVertices | + aiProcess_GenSmoothNormals | + aiProcess_ValidateDataStructure | + aiProcess_LimitBoneWeights | + aiProcess_GlobalScale; + const aiScene* scene = importer.ReadFile(path, flags); + if (!scene || !scene->mRootNode || scene->mNumMeshes == 0) { + Ogre::LogManager::getSingleton().logMessage( + "EditableMesh::loadFromAssimpFile: re-import failed for '" + path + + "' — " + std::string(importer.GetErrorString())); + return false; + } + + m_subMeshes.clear(); + m_subMeshes.reserve(scene->mNumMeshes); + + for (unsigned m = 0; m < scene->mNumMeshes; ++m) { + const aiMesh* aim = scene->mMeshes[m]; + if (!aim || aim->mNumVertices == 0) continue; + + EditableSubMesh sub; + sub.usesSharedVertices = false; + // Material name is left empty here — the live Ogre::Mesh in the + // scene already carries the right material per submesh, and + // EditModeController doesn't write material assignments back + // through this path. + sub.materialName.clear(); + + // Vertices. + sub.vertices.resize(aim->mNumVertices); + const bool hasNormals = aim->HasNormals(); + const bool hasUVs = aim->HasTextureCoords(0); + const bool hasColors = aim->HasVertexColors(0); + for (unsigned i = 0; i < aim->mNumVertices; ++i) { + EditableVertex& ev = sub.vertices[i]; + const aiVector3D& p = aim->mVertices[i]; + ev.position = Ogre::Vector3(p.x, p.y, p.z); + if (hasNormals) { + const aiVector3D& n = aim->mNormals[i]; + ev.normal = Ogre::Vector3(n.x, n.y, n.z); + ev.hasNormal = true; + } + if (hasUVs) { + const aiVector3D& t = aim->mTextureCoords[0][i]; + ev.uv = Ogre::Vector2(t.x, t.y); + ev.hasUV = true; + } + if (hasColors) { + const aiColor4D& c = aim->mColors[0][i]; + ev.color = Ogre::ColourValue(c.r, c.g, c.b, c.a); + ev.hasColor = true; + } + } + + // Bone weights, if any. + if (aim->mNumBones > 0) { + for (unsigned b = 0; b < aim->mNumBones; ++b) { + const aiBone* bone = aim->mBones[b]; + if (!bone) continue; + for (unsigned w = 0; w < bone->mNumWeights; ++w) { + const aiVertexWeight& vw = bone->mWeights[w]; + if (vw.mVertexId >= sub.vertices.size()) continue; + EditableBoneAssignment eba; + eba.boneIndex = static_cast(b); + eba.weight = vw.mWeight; + sub.vertices[vw.mVertexId].boneAssignments.push_back(eba); + } + } + } + + // Faces — this is the whole point of this method. Without + // aiProcess_Triangulate, aiMesh::mFaces retains the original + // polygon structure (3 / 4 / N indices per face). Build + // `EditableFace` directly; chunks 1+2 take care of GPU upload. + sub.faces.reserve(aim->mNumFaces); + bool sawNGon = false; + for (unsigned f = 0; f < aim->mNumFaces; ++f) { + const aiFace& face = aim->mFaces[f]; + if (face.mNumIndices < 3) continue; // points / lines — skip + EditableFace ef; + ef.indices.reserve(face.mNumIndices); + bool inRange = true; + for (unsigned k = 0; k < face.mNumIndices; ++k) { + if (face.mIndices[k] >= aim->mNumVertices) { + inRange = false; + break; + } + ef.indices.push_back(face.mIndices[k]); + } + if (!inRange) continue; + if (face.mNumIndices > 3) sawNGon = true; + sub.faces.push_back(std::move(ef)); + } + + // Always populate `triangles` as the fan-triangulated mirror so + // legacy consumers (the GPU upload path before chunk 2's defensive + // resync, the normal-recalc path on triangle-only submeshes, + // every existing topology op) keep working unchanged. + triangulateFaces(sub); + + // Honour the chunk-1 invariant: leave `faces` empty when every + // face was a triangle, so triangle-only assets don't surface as + // n-gon submeshes downstream. + if (!sawNGon) sub.faces.clear(); + + m_subMeshes.push_back(std::move(sub)); + } + + return !m_subMeshes.empty(); +} + void EditableMesh::collapseToSingleSubmeshAndWeld(float tolerance) { if (m_subMeshes.size() <= 1) { diff --git a/src/EditableMesh.h b/src/EditableMesh.h index 91cd7e359..c1a4bbc31 100644 --- a/src/EditableMesh.h +++ b/src/EditableMesh.h @@ -234,6 +234,39 @@ class EditableMesh */ bool loadFromMesh(const Ogre::MeshPtr& mesh); + /** + * @brief Re-import an asset directly via Assimp, preserving n-gons. + * + * Spins up a fresh `Assimp::Importer` and re-reads the source file + * with the triangulation post-process disabled, so source quads + * survive into `EditableSubMesh::faces` instead of being collapsed + * to triangles. The associated Ogre::Mesh in the live scene + * continues to use the triangulated index buffer for rendering; + * this path only feeds the editing-time representation. + * + * Skips skeleton, animation, material, and tangent processing — + * Edit Mode operates on positions, normals, UVs, vertex colors, + * and bone weights only. Materials are taken from the existing + * Ogre::Mesh by submesh order in `loadFromEntity` / similar paths. + * + * Vertices are read out of `aiMesh::mVertices` / `mNormals` / + * `mTextureCoords[0]` / `mColors[0]` / `mBones[].mWeights`. Faces + * are read from `aiMesh::mFaces` and stored in + * `EditableSubMesh::faces` (n-gon canonical), with `triangles` + * fan-triangulated to maintain the chunk-1 invariant. + * + * Cost: a second Assimp parse of the same file. Order of magnitude + * 10–100ms for typical assets; acceptable as a one-time cost on + * entering Edit Mode. Big assets (50MB+ FBX) may be noticeable. + * + * @param path The path the asset was originally imported from. + * Should be the value cached on `Ogre::Mesh` via + * `getUserObjectBindings().getUserAny("qtme.source_path")`. + * @return true on success; false if the file is missing, can't be + * parsed, or contains no mesh data. + */ + bool loadFromAssimpFile(const std::string& path); + /** * @brief Merge vertices at (approximately) coincident positions within * each submesh. diff --git a/src/EditableMesh_test.cpp b/src/EditableMesh_test.cpp index bd5f2acc1..f26e1b286 100644 --- a/src/EditableMesh_test.cpp +++ b/src/EditableMesh_test.cpp @@ -9,6 +9,9 @@ The MIT License */ #include +#include +#include +#include #include "EditableMesh.h" #include "EditModeController.h" #include "TestHelpers.h" @@ -912,3 +915,135 @@ TEST(EditableMeshStandalone, RecalculateNormalsResyncsTrianglesFromFaces) { EXPECT_NEAR(v.normal.y, 0.0f, 1e-4f); } } + +// =========================================================================== +// loadFromAssimpFile (chunk 3) — n-gon-aware re-import +// =========================================================================== + +namespace { +// Write a minimal OBJ to a temp file with the given face line. Returns +// the path on success, empty on failure. The OBJ format keeps quads +// intact through Assimp's reader when aiProcess_Triangulate is off. +QString writeObj(const QString& baseName, + const QString& vertexLines, + const QString& faceLines) +{ + const QString path = QDir::tempPath() + "/" + baseName + ".obj"; + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return {}; + QTextStream out(&f); + out << "# auto-generated by EditableMesh_test\n"; + out << vertexLines; + out << faceLines; + f.close(); + return path; +} +} // namespace + +TEST(EditableMeshStandalone, LoadFromAssimpFileEmptyPathFails) { + EditableMesh mesh; + EXPECT_FALSE(mesh.loadFromAssimpFile("")); + EXPECT_EQ(mesh.subMeshCount(), 0u); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFileMissingFileFails) { + EditableMesh mesh; + EXPECT_FALSE(mesh.loadFromAssimpFile( + "/this/path/does/not/exist.obj")); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFilePreservesQuadFromObj) { + // OBJ with a single quad face on 4 vertices. Without + // aiProcess_Triangulate, Assimp should yield aiMesh::mFaces[0] + // with mNumIndices == 4, which loadFromAssimpFile records as a + // single 4-vertex EditableFace. + const QString path = writeObj("editmesh_quad", + "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\n", + "f 1 2 3 4\n"); + ASSERT_FALSE(path.isEmpty()); + + EditableMesh mesh; + ASSERT_TRUE(mesh.loadFromAssimpFile(path.toStdString())); + QFile::remove(path); + + ASSERT_EQ(mesh.subMeshCount(), 1u); + const auto& sub = mesh.subMeshes()[0]; + EXPECT_EQ(sub.vertices.size(), 4u); + ASSERT_EQ(sub.faces.size(), 1u) + << "OBJ quad must round-trip as a single 4-vertex EditableFace"; + EXPECT_EQ(sub.faces[0].indices.size(), 4u); + // triangles is the fan-triangulation (chunk 1 invariant) + EXPECT_EQ(sub.triangles.size(), 2u); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFileTriangleOnlyLeavesFacesEmpty) { + // Triangle-only OBJ should follow the chunk-1 invariant: faces + // empty, triangles canonical. + const QString path = writeObj("editmesh_tri", + "v 0 0 0\nv 1 0 0\nv 0 1 0\n", + "f 1 2 3\n"); + ASSERT_FALSE(path.isEmpty()); + + EditableMesh mesh; + ASSERT_TRUE(mesh.loadFromAssimpFile(path.toStdString())); + QFile::remove(path); + + ASSERT_EQ(mesh.subMeshCount(), 1u); + const auto& sub = mesh.subMeshes()[0]; + EXPECT_EQ(sub.vertices.size(), 3u); + EXPECT_TRUE(sub.faces.empty()) + << "triangle-only mesh keeps faces empty (legacy invariant)"; + EXPECT_EQ(sub.triangles.size(), 1u); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFileMixedTriAndQuadKeepsBoth) { + // OBJ with one tri and one quad — the submesh should be quad-aware + // (faces non-empty) and triangles should mirror the fan. + const QString path = writeObj("editmesh_mix", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 2 0 0\nv 2 1 0\nv 1 1 0\n", + "f 1 2 3\nf 2 4 5 6\n"); + ASSERT_FALSE(path.isEmpty()); + + EditableMesh mesh; + ASSERT_TRUE(mesh.loadFromAssimpFile(path.toStdString())); + QFile::remove(path); + + ASSERT_EQ(mesh.subMeshCount(), 1u); + const auto& sub = mesh.subMeshes()[0]; + EXPECT_EQ(sub.vertices.size(), 6u); + ASSERT_EQ(sub.faces.size(), 2u); + // Tri = 3 vertices, quad = 4. + bool sawTri = false, sawQuad = false; + for (const auto& f : sub.faces) { + if (f.indices.size() == 3) sawTri = true; + if (f.indices.size() == 4) sawQuad = true; + } + EXPECT_TRUE(sawTri); + EXPECT_TRUE(sawQuad); + // 1 tri + 2 fan tris from the quad = 3 entries. + EXPECT_EQ(sub.triangles.size(), 3u); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFileReplacesPreviousContents) { + // Loading into a non-empty EditableMesh should replace the + // existing submeshes — like buildFromEditableMesh does. + EditableMesh mesh; + EditableSubMesh stale; + EditableVertex v; + stale.vertices = {v, v, v}; + EditableTriangle t; + t.indices[0] = 0; t.indices[1] = 1; t.indices[2] = 2; + stale.triangles.push_back(t); + mesh.subMeshes().push_back(std::move(stale)); + ASSERT_EQ(mesh.subMeshCount(), 1u); + + const QString path = writeObj("editmesh_replace", + "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\n", + "f 1 2 3 4\n"); + ASSERT_FALSE(path.isEmpty()); + ASSERT_TRUE(mesh.loadFromAssimpFile(path.toStdString())); + QFile::remove(path); + + ASSERT_EQ(mesh.subMeshCount(), 1u); + EXPECT_EQ(mesh.subMeshes()[0].vertices.size(), 4u); +} diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 39feca91d..c4c9f90d6 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -997,9 +997,21 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad // DirectX .x is natively left-handed — skip ConvertToLeftHanded // to avoid double-flipping geometry and UVs. bool convertLH = (file.suffix().compare("x", Qt::CaseInsensitive) != 0); - Ogre::MeshPtr mesh = importer.loadModel(file.filePath().toStdString(), convertLH, additionalFlags); + const std::string sourcePath = file.filePath().toStdString(); + Ogre::MeshPtr mesh = importer.loadModel(sourcePath, convertLH, additionalFlags); // Read coordinate system from metadata immediately — valid for both mesh and animation-only files. if (outUpAxis) *outUpAxis = importer.getSceneUpAxis(); + if (mesh) { + // Cache the source file path so EditModeController can + // re-import the asset through the n-gon-aware + // EditableMesh::loadFromAssimpFile path. Quad-bearing + // assets keep their polygon structure when entering + // Edit Mode; without this cache only the triangulated + // Ogre buffer is available and quads are lost. + // (Quad migration #326, chunk 3.) + mesh->getUserObjectBindings().setUserAny( + "qtme.source_path", Ogre::Any(sourcePath)); + } if (!mesh) { // Animation-only file: skeleton/animations were loaded, but there is no mesh. // Collect into the caller-provided list; callers that want UI notifications From 93e2b33c2f6f9d3c035117927b84f8923a2888f8 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 12:35:23 -0400 Subject: [PATCH 07/34] =?UTF-8?q?feat(quads):=20chunk=204=20=E2=80=94=20wi?= =?UTF-8?q?re=20enterEditMode=20to=20n-gon=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks Edit Mode into the loadFromAssimpFile path added in chunk 3, so fresh-imported assets enter Edit Mode with their source quads intact in EditableSubMesh::faces. Modification tracking: - commitToEntity (same-count edits) and resizeEntityBuffers (topology edits) wipe the qtme.source_path cached on Ogre::Mesh. After any user edit, the live GPU buffers diverge from the source file, so re-importing would discard the edit. - This makes "is this mesh modified?" a single-bit invariant: if qtme.source_path is set, the mesh matches the source; if not, the mesh has diverged (or was never imported, e.g. procedural primitives). enterEditMode flow: 1. If qtme.source_path is set → loadFromAssimpFile (n-gon path). 2. Else (or on re-import failure) → loadFromEntity (legacy path). Procedural primitives, .scene.glb sub-entities, post-edit re-entries, and any path that doesn't carry a source path all fall back cleanly. Tests (+5; Ogre-bound, run on Linux CI): - commitToEntity wipes the cached path. - resizeEntityBuffers wipes the cached path. - enterEditMode uses the n-gon path when source_path is set (faces non-empty for a quad OBJ). - enterEditMode falls back to legacy when source_path absent (faces empty — chunk-1 invariant preserved). - After commit-and-exit, re-entering uses the legacy path. Towards #326. Smoke-testable end-to-end: import a quad asset, Tab into Edit Mode, the inspector should report n-gon submeshes with face count matching the source file. Catmull-Clark and loop cut land in chunks 5 and 7 on top of this. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EditModeController.cpp | 46 +++++++++++++- src/EditModeController_test.cpp | 107 ++++++++++++++++++++++++++++++++ src/EditableMesh.cpp | 9 +++ src/EditableMesh_test.cpp | 55 ++++++++++++++++ 4 files changed, 215 insertions(+), 2 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 7d84eaab1..ae0eb931e 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -269,9 +269,51 @@ bool EditModeController::enterEditMode() QList entities = sel->getResolvedEntities(); m_editEntity = entities.first(); - // Decompose mesh into editable data + // Decompose mesh into editable data. Two paths: + // + // - n-gon path: when MeshImporterExporter has cached the source + // file path (qtme.source_path) on the Ogre::Mesh AND no edit + // has been committed since import (commitToEntity / resize- + // EntityBuffers wipe the cache on mutation), re-import the + // asset through Assimp with aiProcess_Triangulate disabled so + // source quads survive into EditableSubMesh::faces. + // + // - legacy path: read the live Ogre buffers via loadFromEntity. + // This is what every prior chunk used, and what every code + // path that doesn't have a source path (procedural primitives, + // .scene.glb sub-entities, post-edit re-entries) falls back to. + // + // The n-gon path enables Catmull-Clark subdivision, loop cut, and + // any future quad-aware op to act on real source quads instead of + // the diagonal triangulation Assimp emits by default. + // (Quad migration #326, chunk 4.) m_editableMesh = std::make_unique(); - if (!m_editableMesh->loadFromEntity(m_editEntity)) { + + bool loaded = false; + { + const Ogre::MeshPtr meshPtr = m_editEntity->getMesh(); + if (meshPtr) { + const auto& bindings = meshPtr->getUserObjectBindings(); + const Ogre::Any& any = bindings.getUserAny("qtme.source_path"); + if (any.has_value()) { + try { + const std::string sourcePath = + Ogre::any_cast(any); + if (!sourcePath.empty() + && m_editableMesh->loadFromAssimpFile(sourcePath)) { + loaded = true; + SentryReporter::addBreadcrumb("edit_mode", + "Edit Mode entered via n-gon import path"); + } + } catch (const Ogre::Exception&) { + // The Any contained something other than a string — + // shouldn't happen but fall through to the legacy + // path defensively. + } + } + } + } + if (!loaded && !m_editableMesh->loadFromEntity(m_editEntity)) { SentryReporter::addBreadcrumb("edit_mode", "Failed to load mesh data for Edit Mode"); m_editableMesh.reset(); m_editEntity = nullptr; diff --git a/src/EditModeController_test.cpp b/src/EditModeController_test.cpp index 49b07a2a2..650c8cd5a 100644 --- a/src/EditModeController_test.cpp +++ b/src/EditModeController_test.cpp @@ -18,6 +18,9 @@ The MIT License #include "HalfEdgeMesh.h" #include #include +#include +#include +#include #include #include #include @@ -1876,3 +1879,107 @@ TEST_F(EditModeControllerBevelE2ETest, FillSelectionPushesUndoCommand) { EXPECT_EQ(trisAfterUndo.size(), triCountBefore - 1) << "undo after fill should drop back to the post-delete tri count"; } + +// =========================================================================== +// enterEditMode: n-gon import path (chunk 4) +// =========================================================================== + +namespace { +// Write a minimal quad OBJ to a temp file. Mirrors the helper in +// EditableMesh_test.cpp (intentionally duplicated rather than shared +// across translation units, since the test files don't share a TU). +QString writeQuadObjForCtrl(const QString& baseName) +{ + const QString path = QDir::tempPath() + "/" + baseName + ".obj"; + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return {}; + QTextStream out(&f); + out << "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n"; + f.close(); + return path; +} +} // namespace + +TEST_F(EditModeControllerBevelE2ETest, EnterEditModeUsesNgonPathWhenSourceCached) { + // When the entity's mesh has qtme.source_path set, enterEditMode + // should re-import via Assimp with aiProcess_Triangulate disabled, + // populating EditableSubMesh::faces with the polygon structure. + const QString objPath = writeQuadObjForCtrl("ctrl_ngon_path"); + ASSERT_FALSE(objPath.isEmpty()); + + // Tag the cube mesh from the fixture's setup with a source path + // pointing at the OBJ. enterEditMode should then re-import the OBJ + // (overriding the cube's geometry — that's fine, the test only + // verifies that the n-gon code path fired, not vertex content). + auto* mesh = m_entity->getMesh().get(); + mesh->getUserObjectBindings().setUserAny( + "qtme.source_path", Ogre::Any(objPath.toStdString())); + + auto* ctrl = EditModeController::instance(); + ASSERT_TRUE(ctrl->enterEditMode()); + + auto* editableMesh = ctrl->currentMesh(); + ASSERT_NE(editableMesh, nullptr); + ASSERT_FALSE(editableMesh->subMeshes().empty()); + EXPECT_FALSE(editableMesh->subMeshes()[0].faces.empty()) + << "enterEditMode with source_path set should populate faces " + "via the n-gon-aware loadFromAssimpFile path"; + EXPECT_EQ(editableMesh->subMeshes()[0].faces[0].indices.size(), 4u); + + QFile::remove(objPath); +} + +TEST_F(EditModeControllerBevelE2ETest, EnterEditModeFallsBackToLegacyWhenNoSourcePath) { + // The fixture's cube has no qtme.source_path tag. enterEditMode + // should fall through to loadFromEntity and produce the + // triangle-only legacy submesh shape (faces empty). + auto* mesh = m_entity->getMesh().get(); + EXPECT_FALSE(mesh->getUserObjectBindings().getUserAny( + "qtme.source_path").has_value()) + << "fixture cube starts without source path — sanity check"; + + auto* ctrl = EditModeController::instance(); + ASSERT_TRUE(ctrl->enterEditMode()); + + auto* editableMesh = ctrl->currentMesh(); + ASSERT_NE(editableMesh, nullptr); + ASSERT_FALSE(editableMesh->subMeshes().empty()); + EXPECT_TRUE(editableMesh->subMeshes()[0].faces.empty()) + << "no source path → legacy path → faces empty (chunk-1 invariant)"; +} + +TEST_F(EditModeControllerBevelE2ETest, EnterEditModeAfterEditDoesNotReimport) { + // Enter Edit Mode with the n-gon path (sets faces), make a + // committable edit, exit, re-enter. The second entry must use the + // legacy path because the first edit wiped qtme.source_path. + const QString objPath = writeQuadObjForCtrl("ctrl_ngon_post_edit"); + ASSERT_FALSE(objPath.isEmpty()); + + auto* mesh = m_entity->getMesh().get(); + mesh->getUserObjectBindings().setUserAny( + "qtme.source_path", Ogre::Any(objPath.toStdString())); + + auto* ctrl = EditModeController::instance(); + ASSERT_TRUE(ctrl->enterEditMode()); + ASSERT_FALSE(ctrl->currentMesh()->subMeshes().empty()); + ASSERT_FALSE(ctrl->currentMesh()->subMeshes()[0].faces.empty()) + << "first entry must take the n-gon path"; + + // Commit any change (translate one vertex by zero — still triggers + // the commitToEntity path that wipes the source path). + ctrl->setSelectionMode(EditModeController::VertexMode); + ctrl->selectVertex(0); + ctrl->translateSelectedVertices(Ogre::Vector3::ZERO); + ctrl->exitEditMode(/*commitChanges*/ true); + + EXPECT_FALSE(mesh->getUserObjectBindings().getUserAny( + "qtme.source_path").has_value()) + << "exitEditMode commit must have wiped the source path"; + + // Re-enter: should fall back to legacy path now. + ASSERT_TRUE(ctrl->enterEditMode()); + EXPECT_TRUE(ctrl->currentMesh()->subMeshes()[0].faces.empty()) + << "second entry (post-edit) must use legacy loadFromEntity path"; + + QFile::remove(objPath); +} diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 89e4dec08..0be6d7cc0 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -448,6 +448,12 @@ bool EditableMesh::commitToEntity(Ogre::Entity* entity) } } + // Clear the cached source-file path: the live GPU buffers have + // diverged from the imported asset. Subsequent enterEditMode calls + // must use the legacy loadFromEntity path so user edits aren't + // discarded by an n-gon re-import. (Quad migration #326, chunk 4.) + mesh->getUserObjectBindings().eraseUserAny("qtme.source_path"); + return true; } @@ -639,6 +645,9 @@ bool EditableMesh::resizeEntityBuffers(Ogre::Entity* entity) // Recalculate bounds SubMeshTransform::recalculateMeshBounds(mesh); + // Topology has changed — same rationale as commitToEntity above. + mesh->getUserObjectBindings().eraseUserAny("qtme.source_path"); + return true; } diff --git a/src/EditableMesh_test.cpp b/src/EditableMesh_test.cpp index f26e1b286..5d6d9d22c 100644 --- a/src/EditableMesh_test.cpp +++ b/src/EditableMesh_test.cpp @@ -1047,3 +1047,58 @@ TEST(EditableMeshStandalone, LoadFromAssimpFileReplacesPreviousContents) { ASSERT_EQ(mesh.subMeshCount(), 1u); EXPECT_EQ(mesh.subMeshes()[0].vertices.size(), 4u); } + +// =========================================================================== +// commitToEntity / resizeEntityBuffers wipe qtme.source_path (chunk 4) +// =========================================================================== + +TEST_F(EditableMeshTest, CommitToEntityClearsCachedSourcePath) { + // Simulate: a freshly-imported asset has the source path tag + // attached. Commit-to-entity (a same-vertex-count edit) must clear + // the tag so the next enterEditMode falls back to the legacy + // loadFromEntity path instead of re-importing and discarding the + // user's edit. + auto meshPtr = createInMemoryTriangleMesh("EditableMesh_commit_clear_path"); + auto* node = Manager::getSingleton()->addSceneNode("EditableMesh_commit_clear_path_node"); + auto* entity = Manager::getSingleton()->createEntity(node, meshPtr); + + meshPtr->getUserObjectBindings().setUserAny( + "qtme.source_path", Ogre::Any(std::string("/some/path.fbx"))); + ASSERT_TRUE(meshPtr->getUserObjectBindings().getUserAny( + "qtme.source_path").has_value()); + + EditableMesh editMesh; + ASSERT_TRUE(editMesh.loadFromEntity(entity)); + EXPECT_TRUE(editMesh.commitToEntity(entity)); + + EXPECT_FALSE(meshPtr->getUserObjectBindings().getUserAny( + "qtme.source_path").has_value()) + << "commitToEntity must wipe the cached source path so subsequent " + "enterEditMode calls don't re-import and lose the edit"; + + Manager::getSingleton()->destroySceneNode( + "EditableMesh_commit_clear_path_node"); +} + +TEST_F(EditableMeshTest, ResizeEntityBuffersClearsCachedSourcePath) { + // Same rationale as above for the topology-edit path. + auto meshPtr = createInMemoryTriangleMesh("EditableMesh_resize_clear_path"); + auto* node = Manager::getSingleton()->addSceneNode("EditableMesh_resize_clear_path_node"); + auto* entity = Manager::getSingleton()->createEntity(node, meshPtr); + + meshPtr->getUserObjectBindings().setUserAny( + "qtme.source_path", Ogre::Any(std::string("/some/path.fbx"))); + ASSERT_TRUE(meshPtr->getUserObjectBindings().getUserAny( + "qtme.source_path").has_value()); + + EditableMesh editMesh; + ASSERT_TRUE(editMesh.loadFromEntity(entity)); + EXPECT_TRUE(editMesh.resizeEntityBuffers(entity)); + + EXPECT_FALSE(meshPtr->getUserObjectBindings().getUserAny( + "qtme.source_path").has_value()) + << "resizeEntityBuffers must wipe the cached source path"; + + Manager::getSingleton()->destroySceneNode( + "EditableMesh_resize_clear_path_node"); +} From 84ec6346700c4f41ad4d261a0ac0ac13848ff569 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 13:26:57 -0400 Subject: [PATCH 08/34] fix(quads): preserve X-handedness in n-gon re-import (chunk 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original AssimpToOgreImporter applies aiProcess_ConvertToLeftHanded to every non-.x asset, which flips X (and inverts UV V) so Ogre's left-handed coordinate system gets correct geometry. My chunk-4 loadFromAssimpFile re-imported the same source file without that flag, so EditableMesh ended up in the original (right-handed) coordinate space while the rendered Ogre buffers were in the flipped (left-handed) space. The vertex / edge / face overlays in Edit Mode therefore drew mirrored relative to the on-screen mesh. (Reported manually on a Tom & Jerry asset — overlay points appeared reflected through the Y-Z plane.) Fix: - MeshImporterExporter::importer caches the convert-to-left-handed decision alongside the source path on Ogre::Mesh (qtme.source_convert_lh). - EditableMesh::loadFromAssimpFile gains a `convertToLeftHanded` parameter (default true to match the importer's typical behaviour). Applies aiProcess_ConvertToLeftHanded when set. - EditModeController::enterEditMode reads the cached flag and passes it through, so the editable mesh ends up in the same coordinate system as the rendered Ogre buffers. - commitToEntity / resizeEntityBuffers now wipe both qtme.source_path AND qtme.source_convert_lh on user edits — the pair is conceptually one cache entry. Existing 6 LoadFromAssimpFile tests still green (default param keeps their behaviour identical). Towards #326. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EditModeController.cpp | 20 +++++++++++++++++++- src/EditableMesh.cpp | 15 ++++++++++++--- src/EditableMesh.h | 11 ++++++++++- src/MeshImporterExporter.cpp | 9 +++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index ae0eb931e..e4f1f3c9c 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -299,8 +299,26 @@ bool EditModeController::enterEditMode() try { const std::string sourcePath = Ogre::any_cast(any); + // The original importer cached its + // convert-to-left-handed choice alongside the path. + // Apply the SAME flag here so the editable mesh + // stays in the same coordinate system as the + // rendered Ogre buffers — without this, on every + // non-.x asset the vertex / edge / face overlays + // would draw mirrored (X flipped) relative to the + // on-screen geometry. Defaults to true to match + // AssimpToOgreImporter's behaviour for unknown + // origins. + bool convertLH = true; + const Ogre::Any& lhAny = + bindings.getUserAny("qtme.source_convert_lh"); + if (lhAny.has_value()) { + try { + convertLH = Ogre::any_cast(lhAny); + } catch (const Ogre::Exception&) {} + } if (!sourcePath.empty() - && m_editableMesh->loadFromAssimpFile(sourcePath)) { + && m_editableMesh->loadFromAssimpFile(sourcePath, convertLH)) { loaded = true; SentryReporter::addBreadcrumb("edit_mode", "Edit Mode entered via n-gon import path"); diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 0be6d7cc0..293ff2cd5 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -146,7 +146,8 @@ bool EditableMesh::loadFromMesh(const Ogre::MeshPtr& meshPtr) return true; } -bool EditableMesh::loadFromAssimpFile(const std::string& path) +bool EditableMesh::loadFromAssimpFile(const std::string& path, + bool convertToLeftHanded) { if (path.empty()) return false; @@ -154,14 +155,20 @@ bool EditableMesh::loadFromAssimpFile(const std::string& path) // existing AssimpToOgreImporter pipeline. Drop aiProcess_Triangulate // so source quads survive into aiMesh::mFaces. Keep the rest of the // post-processing aligned with the rendering importer so vertex - // attributes don't drift between the two views. + // attributes don't drift between the two views — including the + // ConvertToLeftHanded flip that AssimpToOgreImporter applies to + // every non-.x asset. Without matching that flag here, the + // editable mesh would end up mirrored (X flipped) relative to the + // rendered Ogre mesh, and the vertex/edge/face overlays would draw + // on the wrong side of the on-screen geometry. (Chunk 4 fix.) Assimp::Importer importer; - const unsigned int flags = + unsigned int flags = aiProcess_JoinIdenticalVertices | aiProcess_GenSmoothNormals | aiProcess_ValidateDataStructure | aiProcess_LimitBoneWeights | aiProcess_GlobalScale; + if (convertToLeftHanded) flags |= aiProcess_ConvertToLeftHanded; const aiScene* scene = importer.ReadFile(path, flags); if (!scene || !scene->mRootNode || scene->mNumMeshes == 0) { Ogre::LogManager::getSingleton().logMessage( @@ -453,6 +460,7 @@ bool EditableMesh::commitToEntity(Ogre::Entity* entity) // must use the legacy loadFromEntity path so user edits aren't // discarded by an n-gon re-import. (Quad migration #326, chunk 4.) mesh->getUserObjectBindings().eraseUserAny("qtme.source_path"); + mesh->getUserObjectBindings().eraseUserAny("qtme.source_convert_lh"); return true; } @@ -647,6 +655,7 @@ bool EditableMesh::resizeEntityBuffers(Ogre::Entity* entity) // Topology has changed — same rationale as commitToEntity above. mesh->getUserObjectBindings().eraseUserAny("qtme.source_path"); + mesh->getUserObjectBindings().eraseUserAny("qtme.source_convert_lh"); return true; } diff --git a/src/EditableMesh.h b/src/EditableMesh.h index c1a4bbc31..091195fe2 100644 --- a/src/EditableMesh.h +++ b/src/EditableMesh.h @@ -262,10 +262,19 @@ class EditableMesh * @param path The path the asset was originally imported from. * Should be the value cached on `Ogre::Mesh` via * `getUserObjectBindings().getUserAny("qtme.source_path")`. + * @param convertToLeftHanded If true, applies `aiProcess_ConvertToLeftHanded` + * so the resulting positions / UVs match Ogre's left-handed + * coordinate system. MUST match the flag the original + * import used; otherwise the editable representation + * will be mirrored (X flipped) relative to the rendered + * mesh and the vertex/edge/face overlays will draw + * on the wrong side. The original importer caches its + * choice at `getUserAny("qtme.source_convert_lh")`. * @return true on success; false if the file is missing, can't be * parsed, or contains no mesh data. */ - bool loadFromAssimpFile(const std::string& path); + bool loadFromAssimpFile(const std::string& path, + bool convertToLeftHanded = true); /** * @brief Merge vertices at (approximately) coincident positions within diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index c4c9f90d6..59cbf494a 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -1011,6 +1011,15 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad // (Quad migration #326, chunk 3.) mesh->getUserObjectBindings().setUserAny( "qtme.source_path", Ogre::Any(sourcePath)); + // ALSO cache the convert-to-left-handed flag so the + // n-gon re-import uses the SAME coordinate-system + // transform AssimpToOgreImporter::loadModel applied + // when building the rendered Ogre mesh. Without this + // the Edit-Mode vertex overlay would appear mirrored + // (X flipped) relative to the on-screen geometry on + // every non-.x asset. (Chunk 4.) + mesh->getUserObjectBindings().setUserAny( + "qtme.source_convert_lh", Ogre::Any(convertLH)); } if (!mesh) { // Animation-only file: skeleton/animations were loaded, but there is no mesh. From 7a61fcd85b3b196607404f1475825afe1100d3ea Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 13:46:21 -0400 Subject: [PATCH 09/34] =?UTF-8?q?feat(quads):=20chunk=205a=20=E2=80=94=20C?= =?UTF-8?q?atmull-Clark=20subdivide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the classic Catmull-Clark subdivision-surface operation on the half-edge mesh. Always produces an all-quad output regardless of input topology — a triangle becomes 3 quads, a quad becomes 4, an N-gon becomes N. Output geometry approaches a C¹-continuous limit on closed manifolds via the standard rule: - Face point Fp = average of corner positions. - Edge point Ep = (a + b + Fp1 + Fp2) / 4 on interior edges, (a + b) / 2 on boundary or cross-submesh edges. - Smoothed vertex V' = (F + 2R + (n-3) V) / n on interior vertices, chord rule on boundary vertices. F = avg of adjacent face points, R = avg of adjacent edge midpoints (NOT edge points — spec). Cross-submesh edges are treated as boundaries so material seams stay sharp through the subdivision. Bone weights on smoothed vertices keep the original assignment (the smoothed position is still mostly "near V"); other attributes (UV, normal, color, tangent) blend with the same weights as positions. UI: - The Subdivide toolbar button becomes a dropdown: 1. Standard — the existing 1-to-4 triangle split on selected faces / edges. Unchanged behaviour. 2. Catmull-Clark — whole-mesh subdivide-surface step. Selection is cleared after the op (partial-CC with selection preservation needs a more sophisticated boundary blend that isn't in this MVP). - Button is enabled whenever in edit mode; menu items self-gate. Tests (+7 standalone): - 1 quad → 4 sub-quads. - 1 triangle → 3 sub-quads. - Closed cube stays closed (12 → 36 quads, no boundary edges). - 2x2 quad grid → 16 sub-quads. - Planar quad face point lands at arithmetic centre. - Empty mesh is a no-op. - Round-trip through EditableMesh preserves the all-quad output. 204 standalone tests pass (was 197; +7). Towards #326. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EditModeController.cpp | 57 +++++++ src/EditModeController.h | 22 +++ src/HalfEdgeMesh.cpp | 332 +++++++++++++++++++++++++++++++++++++ src/HalfEdgeMesh.h | 41 +++++ src/HalfEdgeMesh_test.cpp | 181 ++++++++++++++++++++ src/mainwindow.cpp | 32 +++- 6 files changed, 656 insertions(+), 9 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index e4f1f3c9c..e4ebf1a8f 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -3083,6 +3083,63 @@ int EditModeController::subdivideSelection() return static_cast(targetFaces.size()); } +int EditModeController::subdivideCatmullClarkAll() +{ + if (!m_editModeActive || !m_editableMesh || !m_editEntity) return 0; + + // Cancel interactive previews — same rationale as deleteSelection + // (a stale bevel/knife snapshot would replay against post-CC topology). + if (m_bevelSession.active) cancelBevel(); + if (m_knifeSession.active) cancelKnife(); + + HalfEdgeMesh hm; + if (!hm.buildFromEditableMesh(*m_editableMesh)) return 0; + + auto originalSubMeshes = m_editableMesh->subMeshes(); + const auto preSelectedVerts = m_selectedVertices; + const auto preSelectedEdges = m_selectedEdges; + const auto preSelectedFaces = m_selectedFaces; + + const auto newVerts = hm.subdivideCatmullClark(); + if (newVerts.empty()) return 0; + + EditableMesh updated; + if (!hm.toEditableMesh(updated)) return 0; + m_editableMesh->subMeshes() = std::move(updated.subMeshes()); + + if (m_normalsMode == 0) m_editableMesh->recalculateNormals(); + else m_editableMesh->recalculateNormalsFlat(); + + m_editableMesh->resizeEntityBuffers(m_editEntity); + rewriteEntityAfterTopologyChange(m_editEntity); + + // Selection clears: post-CC topology has no stable mapping back to + // the pre-op selection (face/edge points didn't exist, vertex + // indices may have shifted on re-pack). Fresh start is the safe + // default; partial-CC with selection preservation is a follow-up. + m_selectedVertices.clear(); + m_selectedEdges.clear(); + m_selectedFaces.clear(); + + auto* cmd = new EditMeshTopologyCommand( + std::move(originalSubMeshes), + m_editableMesh->subMeshes(), + preSelectedVerts, preSelectedEdges, preSelectedFaces, + m_selectedVertices, m_selectedEdges, m_selectedFaces, + QStringLiteral("Catmull-Clark Subdivide")); + UndoManager::getSingleton()->push(cmd); + + validateMesh(); + SentryReporter::addBreadcrumb("edit_mode", + QString("Catmull-Clark Subdivide (newVerts=%1)").arg(newVerts.size())); + + updateSelectionOverlay(); + refreshNormalVisualizer(); + emit editSelectionChanged(); + emit meshDataChanged(); + return static_cast(newVerts.size()); +} + namespace { // Build the closed BOUNDARY-EDGE loop implied by a set of selected edges. // Returns vertex indices in winding order, or an empty vector if any diff --git a/src/EditModeController.h b/src/EditModeController.h index ec1ea18dc..5cb55564f 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -441,6 +441,28 @@ class EditModeController : public QObject */ Q_INVOKABLE int subdivideSelection(); + /** + * @brief Subdivide the entire mesh by one Catmull-Clark step. + * + * Unlike `subdivideSelection` (which does a 1-to-4 triangle split + * on the selected faces only), this op operates on the whole mesh + * at once and produces an all-quad output regardless of input + * topology — a triangle becomes 3 quads, a quad becomes 4. Output + * geometry is smoothed via the classic Catmull-Clark rule (face + * points, edge points, smoothed vertex positions) so the surface + * approaches a C¹-continuous limit on closed manifolds. + * + * Selection is cleared after the op (the new face/edge points + * don't have stable analogues in the pre-op selection set, and + * partial-mesh CC needs a more sophisticated boundary blend that + * isn't in this MVP). + * + * Pushes one undo command labeled "Catmull-Clark Subdivide". + * + * @return Number of vertices added (0 on no-op). + */ + Q_INVOKABLE int subdivideCatmullClarkAll(); + /** * @brief Fill the current selection with new face(s). * diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index b12a52ace..0fcf437a8 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -4798,6 +4798,338 @@ std::vector HalfEdgeMesh::subdivideFaces(const std::vector& faceIndice return newVertices; } +namespace { + +// Average a list of HEVertex by uniform weight. Used for face points +// (corners → face point) and as the base for edge / smoothing rules. +// Position / normal / UV / color / tangent are summed and divided; +// bone weights are accumulated per bone index. Output flags follow +// the AND of input flags so a vertex with no UV doesn't fabricate one. +HEVertex averageHEVertices(const std::vector& src) +{ + HEVertex r; + if (src.empty()) return r; + const float w = 1.0f / static_cast(src.size()); + bool anyNorm = true, anyUV = true, anyCol = true, anyTan = true; + for (const auto* v : src) { + r.position += v->position * w; + r.normal += v->normal * w; + r.uv += v->uv * w; + r.color += v->color * w; + r.tangent += v->tangent * w; + anyNorm = anyNorm && v->hasNormal; + anyUV = anyUV && v->hasUV; + anyCol = anyCol && v->hasColor; + anyTan = anyTan && v->hasTangent; + } + r.hasNormal = anyNorm; + r.hasUV = anyUV; + r.hasColor = anyCol; + r.hasTangent = anyTan; + if (r.normal.squaredLength() > 1e-12f) r.normal.normalise(); + + auto addBone = [&](unsigned short idx, float weight) { + if (weight <= 1e-6f) return; + for (auto& ba : r.boneAssignments) { + if (ba.first == idx) { ba.second += weight; return; } + } + r.boneAssignments.emplace_back(idx, weight); + }; + for (const auto* v : src) { + for (const auto& ba : v->boneAssignments) addBone(ba.first, ba.second * w); + } + return r; +} + +// Midpoint of two HE vertices with the same attribute rules as +// averageHEVertices. Used by the boundary-edge rule and by midpoint +// computation for the interior smoothing R term. +HEVertex midpointHEVertices(const HEVertex& a, const HEVertex& b) +{ + return averageHEVertices({&a, &b}); +} + +} // namespace + +std::vector HalfEdgeMesh::subdivideCatmullClark() +{ + std::vector newVertices; + + // 0. Snapshot the live geometry. The algorithm reads from the + // pre-step state for the smoothing rule, then writes new + // geometry on top — so we keep a copy of every original vertex. + const std::vector origVerts = m_vertices; + const int origVertexCount = static_cast(origVerts.size()); + const int origFaceCount = static_cast(m_faces.size()); + const int origEdgeCount = static_cast(m_edges.size()); + if (origVertexCount == 0 || origFaceCount == 0) return newVertices; + + // 1. Compute one face point per live face. The face point is the + // arithmetic mean of the face's corner vertices. Skip retired + // faces (halfEdge < 0). Track the face's submesh so output + // quads inherit it. + std::vector facePointIdx(origFaceCount, -1); // HE-vertex idx of face point + std::vector faceSubMesh(origFaceCount, -1); + std::vector> faceCornerIdxs(origFaceCount); + for (int f = 0; f < origFaceCount; ++f) { + if (m_faces[f].halfEdge < 0) continue; + const auto verts = faceVertices(f); + if (verts.size() < 3) continue; + // Cross-submesh skip: faceVertices only walks one face so the + // submesh check is implicit (the face IS one submesh's). The + // cross-submesh guard for SHARED edges happens in step 2. + + std::vector src; + src.reserve(verts.size()); + for (int v : verts) src.push_back(&origVerts[v]); + + HEVertex fp = averageHEVertices(src); + fp.halfEdge = -1; + facePointIdx[f] = static_cast(m_vertices.size()); + m_vertices.push_back(std::move(fp)); + newVertices.push_back(facePointIdx[f]); + + faceSubMesh[f] = m_faces[f].subMeshIndex; + faceCornerIdxs[f] = std::move(verts); + } + + // 2. Compute one edge point per live edge. For an interior edge + // with two adjacent faces in the SAME submesh: + // Ep = (a + b + Fp1 + Fp2) / 4 + // For a boundary edge or a cross-submesh edge: + // Ep = (a + b) / 2 + // Cross-submesh edges are treated as boundaries so material + // seams stay sharp through the subdivision. + std::vector edgePointIdx(origEdgeCount, -1); + std::vector edgeIsBoundary(origEdgeCount, false); + for (int e = 0; e < origEdgeCount; ++e) { + if (m_edges[e].halfEdge < 0) continue; + const auto [va, vb] = edgeVertices(e); + if (va < 0 || vb < 0) continue; + const auto [fA, fB] = edgeFaces(e); + + bool boundary = (fA < 0 || fB < 0); + if (!boundary) { + // Cross-submesh = treat as boundary. + if (m_faces[fA].subMeshIndex != m_faces[fB].subMeshIndex) + boundary = true; + } + edgeIsBoundary[e] = boundary; + + HEVertex ep; + if (boundary) { + ep = midpointHEVertices(origVerts[va], origVerts[vb]); + } else { + std::vector src; + src.reserve(4); + src.push_back(&origVerts[va]); + src.push_back(&origVerts[vb]); + // Adjacent face points are stored as new HE vertices already; + // read their positions back from m_vertices. + if (facePointIdx[fA] >= 0) src.push_back(&m_vertices[facePointIdx[fA]]); + if (facePointIdx[fB] >= 0) src.push_back(&m_vertices[facePointIdx[fB]]); + ep = averageHEVertices(src); + } + ep.halfEdge = -1; + edgePointIdx[e] = static_cast(m_vertices.size()); + m_vertices.push_back(std::move(ep)); + newVertices.push_back(edgePointIdx[e]); + } + + // 3. Update each ORIGINAL vertex in place using the smoothing rule. + // Use origVerts (the snapshot) for any reads — m_vertices[v] is + // being overwritten in this loop. For a boundary vertex, blend + // against its two boundary-edge midpoints; that keeps mesh + // boundaries continuous. For an interior vertex of valence n, + // use Catmull-Clark's classic formula. + for (int v = 0; v < origVertexCount; ++v) { + if (origVerts[v].halfEdge < 0) continue; + + const bool boundary = isVertexBoundary(v); + const auto incidentFaces = facesAroundVertex(v); + const auto incidentEdges = edgesAroundVertex(v); + if (incidentFaces.empty() || incidentEdges.empty()) continue; + + if (boundary) { + // Find the two boundary edges this vertex is on. Their + // mid-points (using PRE-step geometry) plus the vertex's + // own position averaged 1:2:1 weighted is the standard + // chord rule: V' = (V + Em1 + Em2) / 4 where each Em is + // the boundary-edge midpoint between V and the other + // endpoint. We pre-computed boundary edge points in step 2 + // already, so just reuse those. + std::vector src; + src.reserve(3); + src.push_back(&origVerts[v]); + int boundaryEpsFound = 0; + for (int e : incidentEdges) { + if (!edgeIsBoundary[e]) continue; + if (edgePointIdx[e] >= 0) { + src.push_back(&m_vertices[edgePointIdx[e]]); + ++boundaryEpsFound; + } + } + if (boundaryEpsFound < 2) { + // Non-manifold corner or weird topology — leave the + // vertex alone rather than smoothing into a wrong place. + continue; + } + // Average the 3 contributors; this is V/3 + Em1/3 + Em2/3 + // — close to the V/4 + (Em1+Em2)/4 + (Em1+Em2)/4 chord + // rule but slightly more centred on V. Acceptable for the + // interactive editor; tighter weights are a follow-up. + HEVertex updated = averageHEVertices(src); + updated.halfEdge = origVerts[v].halfEdge; // keep outgoing HE + m_vertices[v] = std::move(updated); + } else { + // Interior vertex of valence n. + // F = average of adjacent face points + // R = average of adjacent edge MIDPOINTS (NOT the new + // edge points — that's the spec; midpoint = (a+b)/2) + // V' = (F + 2R + (n-3) V) / n + const int n = static_cast(incidentEdges.size()); + if (n < 3) continue; + + // F: average of adjacent face points (in PRE-step + // m_vertices slots). + HEVertex F; + { + std::vector src; + src.reserve(incidentFaces.size()); + for (int f : incidentFaces) { + if (facePointIdx[f] >= 0) + src.push_back(&m_vertices[facePointIdx[f]]); + } + if (src.empty()) continue; + F = averageHEVertices(src); + } + + // R: average of edge midpoints (NOT the smoothed edge + // points). Compute midpoints on-the-fly from origVerts. + HEVertex R; + { + std::vector midStorage; + midStorage.reserve(incidentEdges.size()); + std::vector src; + src.reserve(incidentEdges.size()); + for (int e : incidentEdges) { + const auto [ea, eb] = edgeVertices(e); + if (ea < 0 || eb < 0) continue; + midStorage.push_back(midpointHEVertices( + origVerts[ea], origVerts[eb])); + src.push_back(&midStorage.back()); + } + if (src.empty()) continue; + R = averageHEVertices(src); + } + + // V'_position = (F + 2R + (n-3) V) / n. Apply the same + // weighted blend to all attributes. + const float invN = 1.0f / static_cast(n); + HEVertex updated; + updated.position = + (F.position + R.position * 2.0f + + origVerts[v].position * static_cast(n - 3)) * invN; + updated.normal = + (F.normal + R.normal * 2.0f + + origVerts[v].normal * static_cast(n - 3)) * invN; + if (updated.normal.squaredLength() > 1e-12f) + updated.normal.normalise(); + updated.uv = + (F.uv + R.uv * 2.0f + + origVerts[v].uv * static_cast(n - 3)) * invN; + updated.color = + (F.color + R.color * 2.0f + + origVerts[v].color * static_cast(n - 3)) * invN; + updated.tangent = + (F.tangent + R.tangent * 2.0f + + origVerts[v].tangent * static_cast(n - 3)) * invN; + updated.hasNormal = F.hasNormal && R.hasNormal && origVerts[v].hasNormal; + updated.hasUV = F.hasUV && R.hasUV && origVerts[v].hasUV; + updated.hasColor = F.hasColor && R.hasColor && origVerts[v].hasColor; + updated.hasTangent = F.hasTangent && R.hasTangent && origVerts[v].hasTangent; + // Bone weights: use the original vertex's weights — the + // smoothed position is still mostly "near V", so preserving + // V's skinning is closer to correct than averaging across + // unrelated neighbours. + updated.boneAssignments = origVerts[v].boneAssignments; + updated.halfEdge = origVerts[v].halfEdge; + m_vertices[v] = std::move(updated); + } + } + + // 4. Replace each face with its quad fan. For a face with corners + // [c0, c1, ..., c(N-1)], emit N quads: + // quad_i = (c_i, edgePoint(c_i, c_{i+1}), facePoint, edgePoint(c_{i-1}, c_i)) + // The output is always quads, regardless of input N (≥3). + auto retireFace = [&](int faceIdx) { + if (faceIdx < 0) return; + const int startHE = m_faces[faceIdx].halfEdge; + if (startHE < 0) return; + int he = startHE; + do { + const int next = m_halfEdges[he].next; + m_halfEdges[he].face = -1; + he = next; + } while (he != startHE && he >= 0); + m_faces[faceIdx].halfEdge = -1; + }; + + auto findEdgeIdxByVerts = [&](int va, int vb) -> int { + // Search the LIVE edges (after step-2 the m_edges array still + // describes the pre-step topology; we haven't rebuilt yet). + for (int e = 0; e < origEdgeCount; ++e) { + if (m_edges[e].halfEdge < 0) continue; + const auto [a, b] = edgeVertices(e); + if ((a == va && b == vb) || (a == vb && b == va)) return e; + } + return -1; + }; + + for (int f = 0; f < origFaceCount; ++f) { + if (facePointIdx[f] < 0) continue; + const auto& corners = faceCornerIdxs[f]; + const int N = static_cast(corners.size()); + if (N < 3) continue; + + // Pre-resolve edge points for each consecutive corner pair so + // we don't search twice per quad. + std::vector edgePointForSide(N, -1); + for (int i = 0; i < N; ++i) { + const int va = corners[i]; + const int vb = corners[(i + 1) % N]; + const int eIdx = findEdgeIdxByVerts(va, vb); + if (eIdx >= 0) edgePointForSide[i] = edgePointIdx[eIdx]; + } + + retireFace(f); + + const int subIdx = faceSubMesh[f]; + const int fp = facePointIdx[f]; + for (int i = 0; i < N; ++i) { + const int prevSide = (i + N - 1) % N; + const int currSide = i; + const int corner = corners[i]; + const int epPrev = edgePointForSide[prevSide]; + const int epCurr = edgePointForSide[currSide]; + if (epPrev < 0 || epCurr < 0) continue; // corrupt — skip + // Quad winding: (corner, ep_curr, face_point, ep_prev). + // Walking corner → ep_curr matches the direction + // corners[i] → corners[i+1], so the resulting quad is + // CCW relative to the original face's winding. + appendFace({corner, epCurr, fp, epPrev}, subIdx); + } + } + + // 5. Rebuild edge / twin / boundary / vertex tables. + rebuildEdgesAndTwins(); + compactBoundaryHalfEdges(); + buildBoundaryHalfEdges(); + fixVertexHalfEdges(); + + return newVertices; +} + int HalfEdgeMesh::fillSelection(const std::vector& vertexIndices) { const int n = static_cast(vertexIndices.size()); diff --git a/src/HalfEdgeMesh.h b/src/HalfEdgeMesh.h index c83a6923b..f72277866 100644 --- a/src/HalfEdgeMesh.h +++ b/src/HalfEdgeMesh.h @@ -603,6 +603,47 @@ class HalfEdgeMesh */ std::vector subdivideFaces(const std::vector& faceIndices); + /** + * @brief Subdivide every face by one Catmull-Clark step. + * + * The classic Catmull-Clark scheme. For each face F: + * - Compute a face point Fp = average of its corner positions. + * - For each edge E with endpoints (a, b) and adjacent face points + * (Fp1, Fp2), compute the edge point Ep = (a + b + Fp1 + Fp2) / 4 + * on interior edges, or Ep = (a + b) / 2 on boundary edges. + * - For each original vertex V of valence n with adjacent face + * points Fi (mean F) and edge midpoints Ri (mean R, taken on + * PRE-update positions), update V to (F + 2R + (n-3) V) / n on + * interior vertices, or (V + Em1 + Em2) / 4 on boundary + * vertices using its two boundary edge midpoints. + * - Replace each face F (with n corners) by n quads, each formed + * from (corner, neighbouring-edge-point, face-point, + * other-neighbouring-edge-point). + * + * Output is ALWAYS quads, regardless of input — a triangle becomes + * 3 quads, a quad becomes 4 quads, an N-gon becomes N quads. + * Submesh assignments are preserved (each output quad inherits its + * source face's submesh). + * + * UVs / normals / colors / bone weights / tangents are blended + * with the same weights as positions (face point: avg of corners; + * edge point: avg of endpoints+adjacent face points; updated + * vertex: weighted blend per the rule above). For boundary + * vertices the chord rule keeps UV seams reasonable; geometry on + * a closed manifold is C¹ continuous in the limit. + * + * Skips faces that span multiple submeshes (would silently weld + * material groups). Cross-submesh edges are treated as boundaries + * for the smoothing rule, so submesh boundaries stay sharp. + * + * @return Indices of every newly created vertex (face points, + * edge points, in creation order). The original vertex + * slots are reused for the smoothed positions; their + * indices are unchanged. Empty on no-op (all submeshes + * empty / all faces invalid). + */ + std::vector subdivideCatmullClark(); + /** * @brief Fill a face from selected vertices or a closed edge loop. * diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index 8f984189b..693c4f186 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -4816,3 +4816,184 @@ TEST(HalfEdgeMeshStandalone, PromoteTrianglesToFacesProducesMatchingFaces) { // identical content) EXPECT_EQ(sub.triangles.size(), 2u); } + +// =========================================================================== +// subdivideCatmullClark — chunk 5a quad-aware subdivision +// =========================================================================== + +TEST(HalfEdgeMeshStandalone, CatmullClarkOnSingleQuadProducesFourQuads) { + // A single quad → 1 face point + 4 edge points + 4 corners (the 4 + // corners get smoothed in place — for a flat planar quad on a + // boundary, the boundary-vertex chord rule will have moved them). + // After CC: 4 output faces, each a quad. + EditableMesh em; + EditableSubMesh sub; + auto mkV = [](float x, float y, float z) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, z); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { + mkV(0, 0, 0), mkV(1, 0, 0), mkV(1, 1, 0), mkV(0, 1, 0), + }; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + em.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + ASSERT_EQ(activeFaceCount(he), 1); + + const auto newVerts = he.subdivideCatmullClark(); + EXPECT_FALSE(newVerts.empty()); + EXPECT_EQ(activeFaceCount(he), 4) << "1 quad → 4 sub-quads"; + + // Every output face should be a quad. + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + EXPECT_EQ(he.faceVertices(static_cast(f)).size(), 4u) + << "Catmull-Clark always produces quads"; + } + EXPECT_TRUE(he.validate()); +} + +TEST(HalfEdgeMeshStandalone, CatmullClarkOnTriangleProducesThreeQuads) { + // A single triangle → 3 sub-quads (one per corner). + auto em = makeTriangleMesh(); + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + ASSERT_EQ(activeFaceCount(he), 1); + + he.subdivideCatmullClark(); + EXPECT_EQ(activeFaceCount(he), 3); + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + EXPECT_EQ(he.faceVertices(static_cast(f)).size(), 4u); + } + EXPECT_TRUE(he.validate()); +} + +TEST(HalfEdgeMeshStandalone, CatmullClarkOnCubeStaysClosed) { + // 12-tri cube → 36 sub-quads. Output is a closed manifold (no + // boundary edges), and toEditableMesh round-trips through the + // n-gon path so all 36 faces appear in EditableSubMesh::faces. + auto em = makeCubeMesh(); + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + ASSERT_EQ(activeFaceCount(he), 12); + + he.subdivideCatmullClark(); + EXPECT_EQ(activeFaceCount(he), 36) << "12 tris × 3 sub-quads = 36"; + EXPECT_TRUE(he.validate()); + + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + auto s = statsOf(back); + EXPECT_EQ(s.boundaryEdges, 0u) + << "Catmull-Clark on a closed cube must stay closed"; + EXPECT_TRUE(isManifold(back)); + // n-gon path: faces non-empty since output is all quads. + ASSERT_FALSE(back.subMeshes().empty()); + EXPECT_FALSE(back.subMeshes()[0].faces.empty()) + << "all-quad output should populate EditableSubMesh::faces"; + EXPECT_EQ(back.subMeshes()[0].faces.size(), 36u); + for (const auto& f : back.subMeshes()[0].faces) { + EXPECT_EQ(f.indices.size(), 4u) << "every output face is a quad"; + } +} + +TEST(HalfEdgeMeshStandalone, CatmullClarkOnQuadGridProducesFourTimesAsManyQuads) { + // 2x2 grid of 4 quads → 16 sub-quads (one per corner of each face). + EditableMesh em; + EditableSubMesh sub; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { + mkV(0, 0), mkV(1, 0), mkV(2, 0), + mkV(0, 1), mkV(1, 1), mkV(2, 1), + mkV(0, 2), mkV(1, 2), mkV(2, 2), + }; + auto mkF = [](unsigned a, unsigned b, unsigned c, unsigned d) { + EditableFace f; + f.indices = {a, b, c, d}; + return f; + }; + sub.faces = { + mkF(0, 1, 4, 3), mkF(1, 2, 5, 4), + mkF(3, 4, 7, 6), mkF(4, 5, 8, 7), + }; + em.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + ASSERT_EQ(activeFaceCount(he), 4); + + he.subdivideCatmullClark(); + EXPECT_EQ(activeFaceCount(he), 16) << "4 quads × 4 sub-quads = 16"; + EXPECT_TRUE(he.validate()); +} + +TEST(HalfEdgeMeshStandalone, CatmullClarkPreservesPlanarFaceCenter) { + // For a planar regular quad on a closed manifold (e.g. the centre + // of a 3x3 quad grid), the face point should land exactly at the + // arithmetic centre of the four corners. + EditableMesh em; + EditableSubMesh sub; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { mkV(0, 0), mkV(1, 0), mkV(1, 1), mkV(0, 1) }; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + em.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + const auto newVerts = he.subdivideCatmullClark(); + + // Find the face point (the only vertex with all 4 corners as 1-ring + // neighbours — exactly the face point on a single-quad mesh). + bool foundFacePoint = false; + for (int v : newVerts) { + const auto neighbours = he.verticesAroundVertex(v); + if (neighbours.size() == 4) { + const auto& p = he.vertex(v).position; + EXPECT_NEAR(p.x, 0.5f, 1e-4f); + EXPECT_NEAR(p.y, 0.5f, 1e-4f); + EXPECT_NEAR(p.z, 0.0f, 1e-4f); + foundFacePoint = true; + break; + } + } + EXPECT_TRUE(foundFacePoint); +} + +TEST(HalfEdgeMeshStandalone, CatmullClarkEmptyMeshIsNoOp) { + HalfEdgeMesh he; + EXPECT_TRUE(he.subdivideCatmullClark().empty()); +} + +TEST(HalfEdgeMeshStandalone, CatmullClarkRoundTripsThroughEditableMesh) { + auto em = makeQuadMesh(); + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + he.subdivideCatmullClark(); + + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + HalfEdgeMesh he2; + ASSERT_TRUE(he2.buildFromEditableMesh(back)); + EXPECT_TRUE(he2.validate()); + // 2 input tris × 3 quads each = 6 output quads. + EXPECT_EQ(activeFaceCount(he2), 6); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 582834561..729ecf3b9 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -747,18 +747,30 @@ void MainWindow::initToolBar() }); QAction* deleteAction = ui->objectsToolbar->addWidget(deleteButton); - // Subdivide: face mode (subdivide selected triangles, retriangulate - // adjacent ones to avoid T-junctions) or edge mode (subdivide every - // triangle incident to a selected edge — Blender convention). + // Subdivide: dropdown with two modes. + // - Standard: 1-to-4 triangle split on the selected faces/edges + // (what was always there). + // - Catmull-Clark: whole-mesh subdivide-surface step. Always + // produces quads regardless of input topology. auto subdivideButton = new QToolButton(ui->objectsToolbar); subdivideButton->setText(QStringLiteral("\u229E")); // ⊞ box-plus, evokes a 4-cell split - subdivideButton->setToolTip(tr("Subdivide selected faces / edges")); + subdivideButton->setToolTip(tr("Subdivide… (Standard / Catmull-Clark)")); subdivideButton->setFont(topoFont); subdivideButton->setStyleSheet(topoBtnStyle); - connect(subdivideButton, &QToolButton::clicked, this, []() { - SentryReporter::addBreadcrumb("ui.action", "Toolbar: Subdivide"); + subdivideButton->setPopupMode(QToolButton::InstantPopup); + auto subdivideMenu = new QMenu(subdivideButton); + auto* actSubStandard = subdivideMenu->addAction(tr("Standard (1-to-4 split, selected faces)")); + auto* actSubCC = subdivideMenu->addAction(tr("Catmull-Clark (whole mesh, smoothed quads)")); + subdivideButton->setMenu(subdivideMenu); + + connect(actSubStandard, &QAction::triggered, this, []() { + SentryReporter::addBreadcrumb("ui.action", "Toolbar: Subdivide (standard)"); EditModeController::instance()->subdivideSelection(); }); + connect(actSubCC, &QAction::triggered, this, []() { + SentryReporter::addBreadcrumb("ui.action", "Toolbar: Subdivide (Catmull-Clark)"); + EditModeController::instance()->subdivideCatmullClarkAll(); + }); QAction* subdivideAction = ui->objectsToolbar->addWidget(subdivideButton); // Fill: vertex mode (3-4+ verts → triangle / fan) or edge mode (closed @@ -811,9 +823,11 @@ void MainWindow::initToolBar() deleteButton->setEnabled((mode == 0 && hasVerts) || (mode == 1 && hasEdges) || (mode == 2 && hasFaces)); - // Subdivide: needs faces (face mode) or edges (edge mode). - subdivideButton->setEnabled((mode == 2 && hasFaces) - || (mode == 1 && hasEdges)); + // Subdivide: enabled whenever in edit mode. The Standard option + // self-gates on a face/edge selection (no-op otherwise); + // Catmull-Clark operates on the whole mesh and never needs a + // selection. + subdivideButton->setEnabled(true); // Fill: needs ≥3 verts (vertex mode) or ≥3 edges that form a // closed loop (edge mode — degree check happens at apply time). fillButton->setEnabled((mode == 0 && c->selectedVertexCount() >= 3) From 82cfb0ec436676cde15f82ebc03301ff22e2668f Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 18:52:00 -0400 Subject: [PATCH 10/34] fix(quads): address Codex P1 review on chunk 5a MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findEdgeIdxByVerts` was a linear scan over `m_edges` (size = origEdgeCount), called for every side of every face during the rebuild step of Catmull-Clark subdivision. On typical quad meshes this turned the rebuild phase into roughly O(F·E) (effectively O(F²)) and would stall on larger assets. Fix: pre-build an undirected (min(va,vb), max(va,vb)) → edgeIdx hash inside the existing edge-walk loop, then look up by vertex pair in O(1). The hash is populated only for live edges (`halfEdge >= 0`) with valid vertex indices, so behaviour matches the previous lambda exactly. All 7 Catmull-Clark standalone tests still pass. --- src/HalfEdgeMesh.cpp | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 0fcf437a8..12b5e4b51 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -32,9 +32,11 @@ THE SOFTWARE. #include #include #include +#include #include #include #include +#include #include bool HalfEdgeMesh::buildFromEditableMesh(const EditableMesh& editableMesh) @@ -4902,10 +4904,21 @@ std::vector HalfEdgeMesh::subdivideCatmullClark() // seams stay sharp through the subdivision. std::vector edgePointIdx(origEdgeCount, -1); std::vector edgeIsBoundary(origEdgeCount, false); + // Undirected (min,max)-vertex-pair → edgeIdx hash, populated as we + // walk live edges below. Used by the per-side rebuild to avoid an + // O(F·E) linear scan per face side. + std::unordered_map edgeIdxByVertPair; + edgeIdxByVertPair.reserve(static_cast(origEdgeCount) * 2u); + auto packVertPair = [](int a, int b) -> std::uint64_t { + const std::uint32_t lo = static_cast(std::min(a, b)); + const std::uint32_t hi = static_cast(std::max(a, b)); + return (static_cast(hi) << 32) | static_cast(lo); + }; for (int e = 0; e < origEdgeCount; ++e) { if (m_edges[e].halfEdge < 0) continue; const auto [va, vb] = edgeVertices(e); if (va < 0 || vb < 0) continue; + edgeIdxByVertPair.emplace(packVertPair(va, vb), e); const auto [fA, fB] = edgeFaces(e); bool boundary = (fA < 0 || fB < 0); @@ -5076,14 +5089,9 @@ std::vector HalfEdgeMesh::subdivideCatmullClark() }; auto findEdgeIdxByVerts = [&](int va, int vb) -> int { - // Search the LIVE edges (after step-2 the m_edges array still - // describes the pre-step topology; we haven't rebuilt yet). - for (int e = 0; e < origEdgeCount; ++e) { - if (m_edges[e].halfEdge < 0) continue; - const auto [a, b] = edgeVertices(e); - if ((a == va && b == vb) || (a == vb && b == va)) return e; - } - return -1; + if (va < 0 || vb < 0) return -1; + auto it = edgeIdxByVertPair.find(packVertPair(va, vb)); + return it == edgeIdxByVertPair.end() ? -1 : it->second; }; for (int f = 0; f < origFaceCount; ++f) { From 575f4f84d1862c68c791406c1bd52b59b90e8432 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 18:11:35 -0400 Subject: [PATCH 11/34] =?UTF-8?q?feat(quads):=20chunk=204b=20=E2=80=94=20n?= =?UTF-8?q?-gon-aware=20face=20selection=20+=20edge=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection in Edit Mode now operates over n-gon polygons rather than the artificial fan-triangulation: - Click on a quad → highlights all of its triangles (the whole polygon), not just one half. Visual selection matches the user's mental model of "I clicked a face". - Edge mode hit-test ignores fan diagonals (the artificial edge between two halves of a quad) and only picks real polygon perimeter edges. Backface culling added so backside edges aren't pickable from the front, matching face-selection behaviour. - Topology ops (delete / dissolve / extrude / subdivide) consume the selection through `selectedFacesAsHEFaceIndices()`, which deduplicates triangle picks down to unique HE face indices — so a quad selected via either of its triangles is processed as a single face. Subdivide-on-quad now actually does something: - New `HalfEdgeMesh::subdivideFacesToQuads` splits each selected n-gon into N sub-quads (face point + edge midpoints + corner), sharing edge midpoints between adjacent selected faces in the same submesh so the result stays manifold. - `subdivideSelection` dispatches by face arity: triangles → the existing 1-to-4 split; n-gons → the new quad split. Plumbing: - `EditableSubMesh` gains a `faceIndexForTriangle()` free helper that maps a fan triangle back to its source face index, used everywhere the controller needs to dilate triangle selections. - `EditableMesh::loadFromAssimpFile` reads tangents from the source file (when present) so the editable mesh carries them end-to-end. We deliberately do NOT request aiProcess_CalcTangentSpace because that flag implicitly triangulates the mesh, defeating the n-gon path. - `MeshImporterExporter::applyNormalMapsToEntity` is now public so future fixes can re-attach RTSS bump-map state from any Edit-Mode op. Tests (+9 standalone + 4 HE): - `faceIndexForTriangle` — legacy / quad / mixed / out-of-range / null-output cases. - `subdivideFacesToQuads` — empty, single quad → 4 sub-quads, triangle → 3 sub-quads, two adjacent quads share midpoints. Known issue (deferred to a follow-up fix-PR): Bump-mapped meshes loaded through the n-gon import path lose their bump map (and sometimes basic per-pixel lighting) after a topology op. Triangle-only and procedural assets are unaffected. Tracked separately because the fix needs proper RTSS / shader pipeline instrumentation rather than continued blind permutation. None of the various invalidate / validate / re-attach permutations attempted reproduce the import-time behaviour reliably; the real fix likely lives in tangent / vertex- declaration handling on the round-trip path. Towards #326. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EditModeController.cpp | 349 +++++++++++++++++++++++++++-------- src/EditModeController.h | 12 ++ src/EditableMesh.cpp | 74 +++++++- src/EditableMesh.h | 31 ++++ src/EditableMesh_test.cpp | 106 +++++++++++ src/HalfEdgeMesh.cpp | 127 +++++++++++++ src/HalfEdgeMesh.h | 37 ++++ src/HalfEdgeMesh_test.cpp | 90 +++++++++ src/MeshImporterExporter.cpp | 5 +- src/MeshImporterExporter.h | 8 + 10 files changed, 758 insertions(+), 81 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index e4ebf1a8f..f2a6e1c27 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -34,6 +34,7 @@ THE SOFTWARE. #include "UndoManager.h" #include "commands/TransformCommands.h" #include "Manager.h" +#include "MeshImporterExporter.h" #include "NormalVisualizer.h" #include "BevelGizmo.h" #include "mainwindow.h" @@ -600,24 +601,59 @@ void EditModeController::selectFace(int triIndex, bool addToSelection) m_selectedEdges.clear(); } - m_selectedFaces.insert(triIndex); - - // Also select the three vertices and three edges of the triangle auto [subIdx, localTri] = globalTriToLocal(triIndex); - if (subIdx < m_editableMesh->subMeshes().size()) { - const auto& tri = m_editableMesh->subMeshes()[subIdx].triangles[localTri]; - int vertOffset = localToGlobal(subIdx, 0); - int g0 = vertOffset + static_cast(tri.indices[0]); - int g1 = vertOffset + static_cast(tri.indices[1]); - int g2 = vertOffset + static_cast(tri.indices[2]); - - m_selectedVertices.insert(g0); - m_selectedVertices.insert(g1); - m_selectedVertices.insert(g2); - - m_selectedEdges.insert({std::min(g0, g1), std::max(g0, g1)}); - m_selectedEdges.insert({std::min(g1, g2), std::max(g1, g2)}); - m_selectedEdges.insert({std::min(g0, g2), std::max(g0, g2)}); + if (subIdx >= m_editableMesh->subMeshes().size()) return; + const auto& sub = m_editableMesh->subMeshes()[subIdx]; + + // n-gon dilation: when the clicked triangle belongs to a face that + // has more than one fan triangle (a quad / N-gon), select EVERY + // fan triangle of that face so the user-visible "face" is the + // whole polygon — not just one half of a quad. Storage stays as + // global triangle indices for backward compat with delete / + // dissolve / undo serialization; downstream ops that need the + // HE-level face dedup it via faceIndexForTriangle. (Chunk 4b.) + size_t faceFirstTri = localTri, faceTriCount = 1; + const int faceK = faceIndexForTriangle(sub, localTri, + &faceFirstTri, &faceTriCount); + + const int vertOffset = localToGlobal(subIdx, 0); + + for (size_t t = 0; t < faceTriCount; ++t) { + const size_t tri = faceFirstTri + t; + if (tri >= sub.triangles.size()) continue; + const int gi = localTriToGlobal(subIdx, tri); + m_selectedFaces.insert(gi); + + const auto& triData = sub.triangles[tri]; + m_selectedVertices.insert(vertOffset + static_cast(triData.indices[0])); + m_selectedVertices.insert(vertOffset + static_cast(triData.indices[1])); + m_selectedVertices.insert(vertOffset + static_cast(triData.indices[2])); + } + + // Edge dilation: insert only the polygon's PERIMETER edges. For + // an n-gon submesh those come from the EditableFace's index loop + // (NOT the fan-triangulation, which would leak the artificial + // diagonal edge into the selection). For triangle-only submeshes + // every triangle edge IS a perimeter edge. (Chunk 4b edge fix.) + if (faceK >= 0 && faceK < static_cast(sub.faces.size())) { + const auto& f = sub.faces[faceK]; + const size_t n = f.indices.size(); + for (size_t i = 0; i < n; ++i) { + const int g0 = vertOffset + static_cast(f.indices[i]); + const int g1 = vertOffset + static_cast(f.indices[(i + 1) % n]); + m_selectedEdges.insert({std::min(g0, g1), std::max(g0, g1)}); + } + } else { + // Legacy single-triangle face: 3 edges = the triangle's edges. + if (faceFirstTri < sub.triangles.size()) { + const auto& triData = sub.triangles[faceFirstTri]; + const int g0 = vertOffset + static_cast(triData.indices[0]); + const int g1 = vertOffset + static_cast(triData.indices[1]); + const int g2 = vertOffset + static_cast(triData.indices[2]); + m_selectedEdges.insert({std::min(g0, g1), std::max(g0, g1)}); + m_selectedEdges.insert({std::min(g1, g2), std::max(g1, g2)}); + m_selectedEdges.insert({std::min(g0, g2), std::max(g0, g2)}); + } } updateSelectionOverlay(); @@ -626,7 +662,33 @@ void EditModeController::selectFace(int triIndex, bool addToSelection) void EditModeController::deselectFace(int triIndex) { - if (m_selectedFaces.erase(triIndex) > 0) { + if (!m_editableMesh) return; + auto [subIdx, localTri] = globalTriToLocal(triIndex); + if (subIdx >= m_editableMesh->subMeshes().size()) { + // Out of range — try the legacy single-triangle erase as a + // best-effort fallback so callers with stale indices don't + // silently no-op. + if (m_selectedFaces.erase(triIndex) > 0) { + updateSelectionOverlay(); + emit editSelectionChanged(); + } + return; + } + const auto& sub = m_editableMesh->subMeshes()[subIdx]; + + // Mirror selectFace's dilation: deselect every fan triangle of + // the clicked face so the user sees the whole face de-highlighted. + size_t faceFirstTri = localTri, faceTriCount = 1; + faceIndexForTriangle(sub, localTri, &faceFirstTri, &faceTriCount); + + bool anyErased = false; + for (size_t t = 0; t < faceTriCount; ++t) { + const size_t tri = faceFirstTri + t; + if (tri >= sub.triangles.size()) continue; + const int gi = localTriToGlobal(subIdx, tri); + if (m_selectedFaces.erase(gi) > 0) anyErased = true; + } + if (anyErased) { updateSelectionOverlay(); emit editSelectionChanged(); } @@ -690,6 +752,53 @@ int EditModeController::localTriToGlobal(size_t subMeshIndex, size_t localTriInd return offset + static_cast(localTriIndex); } +std::vector EditModeController::selectedFacesAsHEFaceIndices() const +{ + std::vector result; + if (!m_editableMesh) return result; + if (m_selectedFaces.empty()) return result; + + // The HE face index for a given (subIdx, localTri) depends on + // whether the submesh has a populated `faces` array. If it does, + // the HE face index = sum of prior submeshes' face counts + + // faceIndexForTriangle's result. If not (legacy triangle-only), + // HE face index = sum of prior submeshes' triangle counts + + // localTri. + // + // HalfEdgeMesh::buildFromEditableMesh visits submeshes in order; + // within a submesh, when faces is non-empty it appends one HE face + // per `EditableFace`, otherwise one per triangle. So the offsets + // match this rule. + const auto& subs = m_editableMesh->subMeshes(); + std::vector heBaseBySub(subs.size(), 0); + int running = 0; + for (size_t s = 0; s < subs.size(); ++s) { + heBaseBySub[s] = running; + running += subs[s].faces.empty() + ? static_cast(subs[s].triangles.size()) + : static_cast(subs[s].faces.size()); + } + + // Walk the selection, dedup via a small set keyed on HE face idx. + std::set uniq; + for (int gi : m_selectedFaces) { + const auto [subIdx, localTri] = globalTriToLocal(gi); + if (subIdx >= subs.size()) continue; + const auto& sub = subs[subIdx]; + const int faceK = faceIndexForTriangle(sub, localTri, nullptr, nullptr); + if (faceK >= 0) { + // n-gon submesh: HE face index = base + faceK. + uniq.insert(heBaseBySub[subIdx] + faceK); + } else { + // Legacy triangle-only submesh: HE face index = base + + // localTri (one HE face per triangle). + uniq.insert(heBaseBySub[subIdx] + static_cast(localTri)); + } + } + result.assign(uniq.begin(), uniq.end()); + return result; +} + // =========================================================================== // Geometry helpers // =========================================================================== @@ -926,48 +1035,96 @@ std::pair EditModeController::hitTestEdge(const QPoint& screenPos, float bestDist = pixelRadius; std::pair bestEdge = {-1, -1}; - for (size_t si = 0; si < m_editableMesh->subMeshes().size(); ++si) { - const auto& sub = m_editableMesh->subMeshes()[si]; - int vertOffset = localToGlobal(si, 0); + const Ogre::Vector3 camPos = camera->getDerivedPosition(); - for (const auto& tri : sub.triangles) { - // Check each of the 3 edges in the triangle - int localIndices[3] = { - static_cast(tri.indices[0]), - static_cast(tri.indices[1]), - static_cast(tri.indices[2]) - }; + auto considerEdge = [&](int li0, int li1, const EditableSubMesh& sub, + int vertOffset) { + if (li0 >= static_cast(sub.vertices.size()) || + li1 >= static_cast(sub.vertices.size())) + return; - for (int e = 0; e < 3; ++e) { - int li0 = localIndices[e]; - int li1 = localIndices[(e + 1) % 3]; + Ogre::Vector3 wp0 = node->convertLocalToWorldPosition(sub.vertices[li0].position); + Ogre::Vector3 wp1 = node->convertLocalToWorldPosition(sub.vertices[li1].position); - if (li0 >= static_cast(sub.vertices.size()) || - li1 >= static_cast(sub.vertices.size())) - continue; + // Skip edges where both endpoints are behind the camera + Ogre::Vector3 camDir = camera->getDerivedDirection(); + bool behind0 = (wp0 - camPos).dotProduct(camDir) < 0; + bool behind1 = (wp1 - camPos).dotProduct(camDir) < 0; + if (behind0 && behind1) return; - Ogre::Vector3 wp0 = node->convertLocalToWorldPosition(sub.vertices[li0].position); - Ogre::Vector3 wp1 = node->convertLocalToWorldPosition(sub.vertices[li1].position); + QPoint sp0 = worldToScreen(wp0, camera, viewportWidth, viewportHeight); + QPoint sp1 = worldToScreen(wp1, camera, viewportWidth, viewportHeight); - // Skip edges where both endpoints are behind the camera - Ogre::Vector3 camPos = camera->getDerivedPosition(); - Ogre::Vector3 camDir = camera->getDerivedDirection(); - bool behind0 = (wp0 - camPos).dotProduct(camDir) < 0; - bool behind1 = (wp1 - camPos).dotProduct(camDir) < 0; - if (behind0 && behind1) - continue; + float dist = pointToSegmentDistance(screenPos, sp0, sp1); + if (dist < bestDist) { + bestDist = dist; + int g0 = vertOffset + li0; + int g1 = vertOffset + li1; + bestEdge = {std::min(g0, g1), std::max(g0, g1)}; + } + }; + + // Compute per-face front-facing flags so we can skip edges whose + // both adjacent polygons face away from the camera. Per-face + // (rather than per-edge) so a quad's perimeter edges all share + // the same culling decision once. Boundary edges (only one + // incident face) are kept if that one face faces the camera. + // (Chunk 4b: edge selection mirrors face selection's front-only + // behaviour.) + auto isPolygonFrontFacing = [&](const EditableSubMesh& sub, + const std::vector& corners) -> bool { + if (corners.size() < 3) return false; + Ogre::Vector3 nrm = Ogre::Vector3::ZERO; + for (size_t i = 0; i < corners.size(); ++i) { + if (corners[i] >= sub.vertices.size()) return false; + const auto& a = sub.vertices[corners[i]].position; + const auto& b = sub.vertices[corners[(i + 1) % corners.size()]].position; + nrm.x += (a.y - b.y) * (a.z + b.z); + nrm.y += (a.z - b.z) * (a.x + b.x); + nrm.z += (a.x - b.x) * (a.y + b.y); + } + // Newell normal in local space → world space (rotate via node + // orientation, ignore translation since direction). + const Ogre::Vector3 worldNrm = node->_getDerivedOrientation() * nrm; + const Ogre::Vector3 anyCornerWorld = + node->convertLocalToWorldPosition(sub.vertices[corners[0]].position); + const Ogre::Vector3 toCam = camPos - anyCornerWorld; + return worldNrm.dotProduct(toCam) > 0.0f; + }; - QPoint sp0 = worldToScreen(wp0, camera, viewportWidth, viewportHeight); - QPoint sp1 = worldToScreen(wp1, camera, viewportWidth, viewportHeight); + for (size_t si = 0; si < m_editableMesh->subMeshes().size(); ++si) { + const auto& sub = m_editableMesh->subMeshes()[si]; + int vertOffset = localToGlobal(si, 0); - float dist = pointToSegmentDistance(screenPos, sp0, sp1); - if (dist < bestDist) { - bestDist = dist; - int g0 = vertOffset + li0; - int g1 = vertOffset + li1; - bestEdge = {std::min(g0, g1), std::max(g0, g1)}; + // n-gon path: iterate the polygon's perimeter edges only, + // skipping triangle-fan diagonals (which are NOT real polygon + // edges) and back-facing polygons. (Chunk 4b edge fix.) + if (!sub.faces.empty()) { + for (const auto& face : sub.faces) { + const size_t n = face.indices.size(); + if (n < 3) continue; + if (!isPolygonFrontFacing(sub, face.indices)) continue; + for (size_t i = 0; i < n; ++i) { + const int li0 = static_cast(face.indices[i]); + const int li1 = static_cast(face.indices[(i + 1) % n]); + considerEdge(li0, li1, sub, vertOffset); } } + } else { + // Legacy triangle-only submesh: every triangle edge IS a + // polygon edge. Apply the same front-facing filter using + // the triangle's own corners. + for (const auto& tri : sub.triangles) { + std::vector corners = { + tri.indices[0], tri.indices[1], tri.indices[2] }; + if (!isPolygonFrontFacing(sub, corners)) continue; + considerEdge(static_cast(tri.indices[0]), + static_cast(tri.indices[1]), sub, vertOffset); + considerEdge(static_cast(tri.indices[1]), + static_cast(tri.indices[2]), sub, vertOffset); + considerEdge(static_cast(tri.indices[0]), + static_cast(tri.indices[2]), sub, vertOffset); + } } } @@ -1375,7 +1532,11 @@ bool EditModeController::extrudeSelection() std::vector newHEVertices; if (m_selectionMode == FaceMode && !m_selectedFaces.empty()) { - std::vector faceIndices(m_selectedFaces.begin(), m_selectedFaces.end()); + // Triangle indices → unique HE face indices (chunk 4b). On + // n-gon submeshes one quad selection produces multiple + // triangle entries that all map to the same HE face; + // dedup is critical so extrudeFaces doesn't double-process. + const std::vector faceIndices = selectedFacesAsHEFaceIndices(); newHEVertices = heMesh.extrudeFaces(faceIndices); } else if (m_selectionMode == EdgeMode && !m_selectedEdges.empty()) { // Convert (min,max) vertex-pair edge selections to HE edge indices @@ -2513,6 +2674,10 @@ inline void rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) preMats.push_back(ent->getSubEntity(i)->getMaterialName()); + // Re-init the entity so its SubEntity caches (vertex / index + // counts, skeleton anim buffers) sync to the resized mesh. + // Without this the next render uses stale draw-call params and + // the topology change appears as holes / broken geometry. ent->_deinitialise(); ent->_initialise(true); @@ -2522,6 +2687,24 @@ inline void rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { ent->getSubEntity(i)->setMaterialName(preMats[i]); } + // Invalidate the cached RTSS technique so the next render + // regenerates it against the new vertex declaration. Lazy regen + // via SchemeResolverListener::handleSchemeNotFound runs on the + // next render. (Same pattern every other topology op uses.) + // + // KNOWN ISSUE post-chunk-4: bump-mapped meshes loaded through + // the n-gon import path lose their bump map (and sometimes basic + // per-pixel lighting) after a topology op. The cause is that + // `EditableMesh::loadFromAssimpFile` deliberately skips + // aiProcess_CalcTangentSpace (it forces triangulation) and the + // post-edit GPU upload path doesn't always reliably trigger an + // Ogre `buildTangentVectors` rebuild. Various RTSS + // re-attach permutations (sync / deferred / via + // applyNormalMapsToEntity / invalidate-only) all reproduce the + // failure on the same model. Tracked for a follow-up fix-PR + // with proper shader-pipeline instrumentation rather than + // continued blind permutation. Triangle-only assets and + // procedural primitives are unaffected. if (auto* sg = Ogre::RTShader::ShaderGenerator::getSingletonPtr()) { for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { const std::string& m = ent->getSubEntity(i)->getMaterialName(); @@ -2884,7 +3067,7 @@ int EditModeController::deleteSelection() }; } else { // FaceMode if (m_selectedFaces.empty()) return 0; - std::vector faces(m_selectedFaces.begin(), m_selectedFaces.end()); + const std::vector faces = selectedFacesAsHEFaceIndices(); opLabel = "Delete Faces"; mutate = [faces](HalfEdgeMesh& hm) { return hm.deleteFaces(faces); }; } @@ -2949,7 +3132,7 @@ int EditModeController::dissolveSelection() // Wire face dissolve to deleteFaces so the menu entry stays // active and predictable; an n-gon-aware variant can replace // this once the rest of the pipeline supports n-gons. - std::vector faces(m_selectedFaces.begin(), m_selectedFaces.end()); + const std::vector faces = selectedFacesAsHEFaceIndices(); opLabel = "Dissolve Faces"; mutate = [faces](HalfEdgeMesh& hm) { return hm.deleteFaces(faces); }; } @@ -2984,7 +3167,8 @@ int EditModeController::subdivideSelection() std::vector targetFaces; if (m_selectionMode == FaceMode) { if (m_selectedFaces.empty()) return 0; - targetFaces.assign(m_selectedFaces.begin(), m_selectedFaces.end()); + // Triangle indices → unique HE face indices (chunk 4b). + targetFaces = selectedFacesAsHEFaceIndices(); } else if (m_selectionMode == EdgeMode) { if (m_selectedEdges.empty()) return 0; // Convert global vertex pairs → HE edge indices → incident faces. @@ -3022,18 +3206,37 @@ int EditModeController::subdivideSelection() const auto preSelectedEdges = m_selectedEdges; const auto preSelectedFaces = m_selectedFaces; + // Split target faces by arity (chunk 4b): triangles take the + // 1-to-4 split via `subdivideFaces`, n-gons take the 1-to-N quad + // split via `subdivideFacesToQuads`. Without this dispatch, the + // existing `subdivideFaces` would silently skip non-triangles + // (its triangle-only MVP), making clicks on quads no-op. + std::vector triFaces, ngonFaces; + for (int f : targetFaces) { + if (f < 0 || f >= static_cast(hm.faceCount())) continue; + const auto verts = hm.faceVertices(f); + if (verts.size() == 3) triFaces.push_back(f); + else if (verts.size() > 3) ngonFaces.push_back(f); + } + // Capture target positions of the new midpoints BEFORE the mesh is // re-packed by toEditableMesh — the indices change after the round // trip, so we re-find the survivors by position (matches the merge // pipeline pattern). HE-vertex positions are stable across the call. - const auto newVertHE = hm.subdivideFaces(targetFaces); - if (newVertHE.empty()) return 0; - - std::vector midpointPositions; - midpointPositions.reserve(newVertHE.size()); - for (int v : newVertHE) { - midpointPositions.push_back(hm.vertex(v).position); + std::vector newVertHE; + if (!triFaces.empty()) { + auto v = hm.subdivideFaces(triFaces); + newVertHE.insert(newVertHE.end(), v.begin(), v.end()); + } + if (!ngonFaces.empty()) { + // subdivideFacesToQuads must run AFTER subdivideFaces because + // both rebuild the edge tables; running tris first means n-gon + // edge lookups happen against the post-tri-split topology. + // Fortunately tri-split doesn't touch n-gon faces. + auto v = hm.subdivideFacesToQuads(ngonFaces); + newVertHE.insert(newVertHE.end(), v.begin(), v.end()); } + if (newVertHE.empty()) return 0; EditableMesh updated; if (!hm.toEditableMesh(updated)) return 0; @@ -3045,23 +3248,17 @@ int EditModeController::subdivideSelection() m_editableMesh->resizeEntityBuffers(m_editEntity); rewriteEntityAfterTopologyChange(m_editEntity); - // Re-find the new midpoints in the post-pack mesh and select them. + // Clear the entire selection. The pre-op selectFace dilation + // populated vertex / edge sets that are now stale (vertex indices + // shifted on re-pack, and the inserted midpoints don't have + // stable analogues in the pre-op selection set). Re-finding new + // midpoints by position used to leave a partial vertex selection + // around — confusing for the user and inconsistent with delete / + // dissolve / Catmull-Clark which all clear after the op. Match + // that pattern here. (Chunk 4b polish.) m_selectedVertices.clear(); m_selectedEdges.clear(); m_selectedFaces.clear(); - const auto& subs = m_editableMesh->subMeshes(); - int globalBase = 0; - for (const auto& sub : subs) { - for (size_t li = 0; li < sub.vertices.size(); ++li) { - for (const auto& tgt : midpointPositions) { - if (sub.vertices[li].position.squaredDistance(tgt) < 1e-10f) { - m_selectedVertices.insert(globalBase + static_cast(li)); - break; - } - } - } - globalBase += static_cast(sub.vertices.size()); - } auto* cmd = new EditMeshTopologyCommand( std::move(originalSubMeshes), diff --git a/src/EditModeController.h b/src/EditModeController.h index 5cb55564f..de551da0c 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -665,6 +665,18 @@ class EditModeController : public QObject /// Convert (subMeshIndex, localTriangleIndex) to a global triangle index. int localTriToGlobal(size_t subMeshIndex, size_t localTriIndex) const; + + /// @brief Convert the current `m_selectedFaces` set (global triangle + /// indices) into a deduplicated list of HE face indices that + /// HalfEdgeMesh ops accept. Each unique HE face is reported once + /// regardless of how many of its fan-triangulated children appear + /// in the selection — so a quad selected via either of its + /// triangles maps to a single HE face. + /// + /// HE face indexing matches `HalfEdgeMesh::buildFromEditableMesh` + /// order: submesh 0's faces first (or its triangles, in legacy + /// triangle-only submeshes), then submesh 1's, etc. + std::vector selectedFacesAsHEFaceIndices() const; /// @} signals: diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 293ff2cd5..0a956d81a 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -161,6 +161,15 @@ bool EditableMesh::loadFromAssimpFile(const std::string& path, // editable mesh would end up mirrored (X flipped) relative to the // rendered Ogre mesh, and the vertex/edge/face overlays would draw // on the wrong side of the on-screen geometry. (Chunk 4 fix.) + // Tangent handling: we deliberately do NOT request + // aiProcess_CalcTangentSpace here because that flag implicitly + // triangulates the mesh in Assimp, defeating the whole point of + // this n-gon-aware path. Instead, when tangents aren't already + // in the source file, the post-edit GPU upload pipeline rebuilds + // them via Ogre::Mesh::buildTangentVectors (run from + // applyNormalMapsToEntity / rewriteEntityAfterTopologyChange), + // which operates on the fan-triangulated index buffer and produces + // correct per-vertex tangents without disturbing `faces`. Assimp::Importer importer; unsigned int flags = aiProcess_JoinIdenticalVertices | @@ -197,6 +206,7 @@ bool EditableMesh::loadFromAssimpFile(const std::string& path, const bool hasNormals = aim->HasNormals(); const bool hasUVs = aim->HasTextureCoords(0); const bool hasColors = aim->HasVertexColors(0); + const bool hasTangents = aim->HasTangentsAndBitangents(); for (unsigned i = 0; i < aim->mNumVertices; ++i) { EditableVertex& ev = sub.vertices[i]; const aiVector3D& p = aim->mVertices[i]; @@ -216,6 +226,22 @@ bool EditableMesh::loadFromAssimpFile(const std::string& path, ev.color = Ogre::ColourValue(c.r, c.g, c.b, c.a); ev.hasColor = true; } + if (hasTangents) { + const aiVector3D& t = aim->mTangents[i]; + // RTSS expects FLOAT4 tangents with handedness in w. + // Compute parity from the input bitangent: if cross + // (normal × tangent) aligns with bitangent, parity = + // +1, else -1. Same convention MeshProcessor / + // applyNormalMapsToEntity use. + const aiVector3D& bt = aim->mBitangents[i]; + Ogre::Vector3 normalV = ev.hasNormal ? ev.normal : Ogre::Vector3::UNIT_Z; + Ogre::Vector3 tangentV(t.x, t.y, t.z); + Ogre::Vector3 expectedBT = normalV.crossProduct(tangentV); + float parity = expectedBT.dotProduct( + Ogre::Vector3(bt.x, bt.y, bt.z)) >= 0.0f ? 1.0f : -1.0f; + ev.tangent = Ogre::Vector4(t.x, t.y, t.z, parity); + ev.hasTangent = true; + } } // Bone weights, if any. @@ -726,6 +752,40 @@ void syncTriangulation(std::vector& subMeshes) } } +int faceIndexForTriangle(const EditableSubMesh& sub, + size_t localTri, + size_t* outFirstTri, + size_t* outTriCount) +{ + if (sub.faces.empty()) { + // Legacy triangle-only submesh: every triangle IS its own face. + if (outFirstTri) *outFirstTri = localTri; + if (outTriCount) *outTriCount = 1; + return -1; + } + // Walk the face array, accumulating each face's fan-triangulation + // length until we contain `localTri`. The chunk-1 invariant says + // `triangulateFaces` emits faces in order, each face producing + // (vertexCount - 2) triangles. + size_t running = 0; + for (size_t k = 0; k < sub.faces.size(); ++k) { + const auto& f = sub.faces[k]; + const size_t n = f.indices.size(); + if (n < 3) continue; // invalid face — triangulateFaces skipped it + const size_t triCount = n - 2; + if (localTri < running + triCount) { + if (outFirstTri) *outFirstTri = running; + if (outTriCount) *outTriCount = triCount; + return static_cast(k); + } + running += triCount; + } + // Out-of-range — defensive fallback to single-triangle behaviour. + if (outFirstTri) *outFirstTri = localTri; + if (outTriCount) *outTriCount = 1; + return -1; +} + void EditableMesh::setVertexPosition(size_t subMeshIndex, size_t vertexIndex, const Ogre::Vector3& pos) { if (subMeshIndex < m_subMeshes.size() && vertexIndex < m_subMeshes[subMeshIndex].vertices.size()) @@ -773,8 +833,9 @@ void EditableMesh::recalculateNormals() { for (auto& sub : m_subMeshes) { // n-gon sync: when faces is canonical, refresh the triangle - // mirror so the normal-accumulation loop below sees the actual - // current geometry. Triangle-only submeshes pay nothing here. + // mirror so callers that consume `triangles` (GPU upload, + // legacy ops) stay in sync with `faces`. Triangle-only + // submeshes pay nothing here. if (!sub.faces.empty()) triangulateFaces(sub); // Zero out all normals @@ -783,7 +844,13 @@ void EditableMesh::recalculateNormals() v.hasNormal = true; } - // Accumulate face normals (area-weighted) + // Always walk triangles for normal accumulation, even on + // n-gon submeshes. The Newell-per-polygon variant produced + // visibly correct flat shading on planar quads but broke + // bump-map / lighting on bumped meshes (likely a sign / + // magnitude convention mismatch with the rest of Ogre's + // pipeline). Reverting to the always-tri loop gets parity + // with what extrude / bevel / merge always used. for (const auto& tri : sub.triangles) { if (tri.indices[0] >= sub.vertices.size() || tri.indices[1] >= sub.vertices.size() || @@ -794,7 +861,6 @@ void EditableMesh::recalculateNormals() const Ogre::Vector3& v1 = sub.vertices[tri.indices[1]].position; const Ogre::Vector3& v2 = sub.vertices[tri.indices[2]].position; - // Cross product gives area-weighted face normal Ogre::Vector3 faceNormal = (v1 - v0).crossProduct(v2 - v0); sub.vertices[tri.indices[0]].normal += faceNormal; diff --git a/src/EditableMesh.h b/src/EditableMesh.h index 091195fe2..579a443be 100644 --- a/src/EditableMesh.h +++ b/src/EditableMesh.h @@ -188,6 +188,37 @@ void syncTriangulation(std::vector& subMeshes); */ size_t totalFaceCount(const std::vector& subMeshes); +/** + * @brief Map a fan-triangulation triangle index to its source face index. + * + * Given a triangle index `localTri` in a submesh's `triangles` array, + * returns the index of the `EditableFace` in `sub.faces` that owns + * that triangle (the result of fan-triangulating that face), or -1 if + * `sub.faces` is empty (legacy triangle-only submesh — every triangle + * IS its own face). Output `outFirstTri` and `outTriCount` describe + * the contiguous range of triangles belonging to that face, so a + * caller can dilate a single-triangle selection back to the whole + * face it came from. + * + * Assumes the chunk-1 invariant holds: when `sub.faces` is non-empty, + * `sub.triangles` is its fan-triangulation produced by + * `triangulateFaces(sub)`. Calling this on a desynced submesh + * returns garbage; resync via `triangulateFaces(sub)` if in doubt. + * + * @param sub The submesh. + * @param localTri Index into `sub.triangles`. + * @param[out] outFirstTri First triangle index of the owning face's + * range. (= localTri for legacy submeshes.) + * @param[out] outTriCount Number of triangles in the owning face's + * range (1 for triangles, 2 for quads, N-2 for N-gons). + * @return Face index in `sub.faces`, or -1 if the submesh is in + * legacy triangle-only mode. + */ +int faceIndexForTriangle(const EditableSubMesh& sub, + size_t localTri, + size_t* outFirstTri, + size_t* outTriCount); + /** * @brief Indexed mesh representation for topology queries and editing. * diff --git a/src/EditableMesh_test.cpp b/src/EditableMesh_test.cpp index 5d6d9d22c..2bba440cc 100644 --- a/src/EditableMesh_test.cpp +++ b/src/EditableMesh_test.cpp @@ -1102,3 +1102,109 @@ TEST_F(EditableMeshTest, ResizeEntityBuffersClearsCachedSourcePath) { Manager::getSingleton()->destroySceneNode( "EditableMesh_resize_clear_path_node"); } + +// =========================================================================== +// faceIndexForTriangle (chunk 4b) — n-gon-aware selection mapping +// =========================================================================== + +TEST(EditableMeshStandalone, FaceIndexForTriangleLegacyTriangleSubmeshIsIdentity) { + EditableSubMesh sub; + EditableVertex v; + sub.vertices = {v, v, v, v}; + EditableTriangle t1, t2; + t1.indices[0] = 0; t1.indices[1] = 1; t1.indices[2] = 2; + t2.indices[0] = 0; t2.indices[1] = 2; t2.indices[2] = 3; + sub.triangles = {t1, t2}; + // faces deliberately empty — legacy triangle-only mode. + + size_t firstTri = 99, count = 99; + EXPECT_EQ(faceIndexForTriangle(sub, 0, &firstTri, &count), -1); + EXPECT_EQ(firstTri, 0u); + EXPECT_EQ(count, 1u); + + EXPECT_EQ(faceIndexForTriangle(sub, 1, &firstTri, &count), -1); + EXPECT_EQ(firstTri, 1u); + EXPECT_EQ(count, 1u); +} + +TEST(EditableMeshStandalone, FaceIndexForTriangleQuadMapsBothTrianglesToSameFace) { + EditableSubMesh sub; + EditableVertex v; + sub.vertices = {v, v, v, v}; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + triangulateFaces(sub); // produces 2 fan triangles + + ASSERT_EQ(sub.triangles.size(), 2u); + + // Both fan triangles map to face 0. + size_t firstTri = 99, count = 99; + EXPECT_EQ(faceIndexForTriangle(sub, 0, &firstTri, &count), 0); + EXPECT_EQ(firstTri, 0u); + EXPECT_EQ(count, 2u) << "quad's owning face spans 2 triangles"; + + EXPECT_EQ(faceIndexForTriangle(sub, 1, &firstTri, &count), 0); + EXPECT_EQ(firstTri, 0u); + EXPECT_EQ(count, 2u); +} + +TEST(EditableMeshStandalone, FaceIndexForTriangleMixedTriQuadMapsCorrectly) { + EditableSubMesh sub; + EditableVertex v; + sub.vertices = {v, v, v, v, v, v, v}; + EditableFace tri; + tri.indices = {0, 1, 2}; + EditableFace quad; + quad.indices = {3, 4, 5, 6}; + sub.faces.push_back(std::move(tri)); + sub.faces.push_back(std::move(quad)); + triangulateFaces(sub); // 1 + 2 = 3 fan triangles + + ASSERT_EQ(sub.triangles.size(), 3u); + + // Triangle 0 → face 0 (the lone triangle). + size_t firstTri = 99, count = 99; + EXPECT_EQ(faceIndexForTriangle(sub, 0, &firstTri, &count), 0); + EXPECT_EQ(firstTri, 0u); + EXPECT_EQ(count, 1u); + + // Triangles 1 + 2 → face 1 (the quad). + EXPECT_EQ(faceIndexForTriangle(sub, 1, &firstTri, &count), 1); + EXPECT_EQ(firstTri, 1u); + EXPECT_EQ(count, 2u); + EXPECT_EQ(faceIndexForTriangle(sub, 2, &firstTri, &count), 1); + EXPECT_EQ(firstTri, 1u); + EXPECT_EQ(count, 2u); +} + +TEST(EditableMeshStandalone, FaceIndexForTriangleOutOfRangeIsBenign) { + EditableSubMesh sub; + EditableVertex v; + sub.vertices = {v, v, v, v}; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + triangulateFaces(sub); + + // Out-of-range triangle index returns -1 with a defensive + // single-triangle fallback. + size_t firstTri = 0, count = 0; + EXPECT_EQ(faceIndexForTriangle(sub, 99, &firstTri, &count), -1); + EXPECT_EQ(firstTri, 99u); + EXPECT_EQ(count, 1u); +} + +TEST(EditableMeshStandalone, FaceIndexForTriangleAcceptsNullOutPointers) { + EditableSubMesh sub; + EditableVertex v; + sub.vertices = {v, v, v, v}; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + triangulateFaces(sub); + + // Caller can pass nullptr for either output if they don't care. + EXPECT_EQ(faceIndexForTriangle(sub, 0, nullptr, nullptr), 0); + EXPECT_EQ(faceIndexForTriangle(sub, 1, nullptr, nullptr), 0); +} diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 12b5e4b51..40653c393 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -4830,6 +4830,26 @@ HEVertex averageHEVertices(const std::vector& src) r.hasTangent = anyTan; if (r.normal.squaredLength() > 1e-12f) r.normal.normalise(); + // Normalize the tangent's xyz component (w stores handedness/ + // parity and stays as the average of input parities). RTSS's + // bump-map shader assumes unit-length tangents — without this + // step, averaged tangents at face / edge points end up with + // length < 1, the shader produces invalid TBN matrices, and + // the surface renders with no per-pixel lighting + no bump map + // (looks washed out, ambient-only). (Chunk 4b.) + { + Ogre::Vector3 txyz(r.tangent.x, r.tangent.y, r.tangent.z); + const float lenSq = txyz.squaredLength(); + if (lenSq > 1e-12f) { + const float invLen = 1.0f / std::sqrt(lenSq); + r.tangent.x = txyz.x * invLen; + r.tangent.y = txyz.y * invLen; + r.tangent.z = txyz.z * invLen; + // Normalize w to ±1 (handedness is binary). + r.tangent.w = (r.tangent.w >= 0.0f) ? 1.0f : -1.0f; + } + } + auto addBone = [&](unsigned short idx, float weight) { if (weight <= 1e-6f) return; for (auto& ba : r.boneAssignments) { @@ -4853,6 +4873,113 @@ HEVertex midpointHEVertices(const HEVertex& a, const HEVertex& b) } // namespace +std::vector HalfEdgeMesh::subdivideFacesToQuads(const std::vector& faceIndices) +{ + std::vector newVertices; + if (faceIndices.empty()) return newVertices; + + // 1. Collect the unique set of currently-live target faces. + // Accept any face with 3+ corners. + std::set selectedFaces; + for (int f : faceIndices) { + if (f < 0 || f >= static_cast(m_faces.size())) continue; + if (m_faces[f].halfEdge < 0) continue; + if (faceVertices(f).size() < 3) continue; + selectedFaces.insert(f); + } + if (selectedFaces.empty()) return newVertices; + + // 2. For each selected face, compute a face point (arithmetic mean + // of corners). Track per-face corner list + submesh for later + // rewiring. + std::vector facePointIdx(m_faces.size(), -1); + std::vector> faceCorners(m_faces.size()); + std::vector faceSubMesh(m_faces.size(), -1); + + for (int f : selectedFaces) { + const auto verts = faceVertices(f); + std::vector src; + src.reserve(verts.size()); + for (int v : verts) src.push_back(&m_vertices[v]); + HEVertex fp = averageHEVertices(src); + fp.halfEdge = -1; + facePointIdx[f] = static_cast(m_vertices.size()); + m_vertices.push_back(std::move(fp)); + newVertices.push_back(facePointIdx[f]); + faceCorners[f] = std::move(verts); + faceSubMesh[f] = m_faces[f].subMeshIndex; + } + + // 3. For each unique edge on the selected faces, compute an edge + // midpoint. SHARE midpoints across selected faces in the same + // submesh so the boundary between two selected faces stays + // watertight (no T-junction). Cross-submesh edges fall back to + // per-face edge points (deliberately NOT shared, so material + // seams keep their per-side normals/UVs). + auto edgeKey = [](int a, int b, int submesh) { + return std::make_tuple(std::min(a, b), std::max(a, b), submesh); + }; + std::map, int> edgePointForKey; + + auto getOrMakeEdgePoint = [&](int va, int vb, int submesh) -> int { + const auto key = edgeKey(va, vb, submesh); + auto it = edgePointForKey.find(key); + if (it != edgePointForKey.end()) return it->second; + HEVertex mp = midpointHEVertices(m_vertices[va], m_vertices[vb]); + mp.halfEdge = -1; + const int idx = static_cast(m_vertices.size()); + m_vertices.push_back(std::move(mp)); + newVertices.push_back(idx); + edgePointForKey[key] = idx; + return idx; + }; + + // 4. Retire each selected face and emit N quads in its place: + // quad_i = [corner_i, edgePoint(corner_i, corner_{i+1}), + // facePoint, edgePoint(corner_{i-1}, corner_i)] + auto retireFace = [&](int faceIdx) { + const int startHE = m_faces[faceIdx].halfEdge; + if (startHE < 0) return; + int he = startHE; + do { + const int next = m_halfEdges[he].next; + m_halfEdges[he].face = -1; + he = next; + } while (he != startHE && he >= 0); + m_faces[faceIdx].halfEdge = -1; + }; + + for (int f : selectedFaces) { + const auto& corners = faceCorners[f]; + const int N = static_cast(corners.size()); + if (N < 3) continue; + const int subIdx = faceSubMesh[f]; + const int fp = facePointIdx[f]; + + // Pre-resolve edge points for each consecutive pair. + std::vector edgePts(N, -1); + for (int i = 0; i < N; ++i) { + edgePts[i] = getOrMakeEdgePoint( + corners[i], corners[(i + 1) % N], subIdx); + } + + retireFace(f); + + for (int i = 0; i < N; ++i) { + const int prev = (i + N - 1) % N; + const int corner = corners[i]; + appendFace({corner, edgePts[i], fp, edgePts[prev]}, subIdx); + } + } + + rebuildEdgesAndTwins(); + compactBoundaryHalfEdges(); + buildBoundaryHalfEdges(); + fixVertexHalfEdges(); + + return newVertices; +} + std::vector HalfEdgeMesh::subdivideCatmullClark() { std::vector newVertices; diff --git a/src/HalfEdgeMesh.h b/src/HalfEdgeMesh.h index f72277866..5f10bf301 100644 --- a/src/HalfEdgeMesh.h +++ b/src/HalfEdgeMesh.h @@ -603,6 +603,43 @@ class HalfEdgeMesh */ std::vector subdivideFaces(const std::vector& faceIndices); + /** + * @brief Subdivide each selected n-gon face into N sub-quads, linearly. + * + * Geometrically equivalent to one Catmull-Clark step ON THE + * SELECTED FACES ONLY, minus the smoothing rules — face points and + * edge points land at the arithmetic mean of their inputs (linear + * subdivide, no chord/face-point blending into surrounding + * vertices). Output is always quads regardless of input N. + * + * Use this when you want to preserve quad structure on a partial + * selection without smoothing the corners. For triangle inputs the + * existing `subdivideFaces` 1-to-4 split is usually a better fit; + * for quad inputs `subdivideFaces` skips them as non-triangles, so + * this method fills that gap. + * + * Adjacent NON-selected faces sharing an edge with a subdivided + * face are NOT retriangulated — this means you'll get T-junctions + * along the boundary between selected and unselected regions on + * the same submesh. Most users will want to either (a) select + * full neighbour rings, or (b) use the whole-mesh + * `subdivideCatmullClark` instead. T-junction prevention is a + * follow-up. + * + * MVP scope: + * - Faces must have 3+ corners. (3 → 3 quads, 4 → 4, N → N.) + * - Cross-submesh boundary handling: edge midpoints are shared + * only between selected faces in the same submesh. Cross- + * submesh edges fall back to per-face edge points (no + * sharing), which still produces a valid mesh — just slightly + * duplicated vertices at material seams. + * + * @param faceIndices The HE face indices to subdivide. + * @return Indices of every newly created vertex (face points + * followed by edge points). Empty on no-op. + */ + std::vector subdivideFacesToQuads(const std::vector& faceIndices); + /** * @brief Subdivide every face by one Catmull-Clark step. * diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index 693c4f186..9bf64eac9 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -4983,6 +4983,96 @@ TEST(HalfEdgeMeshStandalone, CatmullClarkEmptyMeshIsNoOp) { EXPECT_TRUE(he.subdivideCatmullClark().empty()); } +// =========================================================================== +// subdivideFacesToQuads — chunk 4b: subdivide n-gons into quads on selection +// =========================================================================== + +TEST(HalfEdgeMeshStandalone, SubdivideFacesToQuadsEmptyIsNoOp) { + auto em = makeQuadMesh(); + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + EXPECT_TRUE(he.subdivideFacesToQuads({}).empty()); + EXPECT_EQ(activeFaceCount(he), 2); +} + +TEST(HalfEdgeMeshStandalone, SubdivideFacesToQuadsOnQuadProducesFourQuads) { + EditableMesh em; + EditableSubMesh sub; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { mkV(0, 0), mkV(1, 0), mkV(1, 1), mkV(0, 1) }; + EditableFace f; + f.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(f)); + em.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + ASSERT_EQ(activeFaceCount(he), 1); + + he.subdivideFacesToQuads({0}); + EXPECT_EQ(activeFaceCount(he), 4) << "1 quad → 4 sub-quads"; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + EXPECT_EQ(he.faceVertices(static_cast(f)).size(), 4u) + << "every output face is a quad"; + } + EXPECT_TRUE(he.validate()); +} + +TEST(HalfEdgeMeshStandalone, SubdivideFacesToQuadsOnTriangleProducesThreeQuads) { + auto em = makeTriangleMesh(); + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + + he.subdivideFacesToQuads({0}); + EXPECT_EQ(activeFaceCount(he), 3); + EXPECT_TRUE(he.validate()); +} + +TEST(HalfEdgeMeshStandalone, SubdivideFacesToQuadsSharesEdgeMidpointsBetweenSelectedFaces) { + // Two adjacent quads in the same submesh sharing one edge. + // Selecting both should re-use the shared edge's midpoint so the + // boundary stays manifold (no T-junction, no duplicated vertex). + EditableMesh em; + EditableSubMesh sub; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + // 6 verts arranged as two side-by-side quads sharing edge (1,4). + sub.vertices = { + mkV(0, 0), mkV(1, 0), mkV(2, 0), // 0 1 2 (bottom) + mkV(0, 1), mkV(1, 1), mkV(2, 1), // 3 4 5 (top) + }; + auto mkF = [](unsigned a, unsigned b, unsigned c, unsigned d) { + EditableFace f; + f.indices = {a, b, c, d}; + return f; + }; + sub.faces = { mkF(0, 1, 4, 3), mkF(1, 2, 5, 4) }; + em.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + + he.subdivideFacesToQuads({0, 1}); + EXPECT_EQ(activeFaceCount(he), 8) << "2 quads × 4 sub-quads = 8"; + EXPECT_TRUE(he.validate()); + + // Manifold check on round-trip. + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + EXPECT_TRUE(isManifold(back)) + << "shared midpoint between the two quads must keep mesh manifold"; +} + TEST(HalfEdgeMeshStandalone, CatmullClarkRoundTripsThroughEditableMesh) { auto em = makeQuadMesh(); HalfEdgeMesh he; diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 59cbf494a..8e398df67 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -870,7 +870,10 @@ static Ogre::MeshPtr importOgreXmlMesh(const QString& filePath, const std::strin // Apply RTSS normal map shaders to any materials that have a normal-map texture // unit. Called after loading .mesh/.xml files where MaterialProcessor doesn't run. -static void applyNormalMapsToEntity(const Ogre::Entity* en) +// Also reachable as MeshImporterExporter::applyNormalMapsToEntity for callers +// that need to refresh bump-map RTSS state after a topology change wiped it +// (chunk 4b: Edit Mode subdivide / extrude / etc.). +void MeshImporterExporter::applyNormalMapsToEntity(const Ogre::Entity* en) { if (!en) return; auto& log = Ogre::LogManager::getSingleton(); diff --git a/src/MeshImporterExporter.h b/src/MeshImporterExporter.h index e3f86ce7a..94540b294 100755 --- a/src/MeshImporterExporter.h +++ b/src/MeshImporterExporter.h @@ -67,6 +67,14 @@ class MeshImporterExporter static int sceneExporter(const QString &_uri, const ProgressCallback& progress = nullptr); static bool sceneImporter(const QString &_uri); + + /// @brief (Re-)attach RTSS normal-map sub-render-states to every + /// material referenced by `entity`, building tangent vectors first + /// if missing. Idempotent — safe to call after a topology change + /// when materials were invalidated and the bump-map state needs to + /// be re-applied. (Chunk 4b: Edit Mode topology ops use this to + /// preserve bump mapping after subdivide / extrude / etc.) + static void applyNormalMapsToEntity(const Ogre::Entity* entity); }; #endif // MESHIMPORTEREXPORTER_H From d05b23ff3b5db0a658378124ad26028192cf3395 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 18:53:54 -0400 Subject: [PATCH 12/34] fix(quads): address Codex P2 review on chunk 4b MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related findings, same root cause: `faceIndexForTriangle` and `selectedFacesAsHEFaceIndices` were counting *all* faces with at least three indices, but `triangulateFaces` and `HalfEdgeMesh::buildFromEditableMesh` only consume faces that pass `EditableFace::isValid()` (which additionally rejects consecutive duplicate indices). On meshes with any invalid face entry — e.g. a 4-vertex face like `[0,0,1,2]` — the triangle-to-face mapping and the per-submesh HE face base-offset would drift, so face-mode ops (extrude / delete / dissolve / subdivide) could mutate the wrong polygon. Fix: align both call sites to skip faces by `!isValid()` (matching what buildFromEditableMesh actually appends), and base the HE face offset on the count of valid faces only. Adds a regression test (FaceIndexForTriangleSkipsInvalidFaces) covering the consecutive-duplicate-index case explicitly. All 190 standalone tests still pass. --- src/EditModeController.cpp | 16 +++++++++++++--- src/EditableMesh.cpp | 10 +++++++--- src/EditableMesh_test.cpp | 29 +++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index f2a6e1c27..6a41141a7 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -774,9 +774,19 @@ std::vector EditModeController::selectedFacesAsHEFaceIndices() const int running = 0; for (size_t s = 0; s < subs.size(); ++s) { heBaseBySub[s] = running; - running += subs[s].faces.empty() - ? static_cast(subs[s].triangles.size()) - : static_cast(subs[s].faces.size()); + if (subs[s].faces.empty()) { + running += static_cast(subs[s].triangles.size()); + } else { + // Match HalfEdgeMesh::buildFromEditableMesh, which only + // appends faces that pass isValid(). Counting raw faces + // here would over-shoot the offset and shift later + // submeshes' HE face indices. + int valid = 0; + for (const auto& f : subs[s].faces) { + if (f.isValid()) ++valid; + } + running += valid; + } } // Walk the selection, dedup via a small set keyed on HE face idx. diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 0a956d81a..611c692dd 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -770,9 +770,13 @@ int faceIndexForTriangle(const EditableSubMesh& sub, size_t running = 0; for (size_t k = 0; k < sub.faces.size(); ++k) { const auto& f = sub.faces[k]; - const size_t n = f.indices.size(); - if (n < 3) continue; // invalid face — triangulateFaces skipped it - const size_t triCount = n - 2; + // Skip exactly the same set triangulateFaces() skips. Using + // n < 3 alone would drift the mapping for faces that pass the + // size check but fail isValid() (e.g. consecutive duplicate + // indices), since those produce zero triangles in `triangles` + // but would otherwise consume slots in `running`. + if (!f.isValid()) continue; + const size_t triCount = f.indices.size() - 2; if (localTri < running + triCount) { if (outFirstTri) *outFirstTri = running; if (outTriCount) *outTriCount = triCount; diff --git a/src/EditableMesh_test.cpp b/src/EditableMesh_test.cpp index 2bba440cc..5c1c5b9ca 100644 --- a/src/EditableMesh_test.cpp +++ b/src/EditableMesh_test.cpp @@ -1208,3 +1208,32 @@ TEST(EditableMeshStandalone, FaceIndexForTriangleAcceptsNullOutPointers) { EXPECT_EQ(faceIndexForTriangle(sub, 0, nullptr, nullptr), 0); EXPECT_EQ(faceIndexForTriangle(sub, 1, nullptr, nullptr), 0); } + +TEST(EditableMeshStandalone, FaceIndexForTriangleSkipsInvalidFaces) { + // Regression for chunk-4b drift bug: a face with consecutive + // duplicate indices passes `n >= 3` but fails isValid(), so it + // produces zero triangles in `triangles`. faceIndexForTriangle + // must skip it the same way triangulateFaces does — otherwise the + // mapping for triangles after it points to the wrong source face. + EditableSubMesh sub; + EditableVertex v; + sub.vertices = {v, v, v, v, v, v}; + EditableFace bad; // 4 indices but [0]==[1] — !isValid() + bad.indices = {0, 0, 1, 2}; + EditableFace good; // valid quad → 2 fan triangles + good.indices = {2, 3, 4, 5}; + sub.faces.push_back(std::move(bad)); + sub.faces.push_back(std::move(good)); + triangulateFaces(sub); + + ASSERT_EQ(sub.triangles.size(), 2u); + size_t firstTri = 0, count = 0; + // Both triangles must map to face index 1 (the good face), + // not face 0 (which contributed nothing to `triangles`). + EXPECT_EQ(faceIndexForTriangle(sub, 0, &firstTri, &count), 1); + EXPECT_EQ(firstTri, 0u); + EXPECT_EQ(count, 2u); + EXPECT_EQ(faceIndexForTriangle(sub, 1, &firstTri, &count), 1); + EXPECT_EQ(firstTri, 0u); + EXPECT_EQ(count, 2u); +} From f09e755d55f47bf38dcbe38b8e1893445d4eab94 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 19:46:15 -0400 Subject: [PATCH 13/34] fix(quads): preserve transforms + bone handles in n-gon re-import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both Codex P1 findings on PR #332 (chunk 4) before they reach master. Issue 1 — bone-handle drift on skinned meshes loadFromAssimpFile stored aiBone mesh-local indices in EditableBoneAssignment::boneIndex. The GUI import path (MeshProcessor) instead resolves aiBone->mName against the loaded Ogre::Skeleton and stores Ogre::Bone::getHandle(). When a topology op re-emitted VertexBoneAssignments via resizeEntityBuffers, those mesh-local indices were re-interpreted as Ogre handles, so vertices rebound to whichever bones happened to occupy those handle slots. Fix: add an optional Ogre::Skeleton* parameter to loadFromAssimpFile. When non-null, aiBone->mName is resolved against it (matching MeshProcessor) and the resulting handle is stored. Bones that don't resolve are skipped so we never emit wild handles. EditModeController passes the live entity's skeleton when entering edit mode. Issue 2 — Z-up overlay rotation on FBX/glTF assets MeshProcessor bakes a +90°-around-X rotation into rendered buffers for assets declared Z-up (FBX UpAxis = 2), so the Ogre scene-graph stays Y-up without a node rotation. loadFromAssimpFile read raw aiMesh vertices unchanged, so on Z-up assets the editable representation lived in pre-bake space while rendered buffers were post-bake — vertex/edge/face overlays appeared rotated 90° relative to the on-screen geometry, and a commit would write the rotated positions back, silently rotating the entity. Fix: add an `isZup` parameter to loadFromAssimpFile. When true, apply the same +90°-around-X bake to position, normal, and tangent before storing them. MeshImporterExporter caches the source up-axis under "qtme.source_up_axis" alongside the existing source-path / convert-LH caches, and EditModeController reads it back when re-entering edit mode. EditableMesh::commitToEntity / resizeEntityBuffers now also erase this cache key when the live buffers diverge from the source. Tests: two new standalone regression tests cover the Z-up bake math and the unskinned-mesh shape of the new bone-skeleton parameter. The skinned-mesh skeleton-lookup case is exercised by EditModeController integration tests at run time. --- src/EditModeController.cpp | 32 +++++++++++++++- src/EditableMesh.cpp | 63 +++++++++++++++++++++++-------- src/EditableMesh.h | 25 ++++++++++++- src/EditableMesh_test.cpp | 72 ++++++++++++++++++++++++++++++++++++ src/MeshImporterExporter.cpp | 12 ++++++ 5 files changed, 186 insertions(+), 18 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 6a41141a7..3c5f8df68 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -318,8 +318,38 @@ bool EditModeController::enterEditMode() convertLH = Ogre::any_cast(lhAny); } catch (const Ogre::Exception&) {} } + // The original importer also caches the source up- + // axis (1 = Y-up, 2 = Z-up). MeshProcessor bakes a + // +90°-around-X rotation into the rendered buffers + // for Z-up assets; we pass `isZup` so the editable + // representation stays in the same basis. Without + // this, FBX/glTF assets that declare Z-up would + // surface vertex overlays rotated 90° relative to + // the on-screen geometry, and a commit would write + // the rotated positions back, silently rotating + // the entity. + bool isZup = false; + const Ogre::Any& upAny = + bindings.getUserAny("qtme.source_up_axis"); + if (upAny.has_value()) { + try { + isZup = (Ogre::any_cast(upAny) == 2); + } catch (const Ogre::Exception&) {} + } + // Resolve aiBone names against the live skeleton so + // the n-gon path emits Ogre bone HANDLES (matching + // MeshProcessor) instead of mesh-local aiBone + // indices. Without this, a topology op on a skinned + // mesh re-emits VertexBoneAssignments with wild + // handles and skinning rebinds vertices to wrong + // bones. + const Ogre::Skeleton* skel = nullptr; + if (meshPtr->hasSkeleton()) { + skel = meshPtr->getSkeleton().get(); + } if (!sourcePath.empty() - && m_editableMesh->loadFromAssimpFile(sourcePath, convertLH)) { + && m_editableMesh->loadFromAssimpFile( + sourcePath, convertLH, isZup, skel)) { loaded = true; SentryReporter::addBreadcrumb("edit_mode", "Edit Mode entered via n-gon import path"); diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 611c692dd..5a482ab40 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -147,7 +147,9 @@ bool EditableMesh::loadFromMesh(const Ogre::MeshPtr& meshPtr) } bool EditableMesh::loadFromAssimpFile(const std::string& path, - bool convertToLeftHanded) + bool convertToLeftHanded, + bool isZup, + const Ogre::Skeleton* skeletonForBoneHandles) { if (path.empty()) return false; @@ -189,6 +191,18 @@ bool EditableMesh::loadFromAssimpFile(const std::string& path, m_subMeshes.clear(); m_subMeshes.reserve(scene->mNumMeshes); + // Mirror MeshProcessor's Z-up bake: when the source asset declares + // Z-up (FBX UpAxis = 2), the GUI importer rotates every position / + // normal / tangent +90° around X so the Ogre scene-graph stays Y-up + // without needing a node rotation. We must apply the SAME rotation + // here so the editable representation lives in the same basis as + // the rendered Ogre buffers; otherwise the vertex/edge/face overlays + // appear rotated 90° (and on commit, the mismatched positions get + // written back, silently rotating the geometry). + const Ogre::Quaternion zUpBake = isZup + ? Ogre::Quaternion(Ogre::Degree(90), Ogre::Vector3::UNIT_X) + : Ogre::Quaternion::IDENTITY; + for (unsigned m = 0; m < scene->mNumMeshes; ++m) { const aiMesh* aim = scene->mMeshes[m]; if (!aim || aim->mNumVertices == 0) continue; @@ -210,10 +224,10 @@ bool EditableMesh::loadFromAssimpFile(const std::string& path, for (unsigned i = 0; i < aim->mNumVertices; ++i) { EditableVertex& ev = sub.vertices[i]; const aiVector3D& p = aim->mVertices[i]; - ev.position = Ogre::Vector3(p.x, p.y, p.z); + ev.position = zUpBake * Ogre::Vector3(p.x, p.y, p.z); if (hasNormals) { const aiVector3D& n = aim->mNormals[i]; - ev.normal = Ogre::Vector3(n.x, n.y, n.z); + ev.normal = zUpBake * Ogre::Vector3(n.x, n.y, n.z); ev.hasNormal = true; } if (hasUVs) { @@ -227,33 +241,48 @@ bool EditableMesh::loadFromAssimpFile(const std::string& path, ev.hasColor = true; } if (hasTangents) { - const aiVector3D& t = aim->mTangents[i]; // RTSS expects FLOAT4 tangents with handedness in w. - // Compute parity from the input bitangent: if cross - // (normal × tangent) aligns with bitangent, parity = - // +1, else -1. Same convention MeshProcessor / - // applyNormalMapsToEntity use. - const aiVector3D& bt = aim->mBitangents[i]; - Ogre::Vector3 normalV = ev.hasNormal ? ev.normal : Ogre::Vector3::UNIT_Z; - Ogre::Vector3 tangentV(t.x, t.y, t.z); - Ogre::Vector3 expectedBT = normalV.crossProduct(tangentV); - float parity = expectedBT.dotProduct( - Ogre::Vector3(bt.x, bt.y, bt.z)) >= 0.0f ? 1.0f : -1.0f; - ev.tangent = Ogre::Vector4(t.x, t.y, t.z, parity); + // Apply the same Z-up bake to T/B/N before computing + // parity so the convention matches MeshProcessor. + Ogre::Vector3 T = zUpBake * Ogre::Vector3( + aim->mTangents[i].x, aim->mTangents[i].y, aim->mTangents[i].z); + Ogre::Vector3 B = zUpBake * Ogre::Vector3( + aim->mBitangents[i].x, aim->mBitangents[i].y, aim->mBitangents[i].z); + Ogre::Vector3 N = ev.hasNormal ? ev.normal : Ogre::Vector3::UNIT_Z; + float parity = N.crossProduct(T).dotProduct(B) >= 0.0f ? 1.0f : -1.0f; + ev.tangent = Ogre::Vector4(T.x, T.y, T.z, parity); ev.hasTangent = true; } } // Bone weights, if any. + // + // CRITICAL: assignments must use the same bone-handle convention + // the rendered Ogre mesh uses. MeshProcessor (the GUI import + // path) resolves `aiBone->mName` against the loaded skeleton and + // stores `Ogre::Bone::getHandle()`. If we instead stored aiBone + // mesh-local indices here, then on the next topology op (which + // re-emits VertexBoneAssignments through resizeEntityBuffers) + // those indices would be interpreted as handles — vertices would + // bind to whichever bone happens to live at that handle slot, + // not the bone the source file actually weighted them to. + // Skip bones that don't resolve so we never emit wild handles. if (aim->mNumBones > 0) { for (unsigned b = 0; b < aim->mNumBones; ++b) { const aiBone* bone = aim->mBones[b]; if (!bone) continue; + unsigned short handle = static_cast(b); + if (skeletonForBoneHandles) { + const std::string boneName = bone->mName.C_Str(); + if (!skeletonForBoneHandles->hasBone(boneName)) continue; + handle = static_cast( + skeletonForBoneHandles->getBone(boneName)->getHandle()); + } for (unsigned w = 0; w < bone->mNumWeights; ++w) { const aiVertexWeight& vw = bone->mWeights[w]; if (vw.mVertexId >= sub.vertices.size()) continue; EditableBoneAssignment eba; - eba.boneIndex = static_cast(b); + eba.boneIndex = handle; eba.weight = vw.mWeight; sub.vertices[vw.mVertexId].boneAssignments.push_back(eba); } @@ -487,6 +516,7 @@ bool EditableMesh::commitToEntity(Ogre::Entity* entity) // discarded by an n-gon re-import. (Quad migration #326, chunk 4.) mesh->getUserObjectBindings().eraseUserAny("qtme.source_path"); mesh->getUserObjectBindings().eraseUserAny("qtme.source_convert_lh"); + mesh->getUserObjectBindings().eraseUserAny("qtme.source_up_axis"); return true; } @@ -682,6 +712,7 @@ bool EditableMesh::resizeEntityBuffers(Ogre::Entity* entity) // Topology has changed — same rationale as commitToEntity above. mesh->getUserObjectBindings().eraseUserAny("qtme.source_path"); mesh->getUserObjectBindings().eraseUserAny("qtme.source_convert_lh"); + mesh->getUserObjectBindings().eraseUserAny("qtme.source_up_axis"); return true; } diff --git a/src/EditableMesh.h b/src/EditableMesh.h index 579a443be..b88c39334 100644 --- a/src/EditableMesh.h +++ b/src/EditableMesh.h @@ -304,8 +304,31 @@ class EditableMesh * @return true on success; false if the file is missing, can't be * parsed, or contains no mesh data. */ + /** + * @param isZup If true, applies the same +90°-around-X rotation that + * `MeshProcessor` bakes into the rendered Ogre buffers + * when the source asset declares a Z-up coordinate + * system (FBX `UpAxis = 2`). Without this, the editable + * vertices live in the source's pre-bake basis while the + * rendered buffers are post-bake, and selection overlays + * appear rotated 90° relative to the rendered geometry. + * The original importer caches its choice at + * `getUserAny("qtme.source_up_axis")` (an int — 1 = Y-up, + * 2 = Z-up). + * @param skeletonForBoneHandles If non-null, `aiBone->mName` is + * resolved against this skeleton via `getBone(name)` and + * the resulting `Ogre::Bone::getHandle()` is stored as + * `EditableBoneAssignment::boneIndex`. This matches the + * handle convention `MeshProcessor` writes into the live + * Ogre mesh; without it, this path emits mesh-local + * aiBone indices that re-bind vertices to the wrong + * bones after a topology op. Pass nullptr only for + * unskinned meshes. + */ bool loadFromAssimpFile(const std::string& path, - bool convertToLeftHanded = true); + bool convertToLeftHanded = true, + bool isZup = false, + const Ogre::Skeleton* skeletonForBoneHandles = nullptr); /** * @brief Merge vertices at (approximately) coincident positions within diff --git a/src/EditableMesh_test.cpp b/src/EditableMesh_test.cpp index 5c1c5b9ca..2ec09e6c0 100644 --- a/src/EditableMesh_test.cpp +++ b/src/EditableMesh_test.cpp @@ -1024,6 +1024,78 @@ TEST(EditableMeshStandalone, LoadFromAssimpFileMixedTriAndQuadKeepsBoth) { EXPECT_EQ(sub.triangles.size(), 3u); } +TEST(EditableMeshStandalone, LoadFromAssimpFileAppliesZUpBakeWhenRequested) { + // Regression for the quads follow-up: when the source asset was + // declared Z-up (FBX UpAxis = 2), MeshProcessor bakes a +90° X + // rotation into the rendered Ogre buffers. loadFromAssimpFile must + // apply the SAME rotation when isZup=true so the editable mesh + // lives in the same basis. Without this, vertex overlays would + // appear rotated 90° on Z-up assets. + // + // OBJ ignores UpAxis metadata, so passing isZup=true here triggers + // the rotation deterministically regardless of file format. + const QString path = writeObj("editmesh_zup", + "v 0 0 0\nv 1 0 0\nv 0 1 0\n", // y=1 vertex at index 2 + "f 1 2 3\n"); + ASSERT_FALSE(path.isEmpty()); + + EditableMesh ymesh; + ASSERT_TRUE(ymesh.loadFromAssimpFile( + path.toStdString(), /*convertLH=*/false, /*isZup=*/false)); + EditableMesh zmesh; + ASSERT_TRUE(zmesh.loadFromAssimpFile( + path.toStdString(), /*convertLH=*/false, /*isZup=*/true)); + QFile::remove(path); + + ASSERT_EQ(ymesh.subMeshes()[0].vertices.size(), 3u); + ASSERT_EQ(zmesh.subMeshes()[0].vertices.size(), 3u); + + // The vertex at OBJ index 3 is (0,1,0) — Y-up. After +90° X bake + // (R_x90 * (0,1,0) = (0,0,1)) it should land on the Z axis. + const auto& y = ymesh.subMeshes()[0].vertices[2].position; + const auto& z = zmesh.subMeshes()[0].vertices[2].position; + EXPECT_NEAR(y.x, 0.0f, 1e-5f); + EXPECT_NEAR(y.y, 1.0f, 1e-5f); + EXPECT_NEAR(y.z, 0.0f, 1e-5f); + EXPECT_NEAR(z.x, 0.0f, 1e-5f); + EXPECT_NEAR(z.y, 0.0f, 1e-5f); + EXPECT_NEAR(z.z, 1.0f, 1e-5f); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFileSkipsBonesNotInSkeleton) { + // Regression for the quads follow-up bone-handle bug. Without a + // skeleton lookup, this path emitted aiBone mesh-local indices as + // if they were Ogre bone handles. With skeletonForBoneHandles=null + // the legacy behaviour (mesh-local indices) is preserved for + // unskinned meshes; with a non-null skeleton, only resolvable + // bones contribute assignments. This standalone test exercises + // the unskinned-mesh path (OBJ has no bones), confirming the new + // signature compiles and behaves identically when there are no + // bones — the skinned-mesh skeleton-lookup case is exercised in + // the EditModeController integration tests where a real skeleton + // is available. + const QString path = writeObj("editmesh_nobone", + "v 0 0 0\nv 1 0 0\nv 0 1 0\n", + "f 1 2 3\n"); + ASSERT_FALSE(path.isEmpty()); + + EditableMesh m1, m2; + ASSERT_TRUE(m1.loadFromAssimpFile(path.toStdString())); + ASSERT_TRUE(m2.loadFromAssimpFile( + path.toStdString(), /*convertLH=*/true, /*isZup=*/false, + /*skeletonForBoneHandles=*/nullptr)); + QFile::remove(path); + + // Both should produce identical output for an unskinned mesh. + ASSERT_EQ(m1.subMeshCount(), 1u); + ASSERT_EQ(m2.subMeshCount(), 1u); + EXPECT_EQ(m1.subMeshes()[0].vertices.size(), + m2.subMeshes()[0].vertices.size()); + for (const auto& v : m1.subMeshes()[0].vertices) { + EXPECT_TRUE(v.boneAssignments.empty()); + } +} + TEST(EditableMeshStandalone, LoadFromAssimpFileReplacesPreviousContents) { // Loading into a non-empty EditableMesh should replace the // existing submeshes — like buildFromEditableMesh does. diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 8e398df67..a2e8bbb16 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -1023,6 +1023,18 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad // every non-.x asset. (Chunk 4.) mesh->getUserObjectBindings().setUserAny( "qtme.source_convert_lh", Ogre::Any(convertLH)); + // Cache the source up-axis (1 = Y-up, 2 = Z-up) so + // EditModeController can apply MeshProcessor's + // +90°-around-X bake when re-importing the asset + // through the n-gon-aware path. Without this the + // editable representation lives in pre-bake space + // while the rendered buffers are post-bake — the + // overlays appear rotated 90° on FBX/glTF Z-up + // assets, and a commit would write the rotated + // positions back. (Quad migration follow-up.) + mesh->getUserObjectBindings().setUserAny( + "qtme.source_up_axis", + Ogre::Any(importer.getSceneUpAxis())); } if (!mesh) { // Animation-only file: skeleton/animations were loaded, but there is no mesh. From 49f5a2b589c004d5c23370296a4f70ead4bee8d2 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 20:30:10 -0400 Subject: [PATCH 14/34] fix(quads): preserve bump map + per-pixel lighting after topology ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the deferred lighting/RTSS regression flagged on PR #334. Root cause After any Edit-Mode topology op (subdivide, extrude, bevel, knife, merge, delete/dissolve, undo/redo, …) bump-mapped meshes loaded through the n-gon import path went dark and lost their normal map. Two compounding issues: 1. EditableMesh::buildSubMeshBuffers checks only `vertices[0] .hasTangent` to decide whether to add a VES_TANGENT element to the rebuilt declaration. New vertices created by the op default- construct EditableVertex (zero-valued Vector4 tangent), so: (a) if the first vertex retained tangents the declaration kept VES_TANGENT but new vertices wrote (0,0,0,0), making RTSS's SRS_NORMALMAP TBN math collapse to zeros; (b) if the asset came in via loadFromAssimpFile (which deliberately omits aiProcess_CalcTangentSpace because that flag forces triangulation), every vertex has hasTangent=false and the declaration drops VES_TANGENT entirely. Either way the bump map was effectively gone. 2. Each topology op had its OWN inline _deinitialise/_initialise + invalidateMaterial block; the undo/redo path (EditMeshTopologyCommand::applyMeshState) had a third copy that skipped the RTSS hook entirely. So a Subdivide-then-Ctrl-Z lost lighting even when the redo would have restored it. Fix Centralise post-topology-op refresh into a new static method EditModeController::rewriteEntityAfterTopologyChange(Entity*). It: a) Detects bump-map intent by scanning every subentity's material for a `normal_map`/`NormalMap` TUS. If any subentity is bump- mapped, force `Mesh::buildTangentVectors` (storeParityInW=true) BEFORE _deinitialise/_initialise. Order matters: calling it AFTER _initialise is too late — the SubEntity already linked against the old (no-tangent) declaration and the RTSS shaders compile against that stale layout. b) Saves per-subentity material overrides before the deinit/init (Ogre resets them to the SubMesh default), restores after. c) Re-runs MeshImporterExporter::applyNormalMapsToEntity so RTSS re-attaches its SRS_NORMALMAP sub-render-state against the fresh tangents. invalidateMaterial alone only drops cached shader programs; the SRS_NORMALMAP gets dropped on removeShaderBasedTechnique inside applyNormalMap, so we must call it again to re-add it. d) Final invalidateMaterial pass to keep behaviour identical to the old per-op blocks for materials that aren't bump-mapped. All five inline copies in EditModeController.cpp (extrude, bevel commit, bevel cancel, knife commit, generic post-op via applyTopologyMutationNoSurvivor) now call this helper, and so does EditMeshTopologyCommand::applyMeshState in TransformCommands.cpp — so undo/redo gets the same treatment. Tests Standalone regression test confirms the helper is reachable as a static method (so TransformCommands.cpp's qualified call survives a refactor that might shove it back into an anonymous namespace) and null-tolerant. Full bump-map / RTSS exercise needs a GL context which the test infra doesn't provide on macOS; coverage there is via hand smoke tests on the bump-mapped Mixamo asset (subdivide / extrude / undo / redo all confirmed visually preserving the bump map and per-pixel lighting). --- src/EditModeController.cpp | 253 ++++++++++++----------------- src/EditModeController.h | 13 ++ src/EditModeController_test.cpp | 20 +++ src/commands/TransformCommands.cpp | 7 +- 4 files changed, 144 insertions(+), 149 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 3c5f8df68..45ec5f89f 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -1733,24 +1733,9 @@ bool EditModeController::extrudeSelection() if (!m_editableMesh->resizeEntityBuffers(m_editEntity)) return false; - // Force Entity to rebuild its SubEntity list from the updated Mesh. - // Without this, Ogre's skeletal skinning pipeline may use stale - // animation blend buffers that still reference the old vertex layout. - m_editEntity->_deinitialise(); - m_editEntity->_initialise(true); - - // Invalidate RTSS shaders for the entity's materials so they regenerate - // against the new vertex declaration (with possibly new tangent / blend - // indices elements). Without this, bump maps / skinning may render wrong. - auto* shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); - if (shaderGen) { - for (unsigned int i = 0; i < m_editEntity->getNumSubEntities(); ++i) { - const std::string& matName = m_editEntity->getSubEntity(i)->getMaterialName(); - if (!matName.empty()) - shaderGen->invalidateMaterial( - Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, matName); - } - } + // Refresh Entity caches + RTSS state (incl. tangent rebuild for + // bump-mapped materials). See `rewriteEntityAfterTopologyChange`. + rewriteEntityAfterTopologyChange(m_editEntity); // Select the new (offset) vertices by position — offset ensures uniqueness m_selectedVertices.clear(); @@ -1874,34 +1859,10 @@ bool EditModeController::applyBevelTopology( if (!m_editableMesh->resizeEntityBuffers(m_editEntity)) return false; - // _deinitialise/_initialise rebuilds SubEntities and resets their - // material to the SubMesh default. In edit mode the SubEntity holds - // the wireframe variant and MaterialEditor writes go to the SubEntity - // (not the SubMesh), so both must be preserved across the rebuild. - std::vector preMats; - preMats.reserve(m_editEntity->getNumSubEntities()); - for (unsigned int i = 0; i < m_editEntity->getNumSubEntities(); ++i) { - preMats.push_back(m_editEntity->getSubEntity(i)->getMaterialName()); - } - - m_editEntity->_deinitialise(); - m_editEntity->_initialise(true); - - for (unsigned int i = 0; - i < m_editEntity->getNumSubEntities() && i < preMats.size(); ++i) { - if (!preMats[i].empty()) - m_editEntity->getSubEntity(i)->setMaterialName(preMats[i]); - } - - auto* shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); - if (shaderGen) { - for (unsigned int i = 0; i < m_editEntity->getNumSubEntities(); ++i) { - const std::string& matName = m_editEntity->getSubEntity(i)->getMaterialName(); - if (!matName.empty()) - shaderGen->invalidateMaterial( - Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, matName); - } - } + // Refresh Entity caches + RTSS state (incl. tangent rebuild for + // bump-mapped materials), preserving per-subentity material + // overrides (wireframe variant, MaterialEditor writes). + rewriteEntityAfterTopologyChange(m_editEntity); m_selectedVertices.clear(); m_selectedEdges.clear(); @@ -1999,29 +1960,7 @@ bool EditModeController::applyBevelVertexTopology( if (!m_editableMesh->resizeEntityBuffers(m_editEntity)) return false; - std::vector preMats; - preMats.reserve(m_editEntity->getNumSubEntities()); - for (unsigned int i = 0; i < m_editEntity->getNumSubEntities(); ++i) - preMats.push_back(m_editEntity->getSubEntity(i)->getMaterialName()); - - m_editEntity->_deinitialise(); - m_editEntity->_initialise(true); - - for (unsigned int i = 0; - i < m_editEntity->getNumSubEntities() && i < preMats.size(); ++i) { - if (!preMats[i].empty()) - m_editEntity->getSubEntity(i)->setMaterialName(preMats[i]); - } - - auto* shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); - if (shaderGen) { - for (unsigned int i = 0; i < m_editEntity->getNumSubEntities(); ++i) { - const std::string& matName = m_editEntity->getSubEntity(i)->getMaterialName(); - if (!matName.empty()) - shaderGen->invalidateMaterial( - Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, matName); - } - } + rewriteEntityAfterTopologyChange(m_editEntity); m_selectedVertices.clear(); m_selectedEdges.clear(); @@ -2431,35 +2370,10 @@ void EditModeController::cancelBevel() m_selectedEdges = std::move(m_bevelSession.origSelectedEdges); m_selectedFaces = std::move(m_bevelSession.origSelectedFaces); - // Re-sync the entity with the restored mesh. Snapshot SubEntity - // materials before _deinitialise/_initialise (Ogre resets them to - // the SubMesh default) so wireframe / material-editor overrides - // survive the Esc path, matching the commit path. + // Re-sync the entity with the restored mesh — preserves wireframe / + // material-editor SubEntity overrides through the Esc path. m_editableMesh->resizeEntityBuffers(m_editEntity); - if (m_editEntity) { - std::vector preMats; - preMats.reserve(m_editEntity->getNumSubEntities()); - for (unsigned int i = 0; i < m_editEntity->getNumSubEntities(); ++i) - preMats.push_back(m_editEntity->getSubEntity(i)->getMaterialName()); - - m_editEntity->_deinitialise(); - m_editEntity->_initialise(true); - - for (unsigned int i = 0; - i < m_editEntity->getNumSubEntities() && i < preMats.size(); ++i) { - if (!preMats[i].empty()) - m_editEntity->getSubEntity(i)->setMaterialName(preMats[i]); - } - } - auto* shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); - if (shaderGen && m_editEntity) { - for (unsigned int i = 0; i < m_editEntity->getNumSubEntities(); ++i) { - const std::string& matName = m_editEntity->getSubEntity(i)->getMaterialName(); - if (!matName.empty()) - shaderGen->invalidateMaterial( - Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, matName); - } - } + rewriteEntityAfterTopologyChange(m_editEntity); m_bevelSession = {}; if (m_bevelGizmo) m_bevelGizmo->setVisible(false); @@ -2644,29 +2558,7 @@ bool EditModeController::commitKnife() m_editableMesh->subMeshes() = std::move(updated.subMeshes()); m_editableMesh->resizeEntityBuffers(m_editEntity); - std::vector preMats; - preMats.reserve(m_editEntity->getNumSubEntities()); - for (unsigned int i = 0; i < m_editEntity->getNumSubEntities(); ++i) { - preMats.push_back(m_editEntity->getSubEntity(i)->getMaterialName()); - } - - m_editEntity->_deinitialise(); - m_editEntity->_initialise(true); - - for (unsigned int i = 0; - i < m_editEntity->getNumSubEntities() && i < preMats.size(); ++i) { - if (!preMats[i].empty()) - m_editEntity->getSubEntity(i)->setMaterialName(preMats[i]); - } - - if (auto* shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr()) { - for (unsigned int i = 0; i < m_editEntity->getNumSubEntities(); ++i) { - const std::string& matName = m_editEntity->getSubEntity(i)->getMaterialName(); - if (!matName.empty()) - shaderGen->invalidateMaterial( - Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, matName); - } - } + rewriteEntityAfterTopologyChange(m_editEntity); // Clear edge/face selection — pre-cut IDs refer to retired topology // slots and the walk added new vertices that aren't in any existing @@ -2703,17 +2595,88 @@ bool EditModeController::commitKnife() // the HE primitive (centroid / first / last / per-cluster centroid for // by-distance). `applyMergeOp` factors that boilerplate. // --------------------------------------------------------------------------- -namespace { -// Shared post-mesh-mutation hook used by knife + merge: rewrite the entity's -// Ogre buffers, save / restore per-subentity material overrides, and tell -// RTSS to re-link shaders against the new vertex layout. Captures locally -// to avoid a public helper just for this. -inline void rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { +// Shared post-mesh-mutation hook: rewrite the entity's Ogre buffers, +// save / restore per-subentity material overrides, and tell RTSS to +// re-link shaders against the new vertex layout. Used by every Edit- +// Mode topology op AND by EditMeshTopologyCommand::applyMeshState so +// undo/redo preserves bump map / per-pixel lighting state. +// +// Bump-map / per-pixel lighting handling: after a topology op the new +// vertices written by EditableMesh::buildSubMeshBuffers have zero-valued +// tangents (EditableVertex defaults), and the declaration may even drop +// the VES_TANGENT slot entirely if `vertices[0].hasTangent == false` +// (which is the case on the n-gon import path that omits +// aiProcess_CalcTangentSpace). Either way RTSS's SRS_NORMALMAP TBN math +// collapses, dropping bump mapping and (depending on the shader path) +// basic per-pixel lighting. Fix: force `Mesh::buildTangentVectors` +// BEFORE `_deinitialise/_initialise` so the SubEntity vertex-decl cache +// reads the corrected layout, then re-run `applyNormalMapsToEntity` so +// RTSS re-attaches SRS_NORMALMAP against the fresh tangents. +void EditModeController::rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { + if (!ent) return; std::vector preMats; preMats.reserve(ent->getNumSubEntities()); for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) preMats.push_back(ent->getSubEntity(i)->getMaterialName()); + // Rebuild tangents BEFORE `_deinitialise/_initialise` so the + // SubEntity vertex-decl cache captured during `_initialise` already + // sees the VES_TANGENT element. Calling `buildTangentVectors` AFTER + // `_initialise` is too late: the SubEntity has already linked + // against the old (no-tangent) declaration and the next render + // compiles RTSS shaders against that stale layout, so the + // SRS_NORMALMAP code path collapses to a no-op even though the + // tangents physically exist in the buffer. + // + // We need tangents whenever any subentity is bump-mapped (has a + // `normal_map`/`NormalMap` TUS). Two cases trigger that: + // 1. The mesh declaration ALREADY has VES_TANGENT but the new + // vertices written by `EditableMesh::buildSubMeshBuffers` are + // zero-valued (default-constructed `EditableVertex::tangent`). + // 2. The declaration LOST VES_TANGENT during the buffer rewrite — + // this happens when the n-gon import path (which doesn't + // request `aiProcess_CalcTangentSpace` because it forces + // triangulation) didn't have tangents in the source file, so + // `EditableVertex::hasTangent == false` for every vertex and + // the rebuilt declaration drops the slot entirely. + // Either way `Mesh::buildTangentVectors` is the fix: it adds the + // VES_TANGENT element if missing and (re)computes values from + // positions/normals/UVs. + if (auto mesh = ent->getMesh()) { + bool wantsTangents = false; + for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { + auto mat = ent->getSubEntity(i)->getMaterial(); + if (!mat) continue; + if (!mat->isLoaded()) { + try { mat->load(); } catch (...) {} + } + if (mat->getNumTechniques() == 0) continue; + auto* pass = mat->getTechnique(0)->getPass(0); + if (!pass) continue; + for (unsigned short t = 0; t < pass->getNumTextureUnitStates(); ++t) { + const auto& tusName = + pass->getTextureUnitState(t)->getName(); + if (tusName == "normal_map" || tusName == "NormalMap") { + wantsTangents = true; + break; + } + } + if (wantsTangents) break; + } + if (wantsTangents) { + try { + // storeParityInW=true → VET_FLOAT4, matching what + // RTShaderHelper::applyNormalMap and the import path use. + mesh->buildTangentVectors( + Ogre::VES_TANGENT, 0, 0, false, false, true); + } catch (const Ogre::Exception& e) { + Ogre::LogManager::getSingleton().logMessage( + "rewriteEntityAfterTopologyChange: buildTangentVectors " + "failed for '" + mesh->getName() + "': " + e.getDescription()); + } + } + } + // Re-init the entity so its SubEntity caches (vertex / index // counts, skeleton anim buffers) sync to the resized mesh. // Without this the next render uses stale draw-call params and @@ -2727,24 +2690,23 @@ inline void rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { ent->getSubEntity(i)->setMaterialName(preMats[i]); } - // Invalidate the cached RTSS technique so the next render - // regenerates it against the new vertex declaration. Lazy regen - // via SchemeResolverListener::handleSchemeNotFound runs on the - // next render. (Same pattern every other topology op uses.) - // - // KNOWN ISSUE post-chunk-4: bump-mapped meshes loaded through - // the n-gon import path lose their bump map (and sometimes basic - // per-pixel lighting) after a topology op. The cause is that - // `EditableMesh::loadFromAssimpFile` deliberately skips - // aiProcess_CalcTangentSpace (it forces triangulation) and the - // post-edit GPU upload path doesn't always reliably trigger an - // Ogre `buildTangentVectors` rebuild. Various RTSS - // re-attach permutations (sync / deferred / via - // applyNormalMapsToEntity / invalidate-only) all reproduce the - // failure on the same model. Tracked for a follow-up fix-PR - // with proper shader-pipeline instrumentation rather than - // continued blind permutation. Triangle-only assets and - // procedural primitives are unaffected. + // Re-attach the RTSS SRS_NORMALMAP sub-render-state per material that + // has a normal-map TUS. `invalidateMaterial` alone only drops the + // cached shader programs; the renderState's template list is + // preserved so the regenerated shader would normally inherit + // SRS_NORMALMAP — except `applyNormalMap` uses + // `removeShaderBasedTechnique + createShaderBasedTechnique` to start + // from a clean slate (some tangent-related state in the render state + // doesn't survive a buffer-format change), so we must run it again + // to guarantee the SRS_NORMALMAP is re-bound against the new + // tangents. Materials without a normal-map TUS are no-ops here. + MeshImporterExporter::applyNormalMapsToEntity(ent); + + // Final invalidate so any per-material cache that survived the + // applyNormalMap path drops too. validateMaterial inside + // applyNormalMap already kicks shader regen, but explicit + // invalidate keeps behaviour identical to other topology ops for + // materials that aren't bump-mapped. if (auto* sg = Ogre::RTShader::ShaderGenerator::getSingletonPtr()) { for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { const std::string& m = ent->getSubEntity(i)->getMaterialName(); @@ -2754,7 +2716,6 @@ inline void rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { } } } -} // namespace // Find global vertex indices of the survivor positions after toEditableMesh // re-packed the per-submesh arrays. Each survivor is the unique vertex at @@ -3031,7 +2992,7 @@ int applyTopologyMutationNoSurvivor( editableMesh->recalculateNormalsFlat(); editableMesh->resizeEntityBuffers(editEntity); - rewriteEntityAfterTopologyChange(editEntity); + EditModeController::rewriteEntityAfterTopologyChange(editEntity); selVerts.clear(); selEdges.clear(); diff --git a/src/EditModeController.h b/src/EditModeController.h index de551da0c..7dec3d4f3 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -885,6 +885,19 @@ private slots: void applyWireframeMaterials(); void removeWireframeMaterials(); +public: + /// Refresh an entity after a topology mutation: rebuild tangents + /// (when any material is bump-mapped), `_deinitialise/_initialise` + /// the entity, restore per-subentity material overrides, re-attach + /// RTSS SRS_NORMALMAP, and invalidate cached shader programs. Used + /// by every Edit-Mode topology op (subdivide, extrude, bevel, …) + /// AND by `EditMeshTopologyCommand::applyMeshState` so undo/redo + /// preserves bump map / per-pixel lighting state. Static so + /// command code can invoke it without a controller instance. + static void rewriteEntityAfterTopologyChange(Ogre::Entity* ent); + +private: + // Wireframe mode bool m_wireframeEnabled = false; std::map m_savedMaterials; ///< SubEntity index → original material name diff --git a/src/EditModeController_test.cpp b/src/EditModeController_test.cpp index 650c8cd5a..9f4a81ce6 100644 --- a/src/EditModeController_test.cpp +++ b/src/EditModeController_test.cpp @@ -27,6 +27,26 @@ The MIT License #include #include +// =========================================================================== +// rewriteEntityAfterTopologyChange — null + no-bump-map shape (chunk: quads +// follow-up). Full bump-map / RTSS exercise needs a GL context, which the +// test infra doesn't provide on macOS; the integration coverage lives in +// hand smoke tests on the bump-mapped Mixamo asset. Here we lock down the +// public-API contract: static, callable from outside the class (notably +// from EditMeshTopologyCommand::applyMeshState in the undo/redo path), +// null-tolerant, and a no-op when no entity material requests tangents. +// =========================================================================== + +TEST(EditModeControllerStandalone, RewriteEntityAfterTopologyChangeNullIsNoOp) { + // Regression for chunk 4b → quads follow-up: the static helper is + // reachable from command code in TransformCommands.cpp via a class- + // qualified call, and accepts a null entity without crashing. If + // someone refactors it back to a free function in an anonymous + // namespace, undo/redo will silently lose its lighting hook again. + EditModeController::rewriteEntityAfterTopologyChange(nullptr); + SUCCEED(); +} + // =========================================================================== // Pure geometry tests (no Ogre needed) // =========================================================================== diff --git a/src/commands/TransformCommands.cpp b/src/commands/TransformCommands.cpp index 458cc1495..e3830c36b 100644 --- a/src/commands/TransformCommands.cpp +++ b/src/commands/TransformCommands.cpp @@ -664,9 +664,10 @@ void EditMeshTopologyCommand::applyMeshState( // and skeletal skinning are preserved through undo/redo. ctrl->currentMesh()->resizeEntityBuffers(ctrl->editEntity()); - // Refresh Entity's SubEntity caches for the new vertex layout - ctrl->editEntity()->_deinitialise(); - ctrl->editEntity()->_initialise(true); + // Refresh Entity caches + RTSS state — same hook every topology op + // uses, so undo/redo preserves bump map / per-pixel lighting on + // bump-mapped assets through the n-gon import path. + EditModeController::rewriteEntityAfterTopologyChange(ctrl->editEntity()); // Restore full selection state (vertices, edges, and faces) ctrl->deselectAll(); From 60a118dd5fdec12817beab7185eb52a56854ea2b Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 21:08:42 -0400 Subject: [PATCH 15/34] fix(quads): fill produces a single n-gon; knife works on quad meshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions surfaced after chunks 4 / 4b / 5a landed on feat/quads. 1. Fill produced fan triangles instead of an n-gon `HalfEdgeMesh::fillSelection` always called `appendTriangle` in a fan loop and emitted N-2 triangles for N inputs. After the n-gon round-trip, `toEditableMesh` saw N-2 separate triangles and wrote them as N-2 EditableFaces, so a 4-vertex fill ended up as 2 triangles with a visible fan diagonal — and Standard Subdivide on the result treated them as triangles, not as a quad. Fix: for n=3 still call appendTriangle (matches existing output); for n>=4 call `appendFace` once, creating a single n-gon HEFace. The result round-trips through toEditableMesh as one EditableFace with N indices. Return value semantics changes from "triangles created" to "polygons created" (always 1 here); callers used the value as a success/fail flag, so the surface contract holds. 2. Knife silently failed on quad-imported meshes `splitEdge` (the primitive `cutPath` uses) is a triangle-only MVP — it bails immediately if either adjacent face has != 3 vertices. On a quad-imported mesh, every face is an n-gon and the very first split fails, so knife appears to do nothing. Workaround until a proper n-gon-aware splitEdge lands: in the knife commit path, build the HE from a triangle-mode COPY of the editable mesh (clear `.faces` so buildFromEditableMesh falls back to the fan-triangulated `.triangles` mirror). The cost is materialising the fan diagonals on every submesh the cut touches; the benefit is a working knife. Tracked as a follow-up. Tests - 4-vertex fill now expects `1` polygon, not `2`, AND asserts the result round-trips as a single quad EditableFace. - 5-vertex fill same: `1` polygon, asserts a pentagon survives the HE round-trip. - 4-orphan fill updated for the new return value. - Existing `FillSelectionRejectsLargerFanThatDuplicatesExistingTri` still passes — the fan-vs-existing-triangle dedup check at the head of fillSelection still walks fan triangles, so duplicate rejection is unchanged. --- src/EditModeController.cpp | 18 +++++++++++++-- src/HalfEdgeMesh.cpp | 30 +++++++++++++++++-------- src/HalfEdgeMesh_test.cpp | 45 ++++++++++++++++++++++++++++++-------- 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 6a41141a7..447313bb0 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -2559,8 +2559,22 @@ bool EditModeController::commitKnife() // consecutive endpoints, so the visible preview becomes a real chain // of mesh edges. OnFace/OnVertex clicks aren't yet in scope — the // commit skips them and proceeds on the OnEdge subset. + // + // splitEdge — the primitive cutPath uses — is a triangle-only MVP + // (n-gon support requires ear-clip-aware rewiring). On a quad- + // imported mesh, every face is an n-gon and splitEdge bails + // immediately, so knife silently fails. Workaround until a proper + // n-gon splitEdge lands: build the HE from a triangle-mode COPY + // (clear `.faces` so buildFromEditableMesh falls back to the + // fan-triangulated `.triangles` mirror). The user gets a working + // knife at the cost of materialising the fan diagonals on every + // submesh the cut touches. Tracked as a follow-up. + EditableMesh triOnly; + triOnly.subMeshes() = m_editableMesh->subMeshes(); + for (auto& sub : triOnly.subMeshes()) + sub.faces.clear(); HalfEdgeMesh hm; - if (!hm.buildFromEditableMesh(*m_editableMesh)) { + if (!hm.buildFromEditableMesh(triOnly)) { cancelKnife(); return false; } @@ -3481,7 +3495,7 @@ int EditModeController::fillSelection() validateMesh(); SentryReporter::addBreadcrumb("edit_mode", - QString("Fill (verts=%1, tris=%2)") + QString("Fill (verts=%1, faces=%2)") .arg(targetVerts.size()).arg(created)); updateSelectionOverlay(); diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 40653c393..1d9385a16 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -5325,22 +5325,34 @@ int HalfEdgeMesh::fillSelection(const std::vector& vertexIndices) return 0; } - // 3. Fan-triangulate from vertexIndices[0]. For n=3 emits one triangle; - // for n=4 emits two; for general N emits N-2. - int triCount = 0; - for (int i = 1; i + 1 < n; ++i) { - appendTriangle(vertexIndices[0], vertexIndices[i], - vertexIndices[i + 1], subIdx); - ++triCount; + // 3. Build the new face. For n=3 emit a single triangle (matches + // the existing fan output); for n>=4 emit a single n-gon HEFace + // so the result round-trips back through toEditableMesh as one + // EditableFace with N indices, not N-2 fan triangles. Without + // this branch, filling a 4-vertex selection would create two + // triangles whose shared diagonal then surfaces as a fan-edge + // in Edit Mode (and Standard Subdivide treats them as + // triangles, not as a quad). Returns the number of polygons + // created (always 1 here — kept as `int` to preserve the + // function signature; callers use the return only as a + // success/fail flag). + int created; + if (n == 3) { + appendTriangle(vertexIndices[0], vertexIndices[1], + vertexIndices[2], subIdx); + created = 1; + } else { + const int fIdx = appendFace(vertexIndices, subIdx); + if (fIdx < 0) return 0; + created = 1; } - if (triCount == 0) return 0; rebuildEdgesAndTwins(); compactBoundaryHalfEdges(); buildBoundaryHalfEdges(); fixVertexHalfEdges(); - return triCount; + return created; } bool HalfEdgeMesh::validate() const diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index 9bf64eac9..3599a4e84 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -4293,19 +4293,35 @@ TEST(HalfEdgeMeshStandalone, FillSelectionFourVerticesEmitsTwoTriangles) { ASSERT_TRUE(he.buildFromEditableMesh(mesh)); ASSERT_EQ(activeFaceCount(he), 1); - // Fan-triangulate the (3, 4, 5, 6) quad from vertex 3. Produces - // (3, 4, 5) and (3, 5, 6) — a watertight 4-vertex fill. - EXPECT_EQ(he.fillSelection({3, 4, 5, 6}), 2); - EXPECT_EQ(activeFaceCount(he), 3); + // Fill (3, 4, 5, 6) as a single quad face — NOT a fan-triangulation. + // Pre-quads-followup this returned 2 (triangle count); now it + // returns 1 (face count) and the result round-trips back through + // toEditableMesh as one 4-index EditableFace. + EXPECT_EQ(he.fillSelection({3, 4, 5, 6}), 1); + EXPECT_EQ(activeFaceCount(he), 2); EXPECT_TRUE(he.validate()); EditableMesh back; ASSERT_TRUE(he.toEditableMesh(back)); EXPECT_TRUE(isManifold(back)); + + // The new face must round-trip as a quad EditableFace, not 2 tris. + ASSERT_EQ(back.subMeshes().size(), 1u); + const auto& subBack = back.subMeshes()[0]; + ASSERT_FALSE(subBack.faces.empty()) + << "n>=4 fill must populate EditableSubMesh::faces"; + bool sawQuad = false; + for (const auto& f : subBack.faces) { + if (f.indices.size() == 4) { sawQuad = true; break; } + } + EXPECT_TRUE(sawQuad) + << "fillSelection of 4 verts must produce a single quad face"; } -TEST(HalfEdgeMeshStandalone, FillSelectionFiveVerticesFanTriangulatesIntoThree) { - // Pentagon fan-triangulated from vertexIndices[0]: 5 vertices → 3 tris. +TEST(HalfEdgeMeshStandalone, FillSelectionFiveVerticesProducesSinglePentagon) { + // Pentagon fill: 5 vertices → ONE 5-vertex EditableFace, not 3 fan + // triangles. (Quads-followup: a single n-gon HEFace round-trips as + // a single n-gon EditableFace through toEditableMesh.) EditableMesh mesh; EditableSubMesh sub; sub.materialName = "FillMat"; @@ -4325,8 +4341,19 @@ TEST(HalfEdgeMeshStandalone, FillSelectionFiveVerticesFanTriangulatesIntoThree) ASSERT_TRUE(he.buildFromEditableMesh(mesh)); // Vertices 0..4 form a convex pentagon. Fill its loop in order. - EXPECT_EQ(he.fillSelection({0, 1, 2, 3, 4}), 3); + EXPECT_EQ(he.fillSelection({0, 1, 2, 3, 4}), 1); EXPECT_TRUE(he.validate()); + + // Confirm round-trip preserves the 5-gon. + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + ASSERT_EQ(back.subMeshes().size(), 1u); + bool sawPentagon = false; + for (const auto& f : back.subMeshes()[0].faces) { + if (f.indices.size() == 5) { sawPentagon = true; break; } + } + EXPECT_TRUE(sawPentagon) + << "fillSelection of 5 verts must produce a single pentagon face"; } // =========================================================================== @@ -4537,8 +4564,8 @@ TEST(HalfEdgeMeshStandalone, FillSelectionAcceptsOrphanedVertices) { HalfEdgeMesh he; ASSERT_TRUE(he.buildFromEditableMesh(mesh)); - // Fill with all 4 orphans → 2 new triangles (fan from vert 0). - EXPECT_EQ(he.fillSelection({0, 1, 3, 2}), 2); + // Fill with all 4 orphans → ONE new quad face (quads-followup). + EXPECT_EQ(he.fillSelection({0, 1, 3, 2}), 1); EXPECT_TRUE(he.validate()); } From 1aaff0d48b8cb67b20672e8435fd2413d415c644 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 21:10:56 -0400 Subject: [PATCH 16/34] fix(quads): guard normal-map mat->load against broken resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 on PR #335: applyNormalMapsToEntity is now called from rewriteEntityAfterTopologyChange on every topology op AND every undo/redo, so any single broken/unresolvable material would now abort the entire edit op via an unhandled `mat->load()` throw — a regression from the old path that only invalidated RTSS without forcing a load. Wrap the load call in a try/catch and skip the offending sub-entity on failure. Logs the material name + Ogre exception description so we don't lose the diagnostic. --- src/MeshImporterExporter.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index a2e8bbb16..00a858195 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -904,7 +904,19 @@ void MeshImporterExporter::applyNormalMapsToEntity(const Ogre::Entity* en) auto mat = subEnt->getMaterial(); if (!mat) continue; // Ensure the material is fully loaded so TUS names are populated. - if (!mat->isLoaded()) mat->load(); + // `load()` can throw on broken/unresolvable resources; this hook + // runs on every topology mutation AND undo/redo, so a single + // bad material shouldn't abort the edit op. Skip the offending + // sub-entity and let the rest of the entity refresh. + if (!mat->isLoaded()) { + try { + mat->load(); + } catch (const Ogre::Exception& e) { + log.logMessage("applyNormalMapsToEntity: skipping mat '" + + mat->getName() + "' — load failed: " + e.getDescription()); + continue; + } + } if (mat->getNumTechniques() == 0) continue; auto* pass = mat->getTechnique(0)->getPass(0); if (!pass) continue; From 4de903ac37a2af9b89c2e28d1fe9b12570d66ab4 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 21:48:54 -0400 Subject: [PATCH 17/34] fix(quads): fill orients to surrounding mesh; knife uses same HE in hit-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes on top of the earlier knife/fill work. 1. Fill produced inward-facing normals `fillSelection` walked the input vertices in user-supplied order (typically `std::set` ascending), which is whatever order the selection produced — nothing guaranteed it matched the winding of the surrounding mesh. On a hole's boundary loop, the new face often ended up oriented INTO the volume. Fix: before building the new face, compute its Newell normal and compare against the average Newell normal of every existing face that shares at least one of the selected vertices. If the dot product is negative the winding is inverted; reverse it. n=3 triangles and n>=4 n-gons go through the same orientation step. 2. Knife was a no-op on quad-imported meshes `knifeHitTest` built the HE from the live `*m_editableMesh` (n-gon path, since `.faces` is populated for quad-imported assets), but `commitKnife` builds the HE from a triangle-mode copy (clears `.faces` so the splitEdge MVP — triangle-only — can run). The `edgeIndex` recorded at click time pointed at edges in the n-gon HE; the commit-time HE had different edge numbering, so cutPath tried to split an unrelated edge and either no-op'd or mutated the wrong region. Fix: build the SAME triangle-mode HE in `knifeHitTest`, mirroring the commit pipeline. Edge indices now line up between hit-test and commit. Triangle-only meshes (welded cube, primitives) were unaffected since their `.faces` is already empty — the existing knife tests still pass. --- src/EditModeController.cpp | 12 +++++++++- src/HalfEdgeMesh.cpp | 46 ++++++++++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 8cea81224..de55fc379 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -3553,8 +3553,18 @@ bool EditModeController::knifeHitTest(const QPoint& screenPos, OgreWidget* widge rD = worldToLocal.linear() * rD; } + // Build the HE from a triangle-mode COPY of the editable mesh, + // mirroring what the knife commit path does. Otherwise the HE + // edge indices we record on the click here (n-gon HE: one edge + // per polygon side) wouldn't match the indices `commitKnife` + // resolves against (triangle HE: one edge per fan side), and + // every cut would land on the wrong edge — manifesting as a + // knife that "does nothing" on quad-imported assets. + EditableMesh triOnly; + triOnly.subMeshes() = m_editableMesh->subMeshes(); + for (auto& sub : triOnly.subMeshes()) sub.faces.clear(); HalfEdgeMesh tmp; - if (tmp.buildFromEditableMesh(*m_editableMesh)) { + if (tmp.buildFromEditableMesh(triOnly)) { const Ogre::Vector3 camPosWorld = camera->getDerivedPosition(); constexpr float kPixelRadius = 10.0f; float bestDepth = std::numeric_limits::infinity(); diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 1d9385a16..22e490c1f 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -5325,7 +5325,46 @@ int HalfEdgeMesh::fillSelection(const std::vector& vertexIndices) return 0; } - // 3. Build the new face. For n=3 emit a single triangle (matches + // 3. Pick a winding that faces the same way as the surrounding mesh. + // Without this, a fill on a hole's boundary loop produces a face + // whose normal points INTO the volume (the user's selection order + // is whatever std::set / the loop walker produced; nothing + // guarantees it matches the existing topology). + // Heuristic: compare the candidate face's Newell normal to the + // average normal of every existing face that shares at least one + // of the selected vertices. If the dot product is negative the + // winding is inverted; reverse it. + auto computeNewellNormal = + [this](const std::vector& verts) -> Ogre::Vector3 { + Ogre::Vector3 normal = Ogre::Vector3::ZERO; + const size_t N = verts.size(); + for (size_t i = 0; i < N; ++i) { + const auto& a = m_vertices[verts[i]].position; + const auto& b = m_vertices[verts[(i + 1) % N]].position; + normal.x += (a.y - b.y) * (a.z + b.z); + normal.y += (a.z - b.z) * (a.x + b.x); + normal.z += (a.x - b.x) * (a.y + b.y); + } + return normal; + }; + + Ogre::Vector3 candidateNormal = computeNewellNormal(vertexIndices); + Ogre::Vector3 referenceNormal = Ogre::Vector3::ZERO; + for (int v : vertexIndices) { + for (int f : facesAroundVertex(v)) { + const auto fv = faceVertices(f); + if (fv.size() < 3) continue; + referenceNormal += computeNewellNormal(fv); + } + } + std::vector winding = vertexIndices; + if (referenceNormal.squaredLength() > 1e-12f + && candidateNormal.squaredLength() > 1e-12f + && candidateNormal.dotProduct(referenceNormal) < 0.0f) { + std::reverse(winding.begin(), winding.end()); + } + + // 4. Build the new face. For n=3 emit a single triangle (matches // the existing fan output); for n>=4 emit a single n-gon HEFace // so the result round-trips back through toEditableMesh as one // EditableFace with N indices, not N-2 fan triangles. Without @@ -5338,11 +5377,10 @@ int HalfEdgeMesh::fillSelection(const std::vector& vertexIndices) // success/fail flag). int created; if (n == 3) { - appendTriangle(vertexIndices[0], vertexIndices[1], - vertexIndices[2], subIdx); + appendTriangle(winding[0], winding[1], winding[2], subIdx); created = 1; } else { - const int fIdx = appendFace(vertexIndices, subIdx); + const int fIdx = appendFace(winding, subIdx); if (fIdx < 0) return 0; created = 1; } From 197a67361047f85fdbff7098c714430115dc4cb8 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 22:23:11 -0400 Subject: [PATCH 18/34] fix(quads): knife accepts OnVertex clicks on dense imported meshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: on the FBX quad asset the knife appeared to do nothing — clicks went through, the commit ran, but cutPath was never called. Root cause: knifeHitTest has a Priority-1 vertex snap (10px radius). On a dense imported mesh (Mixamo character ≈ 19k edges), almost any click also lands within 10px of a vertex, so points came back as KnifePoint::OnVertex. commitKnife only accepted OnEdge — the kind check rejected silently via a Sentry breadcrumb. Fix: at commit time, translate OnVertex points into edge clicks by finding any incident edge to that vertex in the (triangle-mode) HE and using t=0 or t=1. cutPath is unchanged — its splitEdge primitive clamps t away from the endpoints by 1e-4 to keep faces non-degenerate, so the resulting cut vertex sits ≈ 1e-4 of an edge length off the original. Fine for an MVP; a follow-up can teach cutPath to start / end at an existing vertex without splitting at all. OnFace clicks are still rejected (no edge-walk path can represent them). --- src/EditModeController.cpp | 46 +++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index de55fc379..781710480 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -2523,24 +2523,44 @@ bool EditModeController::commitKnife() return false; } - // Refuse the commit if any confirmed point isn't snapped to an edge. - // OnFace and OnVertex captures exist for the preview, but the MVP - // commit pipeline only knows how to cut along edges; silently - // dropping a face click would produce a mesh that doesn't match the - // line the user just drew, which is worse than failing the commit. + // Build the CutPoint list, translating OnVertex clicks into edge + // clicks: pick any incident edge to the vertex and use t=0 or t=1 + // depending on which endpoint the vertex sits at. `cutPath` only + // accepts edge inputs (its `splitEdge` primitive is edge-keyed), + // and `splitEdge` clamps t away from 0/1 by 1e-4 to avoid sliver + // faces — so the resulting vertex sits a hair off the user's click + // but topology stays clean. OnFace clicks are still rejected: the + // commit can't represent them with the current edge-walk algorithm. + std::vector cpts; + cpts.reserve(m_knifeSession.points.size()); for (const auto& p : m_knifeSession.points) { - if (p.kind != KnifePoint::OnEdge) { + if (p.kind == KnifePoint::OnEdge) { + cpts.push_back({p.edgeIndex, p.edgeT}); + continue; + } + if (p.kind != KnifePoint::OnVertex) { SentryReporter::addBreadcrumb("edit_mode", - "Knife: commit rejected (non-edge point — only edge cuts supported)"); + "Knife: commit rejected (OnFace point — only edge / vertex supported)"); cancelKnife(); return false; } - } - - std::vector cpts; - cpts.reserve(m_knifeSession.points.size()); - for (const auto& p : m_knifeSession.points) { - cpts.push_back({p.edgeIndex, p.edgeT}); + // OnVertex → find any incident edge in the triangle-mode HE + // and pick the t that puts the new split-vertex closest to the + // clicked vertex. + int incidentEdge = -1; + float incidentT = 0.0f; + for (size_t e = 0; e < hm.edgeCount(); ++e) { + const auto [ev0, ev1] = hm.edgeVertices(static_cast(e)); + if (ev0 == p.vertexIndex) { incidentEdge = static_cast(e); incidentT = 0.0f; break; } + if (ev1 == p.vertexIndex) { incidentEdge = static_cast(e); incidentT = 1.0f; break; } + } + if (incidentEdge < 0) { + SentryReporter::addBreadcrumb("edit_mode", + "Knife: commit rejected (OnVertex point — no incident edge)"); + cancelKnife(); + return false; + } + cpts.push_back({incidentEdge, incidentT}); } if (cpts.size() < 2) { SentryReporter::addBreadcrumb("edit_mode", From 4c96d0f71d9096f9e4049fcdba2885472ac4c638 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 22:39:31 -0400 Subject: [PATCH 19/34] fix(quads): knife hit-test culls back-face geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On dense imported meshes the knife was picking edges/vertices on the far side of the model — invisible to the user but inside the screen- space pixel radius. Mirrors the chunk-4b front-facing fix that hitTestEdge / face selection already use. Implementation Build front-facing vertex / edge sets once at the top of knifeHitTest, using the n-gon `m_editableMesh` so quad meshes are filtered correctly. A polygon is front-facing when its Newell normal (rotated into world space) points toward the camera. A vertex is front-facing iff at least one incident polygon does; an edge iff at least one of its two adjacent polygons does. Priority 1 (vertex snap): only return OnVertex if `snapVert` is in the front-facing set. Priority 2 (edge snap): skip edges not in the front-facing set. Edge keys use (min,max) global-vertex pairs which line up directly with the triangle-mode HE the snap loop walks. Fan-triangulation diagonals — which the triangle-mode HE generates internally for quad faces — are NOT in `frontEdges` (it's built from polygon perimeters only), so the knife also can't snap to fake interior edges that don't exist on the n-gon mesh. Trade-off note Knife still cuts as triangles on quad meshes (the build-from- triangle-copy workaround materialises fan diagonals at commit). The proper fix is an n-gon-aware splitEdge, tracked as a follow-up before loop cut since both ops will share that infrastructure. --- src/EditModeController.cpp | 80 +++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 781710480..2b2156ae9 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -3536,10 +3536,70 @@ bool EditModeController::knifeHitTest(const QPoint& screenPos, OgreWidget* widge const int vw = widget->width(); const int vh = widget->height(); + // Build the front-facing vertex / edge sets once (mirrors the + // chunk-4b behaviour of regular hitTestEdge / face selection): on + // a dense mesh, snapping to back-face geometry that's hidden behind + // the visible surface is impossible to control. Front-facing means + // the polygon's Newell normal points toward the camera. A vertex is + // front-facing if at least one incident polygon faces the camera; + // an edge is front-facing if at least one of its two adjacent + // polygons does. Edge keys use (min,max) global vertex indices so + // they line up with the triangle-mode HE used below. + Ogre::SceneNode* node = m_editEntity->getParentSceneNode(); + const Ogre::Vector3 camPos = camera->getDerivedPosition(); + auto isPolygonFrontFacing = [&](const EditableSubMesh& sub, + const std::vector& corners) -> bool { + if (corners.size() < 3 || !node) return false; + Ogre::Vector3 nrm = Ogre::Vector3::ZERO; + for (size_t i = 0; i < corners.size(); ++i) { + if (corners[i] >= sub.vertices.size()) return false; + const auto& a = sub.vertices[corners[i]].position; + const auto& b = sub.vertices[corners[(i + 1) % corners.size()]].position; + nrm.x += (a.y - b.y) * (a.z + b.z); + nrm.y += (a.z - b.z) * (a.x + b.x); + nrm.z += (a.x - b.x) * (a.y + b.y); + } + const Ogre::Vector3 worldNrm = node->_getDerivedOrientation() * nrm; + const Ogre::Vector3 anyCornerWorld = + node->convertLocalToWorldPosition(sub.vertices[corners[0]].position); + return worldNrm.dotProduct(camPos - anyCornerWorld) > 0.0f; + }; + + std::set frontVerts; + std::set> frontEdges; + for (size_t si = 0; si < m_editableMesh->subMeshes().size(); ++si) { + const auto& sub = m_editableMesh->subMeshes()[si]; + const int vertOffset = localToGlobal(static_cast(si), 0); + auto recordFrontPoly = [&](const std::vector& corners) { + for (size_t i = 0; i < corners.size(); ++i) { + const int g0 = vertOffset + static_cast(corners[i]); + const int g1 = vertOffset + static_cast(corners[(i + 1) % corners.size()]); + frontVerts.insert(g0); + frontEdges.emplace(std::min(g0, g1), std::max(g0, g1)); + } + }; + if (!sub.faces.empty()) { + for (const auto& face : sub.faces) { + if (face.indices.size() < 3) continue; + if (!isPolygonFrontFacing(sub, face.indices)) continue; + recordFrontPoly(face.indices); + } + } else { + for (const auto& tri : sub.triangles) { + std::vector corners = { + tri.indices[0], tri.indices[1], tri.indices[2] }; + if (!isPolygonFrontFacing(sub, corners)) continue; + recordFrontPoly(corners); + } + } + } + // Priority 1: vertex snap. Uses the same radius as regular edit-mode - // vertex picking so the user's eye can predict the snap. + // vertex picking so the user's eye can predict the snap. Skip + // back-face vertices (not in `frontVerts`) so dense meshes don't + // pull clicks to vertices the user can't see. const int snapVert = hitTestVertex(screenPos, camera, vw, vh, 10.0f); - if (snapVert >= 0) { + if (snapVert >= 0 && frontVerts.count(snapVert)) { auto [subIdx, localIdx] = globalToLocal(snapVert); if (subIdx < m_editableMesh->subMeshes().size() && localIdx < m_editableMesh->subMeshes()[subIdx].vertices.size()) { @@ -3563,7 +3623,6 @@ bool EditModeController::knifeHitTest(const QPoint& screenPos, OgreWidget* widge const Ogre::Real nx = static_cast(screenPos.x()) / vw; const Ogre::Real ny = static_cast(screenPos.y()) / vh; const Ogre::Ray ray = camera->getCameraToViewportRay(nx, ny); - Ogre::SceneNode* node = m_editEntity->getParentSceneNode(); Ogre::Vector3 rO = ray.getOrigin(); Ogre::Vector3 rD = ray.getDirection(); @@ -3585,7 +3644,6 @@ bool EditModeController::knifeHitTest(const QPoint& screenPos, OgreWidget* widge for (auto& sub : triOnly.subMeshes()) sub.faces.clear(); HalfEdgeMesh tmp; if (tmp.buildFromEditableMesh(triOnly)) { - const Ogre::Vector3 camPosWorld = camera->getDerivedPosition(); constexpr float kPixelRadius = 10.0f; float bestDepth = std::numeric_limits::infinity(); int bestEdgeIdx = -1; @@ -3595,6 +3653,18 @@ bool EditModeController::knifeHitTest(const QPoint& screenPos, OgreWidget* widge for (size_t e = 0; e < tmp.edgeCount(); ++e) { auto [gv0, gv1] = tmp.edgeVertices(static_cast(e)); if (gv0 < 0 || gv1 < 0) continue; + // Skip back-face edges. `frontEdges` was computed from + // the n-gon `m_editableMesh`, keyed by global vertex + // pair; the triangle-mode `tmp` indices use the same + // global numbering, so the lookup is direct. Note that + // `frontEdges` only contains polygon-perimeter edges: + // a fan-triangulation diagonal between two front-facing + // verts is rejected here, which is correct — the user + // shouldn't be able to click on a fake interior edge. + { + const std::pair key{std::min(gv0, gv1), std::max(gv0, gv1)}; + if (!frontEdges.count(key)) continue; + } auto [sub0, loc0] = globalToLocal(gv0); auto [sub1, loc1] = globalToLocal(gv1); if (sub0 >= m_editableMesh->subMeshes().size()) continue; @@ -3633,7 +3703,7 @@ bool EditModeController::knifeHitTest(const QPoint& screenPos, OgreWidget* widge } const Ogre::Vector3 local = p0 + d * t; const Ogre::Vector3 world = node->convertLocalToWorldPosition(local); - const float depth = (world - camPosWorld).dotProduct(camera->getDerivedDirection()); + const float depth = (world - camPos).dotProduct(camera->getDerivedDirection()); if (depth <= 0.0f) continue; // behind camera if (depth < bestDepth) { bestDepth = depth; From 2b0a1ccda050cf9de13db4cdf82edde39e8a37eb Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 22:42:44 -0400 Subject: [PATCH 20/34] refactor(quads): extract helpers to satisfy Sonar complexity gate PR #335's SonarCloud quality gate failed on cognitive complexity: - rewriteEntityAfterTopologyChange: 47 (limit 25) - enterEditMode: 32 (limit 25) plus a deprecation warning on the old buildTangentVectors overload. Refactor (no behaviour change): - File-scope `entityWantsTangents`, `rebuildMeshTangents`, `invalidateEntityRtssMaterials` factor out the three loops inside `rewriteEntityAfterTopologyChange`. Switches to the non-deprecated buildTangentVectors signature. - File-scope `tryLoadEditableMeshNGonPath` factors out the four- nested-try-block n-gon-import attempt from `enterEditMode`. `rewriteEntityAfterTopologyChange` and `enterEditMode` now read top- to-bottom as plain sequences of named steps. All 234 standalone tests still pass. --- src/EditModeController.cpp | 303 +++++++++++++++++-------------------- 1 file changed, 141 insertions(+), 162 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 2b2156ae9..d48c82d3e 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -256,6 +256,55 @@ void EditModeController::toggleEditMode() enterEditMode(); } +// Try the n-gon-aware re-import path: when the entity's mesh has the +// `qtme.source_path` user-binding (cached by MeshImporterExporter at +// import time AND not yet wiped by a topology mutation), re-read the +// source asset through Assimp with aiProcess_Triangulate disabled so +// source quads survive into EditableSubMesh::faces. The cached +// `qtme.source_convert_lh` and `qtme.source_up_axis` keys make the +// editable mesh share basis with the rendered buffers; passing the +// live skeleton makes bone handles match MeshProcessor's convention. +// Returns true on success (caller uses the editable mesh as-is), +// false to signal that the legacy `loadFromEntity` path should run. +static bool tryLoadEditableMeshNGonPath( + Ogre::Entity* entity, EditableMesh* editableMesh) +{ + if (!entity || !editableMesh) return false; + const Ogre::MeshPtr meshPtr = entity->getMesh(); + if (!meshPtr) return false; + + const auto& bindings = meshPtr->getUserObjectBindings(); + const Ogre::Any& any = bindings.getUserAny("qtme.source_path"); + if (!any.has_value()) return false; + + std::string sourcePath; + try { + sourcePath = Ogre::any_cast(any); + } catch (const Ogre::Exception&) { + return false; // Any held the wrong type — fall back defensively. + } + if (sourcePath.empty()) return false; + + // Default: convert-LH=true matches AssimpToOgreImporter's behaviour + // for unknown origins; up-axis=Y-up is the safe default if the + // import didn't cache one. + bool convertLH = true; + const Ogre::Any& lhAny = bindings.getUserAny("qtme.source_convert_lh"); + if (lhAny.has_value()) { + try { convertLH = Ogre::any_cast(lhAny); } + catch (const Ogre::Exception&) {} + } + bool isZup = false; + const Ogre::Any& upAny = bindings.getUserAny("qtme.source_up_axis"); + if (upAny.has_value()) { + try { isZup = (Ogre::any_cast(upAny) == 2); } + catch (const Ogre::Exception&) {} + } + const Ogre::Skeleton* skel = + meshPtr->hasSkeleton() ? meshPtr->getSkeleton().get() : nullptr; + return editableMesh->loadFromAssimpFile(sourcePath, convertLH, isZup, skel); +} + bool EditModeController::enterEditMode() { if (m_editModeActive) @@ -270,97 +319,20 @@ bool EditModeController::enterEditMode() QList entities = sel->getResolvedEntities(); m_editEntity = entities.first(); - // Decompose mesh into editable data. Two paths: - // - // - n-gon path: when MeshImporterExporter has cached the source - // file path (qtme.source_path) on the Ogre::Mesh AND no edit - // has been committed since import (commitToEntity / resize- - // EntityBuffers wipe the cache on mutation), re-import the - // asset through Assimp with aiProcess_Triangulate disabled so - // source quads survive into EditableSubMesh::faces. - // - // - legacy path: read the live Ogre buffers via loadFromEntity. - // This is what every prior chunk used, and what every code - // path that doesn't have a source path (procedural primitives, - // .scene.glb sub-entities, post-edit re-entries) falls back to. - // - // The n-gon path enables Catmull-Clark subdivision, loop cut, and - // any future quad-aware op to act on real source quads instead of - // the diagonal triangulation Assimp emits by default. - // (Quad migration #326, chunk 4.) + // Decompose mesh into editable data. Prefer the n-gon path (re- + // import via Assimp with aiProcess_Triangulate off) when the mesh + // still carries qtme.source_path. Fall back to the legacy + // loadFromEntity path for procedural primitives, .scene.glb sub- + // entities, and post-edit re-entries (where commitToEntity / + // resizeEntityBuffers wipe the cache on mutation). The n-gon path + // is what enables Catmull-Clark subdivide / loop cut / future + // quad-aware ops to act on real source quads. (Quad migration + // #326, chunk 4.) m_editableMesh = std::make_unique(); - - bool loaded = false; - { - const Ogre::MeshPtr meshPtr = m_editEntity->getMesh(); - if (meshPtr) { - const auto& bindings = meshPtr->getUserObjectBindings(); - const Ogre::Any& any = bindings.getUserAny("qtme.source_path"); - if (any.has_value()) { - try { - const std::string sourcePath = - Ogre::any_cast(any); - // The original importer cached its - // convert-to-left-handed choice alongside the path. - // Apply the SAME flag here so the editable mesh - // stays in the same coordinate system as the - // rendered Ogre buffers — without this, on every - // non-.x asset the vertex / edge / face overlays - // would draw mirrored (X flipped) relative to the - // on-screen geometry. Defaults to true to match - // AssimpToOgreImporter's behaviour for unknown - // origins. - bool convertLH = true; - const Ogre::Any& lhAny = - bindings.getUserAny("qtme.source_convert_lh"); - if (lhAny.has_value()) { - try { - convertLH = Ogre::any_cast(lhAny); - } catch (const Ogre::Exception&) {} - } - // The original importer also caches the source up- - // axis (1 = Y-up, 2 = Z-up). MeshProcessor bakes a - // +90°-around-X rotation into the rendered buffers - // for Z-up assets; we pass `isZup` so the editable - // representation stays in the same basis. Without - // this, FBX/glTF assets that declare Z-up would - // surface vertex overlays rotated 90° relative to - // the on-screen geometry, and a commit would write - // the rotated positions back, silently rotating - // the entity. - bool isZup = false; - const Ogre::Any& upAny = - bindings.getUserAny("qtme.source_up_axis"); - if (upAny.has_value()) { - try { - isZup = (Ogre::any_cast(upAny) == 2); - } catch (const Ogre::Exception&) {} - } - // Resolve aiBone names against the live skeleton so - // the n-gon path emits Ogre bone HANDLES (matching - // MeshProcessor) instead of mesh-local aiBone - // indices. Without this, a topology op on a skinned - // mesh re-emits VertexBoneAssignments with wild - // handles and skinning rebinds vertices to wrong - // bones. - const Ogre::Skeleton* skel = nullptr; - if (meshPtr->hasSkeleton()) { - skel = meshPtr->getSkeleton().get(); - } - if (!sourcePath.empty() - && m_editableMesh->loadFromAssimpFile( - sourcePath, convertLH, isZup, skel)) { - loaded = true; - SentryReporter::addBreadcrumb("edit_mode", - "Edit Mode entered via n-gon import path"); - } - } catch (const Ogre::Exception&) { - // The Any contained something other than a string — - // shouldn't happen but fall through to the legacy - // path defensively. - } - } - } + bool loaded = tryLoadEditableMeshNGonPath(m_editEntity, m_editableMesh.get()); + if (loaded) { + SentryReporter::addBreadcrumb("edit_mode", + "Edit Mode entered via n-gon import path"); } if (!loaded && !m_editableMesh->loadFromEntity(m_editEntity)) { SentryReporter::addBreadcrumb("edit_mode", "Failed to load mesh data for Edit Mode"); @@ -2646,8 +2618,70 @@ bool EditModeController::commitKnife() // BEFORE `_deinitialise/_initialise` so the SubEntity vertex-decl cache // reads the corrected layout, then re-run `applyNormalMapsToEntity` so // RTSS re-attaches SRS_NORMALMAP against the fresh tangents. +namespace { +// Returns true if any sub-entity of `ent` has a normal-map TUS — the +// signal we use to decide whether RTSS will need tangents on the next +// render. Materials that fail to load are skipped so a single broken +// resource doesn't abort the topology op. +bool entityWantsTangents(Ogre::Entity* ent) { + for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { + auto mat = ent->getSubEntity(i)->getMaterial(); + if (!mat) continue; + if (!mat->isLoaded()) { + try { mat->load(); } + catch (const Ogre::Exception&) { continue; } + } + if (mat->getNumTechniques() == 0) continue; + auto* pass = mat->getTechnique(0)->getPass(0); + if (!pass) continue; + for (unsigned short t = 0; t < pass->getNumTextureUnitStates(); ++t) { + const auto& tusName = pass->getTextureUnitState(t)->getName(); + if (tusName == "normal_map" || tusName == "NormalMap") return true; + } + } + return false; +} + +// Force-rebuild tangent vectors on `mesh`. Logs (rather than throws) on +// failure since the caller runs from inside an entity-refresh hook. +void rebuildMeshTangents(const Ogre::MeshPtr& mesh) { + if (!mesh) return; + try { + // storeParityInW=true → VET_FLOAT4, matching what + // RTShaderHelper::applyNormalMap and the import path use. + mesh->buildTangentVectors(/*sourceTexCoordSet=*/0, + /*splitMirrored=*/false, + /*splitRotated=*/false, + /*storeParityInW=*/true); + } catch (const Ogre::Exception& e) { + Ogre::LogManager::getSingleton().logMessage( + "rewriteEntityAfterTopologyChange: buildTangentVectors " + "failed for '" + mesh->getName() + "': " + e.getDescription()); + } +} + +// Drop the cached RTSS shader programs for every sub-entity material +// so the next render regenerates them against the post-topology-op +// vertex declaration. No-op when the ShaderGenerator is absent. +void invalidateEntityRtssMaterials(Ogre::Entity* ent) { + auto* sg = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); + if (!sg) return; + for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { + const std::string& m = ent->getSubEntity(i)->getMaterialName(); + if (!m.empty()) + sg->invalidateMaterial( + Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, m); + } +} +} // namespace + void EditModeController::rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { if (!ent) return; + + // Snapshot per-subentity material overrides — _deinitialise/_initialise + // resets them to the SubMesh default, but the wireframe overlay and + // MaterialEditor write to the SubEntity, not the SubMesh, so both + // must survive the rebuild. std::vector preMats; preMats.reserve(ent->getNumSubEntities()); for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) @@ -2662,54 +2696,15 @@ void EditModeController::rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { // SRS_NORMALMAP code path collapses to a no-op even though the // tangents physically exist in the buffer. // - // We need tangents whenever any subentity is bump-mapped (has a - // `normal_map`/`NormalMap` TUS). Two cases trigger that: - // 1. The mesh declaration ALREADY has VES_TANGENT but the new - // vertices written by `EditableMesh::buildSubMeshBuffers` are - // zero-valued (default-constructed `EditableVertex::tangent`). - // 2. The declaration LOST VES_TANGENT during the buffer rewrite — - // this happens when the n-gon import path (which doesn't - // request `aiProcess_CalcTangentSpace` because it forces - // triangulation) didn't have tangents in the source file, so - // `EditableVertex::hasTangent == false` for every vertex and - // the rebuilt declaration drops the slot entirely. - // Either way `Mesh::buildTangentVectors` is the fix: it adds the - // VES_TANGENT element if missing and (re)computes values from - // positions/normals/UVs. - if (auto mesh = ent->getMesh()) { - bool wantsTangents = false; - for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { - auto mat = ent->getSubEntity(i)->getMaterial(); - if (!mat) continue; - if (!mat->isLoaded()) { - try { mat->load(); } catch (...) {} - } - if (mat->getNumTechniques() == 0) continue; - auto* pass = mat->getTechnique(0)->getPass(0); - if (!pass) continue; - for (unsigned short t = 0; t < pass->getNumTextureUnitStates(); ++t) { - const auto& tusName = - pass->getTextureUnitState(t)->getName(); - if (tusName == "normal_map" || tusName == "NormalMap") { - wantsTangents = true; - break; - } - } - if (wantsTangents) break; - } - if (wantsTangents) { - try { - // storeParityInW=true → VET_FLOAT4, matching what - // RTShaderHelper::applyNormalMap and the import path use. - mesh->buildTangentVectors( - Ogre::VES_TANGENT, 0, 0, false, false, true); - } catch (const Ogre::Exception& e) { - Ogre::LogManager::getSingleton().logMessage( - "rewriteEntityAfterTopologyChange: buildTangentVectors " - "failed for '" + mesh->getName() + "': " + e.getDescription()); - } - } - } + // We rebuild whenever any subentity is bump-mapped: new vertices + // written by `EditableMesh::buildSubMeshBuffers` start with zero- + // valued tangents (EditableVertex defaults), and on the n-gon + // import path — which omits `aiProcess_CalcTangentSpace` because + // that flag forces triangulation — the declaration drops VES_TANGENT + // entirely. `Mesh::buildTangentVectors` re-adds the element if + // missing and recomputes values from positions/normals/UVs. + if (entityWantsTangents(ent)) + rebuildMeshTangents(ent->getMesh()); // Re-init the entity so its SubEntity caches (vertex / index // counts, skeleton anim buffers) sync to the resized mesh. @@ -2724,31 +2719,15 @@ void EditModeController::rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { ent->getSubEntity(i)->setMaterialName(preMats[i]); } - // Re-attach the RTSS SRS_NORMALMAP sub-render-state per material that - // has a normal-map TUS. `invalidateMaterial` alone only drops the - // cached shader programs; the renderState's template list is - // preserved so the regenerated shader would normally inherit - // SRS_NORMALMAP — except `applyNormalMap` uses - // `removeShaderBasedTechnique + createShaderBasedTechnique` to start - // from a clean slate (some tangent-related state in the render state - // doesn't survive a buffer-format change), so we must run it again - // to guarantee the SRS_NORMALMAP is re-bound against the new - // tangents. Materials without a normal-map TUS are no-ops here. + // Re-attach the RTSS SRS_NORMALMAP sub-render-state per material + // that has a normal-map TUS, then drop any cached shader programs + // so the next render regenerates against the new vertex layout. + // `applyNormalMapsToEntity` uses `removeShaderBasedTechnique + + // createShaderBasedTechnique` to start from a clean slate (some + // tangent-related state doesn't survive a buffer-format change), + // so it must run AFTER the tangent rebuild + _initialise. MeshImporterExporter::applyNormalMapsToEntity(ent); - - // Final invalidate so any per-material cache that survived the - // applyNormalMap path drops too. validateMaterial inside - // applyNormalMap already kicks shader regen, but explicit - // invalidate keeps behaviour identical to other topology ops for - // materials that aren't bump-mapped. - if (auto* sg = Ogre::RTShader::ShaderGenerator::getSingletonPtr()) { - for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { - const std::string& m = ent->getSubEntity(i)->getMaterialName(); - if (!m.empty()) - sg->invalidateMaterial( - Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, m); - } - } + invalidateEntityRtssMaterials(ent); } // Find global vertex indices of the survivor positions after toEditableMesh From bbf4f0dc80a6d1561a957eaa973916a3ed14997d Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 22:42:44 -0400 Subject: [PATCH 21/34] refactor(quads): extract helpers to satisfy Sonar complexity gate PR #335's SonarCloud quality gate failed on cognitive complexity: - rewriteEntityAfterTopologyChange: 47 (limit 25) - enterEditMode: 32 (limit 25) plus a deprecation warning on the old buildTangentVectors overload. Refactor (no behaviour change): - File-scope `entityWantsTangents`, `rebuildMeshTangents`, `invalidateEntityRtssMaterials` factor out the three loops inside `rewriteEntityAfterTopologyChange`. Switches to the non-deprecated buildTangentVectors signature. - File-scope `tryLoadEditableMeshNGonPath` factors out the four- nested-try-block n-gon-import attempt from `enterEditMode`. `rewriteEntityAfterTopologyChange` and `enterEditMode` now read top- to-bottom as plain sequences of named steps. All 234 standalone tests still pass. --- src/EditModeController.cpp | 303 +++++++++++++++++-------------------- 1 file changed, 141 insertions(+), 162 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 45ec5f89f..81f29789d 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -256,6 +256,55 @@ void EditModeController::toggleEditMode() enterEditMode(); } +// Try the n-gon-aware re-import path: when the entity's mesh has the +// `qtme.source_path` user-binding (cached by MeshImporterExporter at +// import time AND not yet wiped by a topology mutation), re-read the +// source asset through Assimp with aiProcess_Triangulate disabled so +// source quads survive into EditableSubMesh::faces. The cached +// `qtme.source_convert_lh` and `qtme.source_up_axis` keys make the +// editable mesh share basis with the rendered buffers; passing the +// live skeleton makes bone handles match MeshProcessor's convention. +// Returns true on success (caller uses the editable mesh as-is), +// false to signal that the legacy `loadFromEntity` path should run. +static bool tryLoadEditableMeshNGonPath( + Ogre::Entity* entity, EditableMesh* editableMesh) +{ + if (!entity || !editableMesh) return false; + const Ogre::MeshPtr meshPtr = entity->getMesh(); + if (!meshPtr) return false; + + const auto& bindings = meshPtr->getUserObjectBindings(); + const Ogre::Any& any = bindings.getUserAny("qtme.source_path"); + if (!any.has_value()) return false; + + std::string sourcePath; + try { + sourcePath = Ogre::any_cast(any); + } catch (const Ogre::Exception&) { + return false; // Any held the wrong type — fall back defensively. + } + if (sourcePath.empty()) return false; + + // Default: convert-LH=true matches AssimpToOgreImporter's behaviour + // for unknown origins; up-axis=Y-up is the safe default if the + // import didn't cache one. + bool convertLH = true; + const Ogre::Any& lhAny = bindings.getUserAny("qtme.source_convert_lh"); + if (lhAny.has_value()) { + try { convertLH = Ogre::any_cast(lhAny); } + catch (const Ogre::Exception&) {} + } + bool isZup = false; + const Ogre::Any& upAny = bindings.getUserAny("qtme.source_up_axis"); + if (upAny.has_value()) { + try { isZup = (Ogre::any_cast(upAny) == 2); } + catch (const Ogre::Exception&) {} + } + const Ogre::Skeleton* skel = + meshPtr->hasSkeleton() ? meshPtr->getSkeleton().get() : nullptr; + return editableMesh->loadFromAssimpFile(sourcePath, convertLH, isZup, skel); +} + bool EditModeController::enterEditMode() { if (m_editModeActive) @@ -270,97 +319,20 @@ bool EditModeController::enterEditMode() QList entities = sel->getResolvedEntities(); m_editEntity = entities.first(); - // Decompose mesh into editable data. Two paths: - // - // - n-gon path: when MeshImporterExporter has cached the source - // file path (qtme.source_path) on the Ogre::Mesh AND no edit - // has been committed since import (commitToEntity / resize- - // EntityBuffers wipe the cache on mutation), re-import the - // asset through Assimp with aiProcess_Triangulate disabled so - // source quads survive into EditableSubMesh::faces. - // - // - legacy path: read the live Ogre buffers via loadFromEntity. - // This is what every prior chunk used, and what every code - // path that doesn't have a source path (procedural primitives, - // .scene.glb sub-entities, post-edit re-entries) falls back to. - // - // The n-gon path enables Catmull-Clark subdivision, loop cut, and - // any future quad-aware op to act on real source quads instead of - // the diagonal triangulation Assimp emits by default. - // (Quad migration #326, chunk 4.) + // Decompose mesh into editable data. Prefer the n-gon path (re- + // import via Assimp with aiProcess_Triangulate off) when the mesh + // still carries qtme.source_path. Fall back to the legacy + // loadFromEntity path for procedural primitives, .scene.glb sub- + // entities, and post-edit re-entries (where commitToEntity / + // resizeEntityBuffers wipe the cache on mutation). The n-gon path + // is what enables Catmull-Clark subdivide / loop cut / future + // quad-aware ops to act on real source quads. (Quad migration + // #326, chunk 4.) m_editableMesh = std::make_unique(); - - bool loaded = false; - { - const Ogre::MeshPtr meshPtr = m_editEntity->getMesh(); - if (meshPtr) { - const auto& bindings = meshPtr->getUserObjectBindings(); - const Ogre::Any& any = bindings.getUserAny("qtme.source_path"); - if (any.has_value()) { - try { - const std::string sourcePath = - Ogre::any_cast(any); - // The original importer cached its - // convert-to-left-handed choice alongside the path. - // Apply the SAME flag here so the editable mesh - // stays in the same coordinate system as the - // rendered Ogre buffers — without this, on every - // non-.x asset the vertex / edge / face overlays - // would draw mirrored (X flipped) relative to the - // on-screen geometry. Defaults to true to match - // AssimpToOgreImporter's behaviour for unknown - // origins. - bool convertLH = true; - const Ogre::Any& lhAny = - bindings.getUserAny("qtme.source_convert_lh"); - if (lhAny.has_value()) { - try { - convertLH = Ogre::any_cast(lhAny); - } catch (const Ogre::Exception&) {} - } - // The original importer also caches the source up- - // axis (1 = Y-up, 2 = Z-up). MeshProcessor bakes a - // +90°-around-X rotation into the rendered buffers - // for Z-up assets; we pass `isZup` so the editable - // representation stays in the same basis. Without - // this, FBX/glTF assets that declare Z-up would - // surface vertex overlays rotated 90° relative to - // the on-screen geometry, and a commit would write - // the rotated positions back, silently rotating - // the entity. - bool isZup = false; - const Ogre::Any& upAny = - bindings.getUserAny("qtme.source_up_axis"); - if (upAny.has_value()) { - try { - isZup = (Ogre::any_cast(upAny) == 2); - } catch (const Ogre::Exception&) {} - } - // Resolve aiBone names against the live skeleton so - // the n-gon path emits Ogre bone HANDLES (matching - // MeshProcessor) instead of mesh-local aiBone - // indices. Without this, a topology op on a skinned - // mesh re-emits VertexBoneAssignments with wild - // handles and skinning rebinds vertices to wrong - // bones. - const Ogre::Skeleton* skel = nullptr; - if (meshPtr->hasSkeleton()) { - skel = meshPtr->getSkeleton().get(); - } - if (!sourcePath.empty() - && m_editableMesh->loadFromAssimpFile( - sourcePath, convertLH, isZup, skel)) { - loaded = true; - SentryReporter::addBreadcrumb("edit_mode", - "Edit Mode entered via n-gon import path"); - } - } catch (const Ogre::Exception&) { - // The Any contained something other than a string — - // shouldn't happen but fall through to the legacy - // path defensively. - } - } - } + bool loaded = tryLoadEditableMeshNGonPath(m_editEntity, m_editableMesh.get()); + if (loaded) { + SentryReporter::addBreadcrumb("edit_mode", + "Edit Mode entered via n-gon import path"); } if (!loaded && !m_editableMesh->loadFromEntity(m_editEntity)) { SentryReporter::addBreadcrumb("edit_mode", "Failed to load mesh data for Edit Mode"); @@ -2612,8 +2584,70 @@ bool EditModeController::commitKnife() // BEFORE `_deinitialise/_initialise` so the SubEntity vertex-decl cache // reads the corrected layout, then re-run `applyNormalMapsToEntity` so // RTSS re-attaches SRS_NORMALMAP against the fresh tangents. +namespace { +// Returns true if any sub-entity of `ent` has a normal-map TUS — the +// signal we use to decide whether RTSS will need tangents on the next +// render. Materials that fail to load are skipped so a single broken +// resource doesn't abort the topology op. +bool entityWantsTangents(Ogre::Entity* ent) { + for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { + auto mat = ent->getSubEntity(i)->getMaterial(); + if (!mat) continue; + if (!mat->isLoaded()) { + try { mat->load(); } + catch (const Ogre::Exception&) { continue; } + } + if (mat->getNumTechniques() == 0) continue; + auto* pass = mat->getTechnique(0)->getPass(0); + if (!pass) continue; + for (unsigned short t = 0; t < pass->getNumTextureUnitStates(); ++t) { + const auto& tusName = pass->getTextureUnitState(t)->getName(); + if (tusName == "normal_map" || tusName == "NormalMap") return true; + } + } + return false; +} + +// Force-rebuild tangent vectors on `mesh`. Logs (rather than throws) on +// failure since the caller runs from inside an entity-refresh hook. +void rebuildMeshTangents(const Ogre::MeshPtr& mesh) { + if (!mesh) return; + try { + // storeParityInW=true → VET_FLOAT4, matching what + // RTShaderHelper::applyNormalMap and the import path use. + mesh->buildTangentVectors(/*sourceTexCoordSet=*/0, + /*splitMirrored=*/false, + /*splitRotated=*/false, + /*storeParityInW=*/true); + } catch (const Ogre::Exception& e) { + Ogre::LogManager::getSingleton().logMessage( + "rewriteEntityAfterTopologyChange: buildTangentVectors " + "failed for '" + mesh->getName() + "': " + e.getDescription()); + } +} + +// Drop the cached RTSS shader programs for every sub-entity material +// so the next render regenerates them against the post-topology-op +// vertex declaration. No-op when the ShaderGenerator is absent. +void invalidateEntityRtssMaterials(Ogre::Entity* ent) { + auto* sg = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); + if (!sg) return; + for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { + const std::string& m = ent->getSubEntity(i)->getMaterialName(); + if (!m.empty()) + sg->invalidateMaterial( + Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, m); + } +} +} // namespace + void EditModeController::rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { if (!ent) return; + + // Snapshot per-subentity material overrides — _deinitialise/_initialise + // resets them to the SubMesh default, but the wireframe overlay and + // MaterialEditor write to the SubEntity, not the SubMesh, so both + // must survive the rebuild. std::vector preMats; preMats.reserve(ent->getNumSubEntities()); for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) @@ -2628,54 +2662,15 @@ void EditModeController::rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { // SRS_NORMALMAP code path collapses to a no-op even though the // tangents physically exist in the buffer. // - // We need tangents whenever any subentity is bump-mapped (has a - // `normal_map`/`NormalMap` TUS). Two cases trigger that: - // 1. The mesh declaration ALREADY has VES_TANGENT but the new - // vertices written by `EditableMesh::buildSubMeshBuffers` are - // zero-valued (default-constructed `EditableVertex::tangent`). - // 2. The declaration LOST VES_TANGENT during the buffer rewrite — - // this happens when the n-gon import path (which doesn't - // request `aiProcess_CalcTangentSpace` because it forces - // triangulation) didn't have tangents in the source file, so - // `EditableVertex::hasTangent == false` for every vertex and - // the rebuilt declaration drops the slot entirely. - // Either way `Mesh::buildTangentVectors` is the fix: it adds the - // VES_TANGENT element if missing and (re)computes values from - // positions/normals/UVs. - if (auto mesh = ent->getMesh()) { - bool wantsTangents = false; - for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { - auto mat = ent->getSubEntity(i)->getMaterial(); - if (!mat) continue; - if (!mat->isLoaded()) { - try { mat->load(); } catch (...) {} - } - if (mat->getNumTechniques() == 0) continue; - auto* pass = mat->getTechnique(0)->getPass(0); - if (!pass) continue; - for (unsigned short t = 0; t < pass->getNumTextureUnitStates(); ++t) { - const auto& tusName = - pass->getTextureUnitState(t)->getName(); - if (tusName == "normal_map" || tusName == "NormalMap") { - wantsTangents = true; - break; - } - } - if (wantsTangents) break; - } - if (wantsTangents) { - try { - // storeParityInW=true → VET_FLOAT4, matching what - // RTShaderHelper::applyNormalMap and the import path use. - mesh->buildTangentVectors( - Ogre::VES_TANGENT, 0, 0, false, false, true); - } catch (const Ogre::Exception& e) { - Ogre::LogManager::getSingleton().logMessage( - "rewriteEntityAfterTopologyChange: buildTangentVectors " - "failed for '" + mesh->getName() + "': " + e.getDescription()); - } - } - } + // We rebuild whenever any subentity is bump-mapped: new vertices + // written by `EditableMesh::buildSubMeshBuffers` start with zero- + // valued tangents (EditableVertex defaults), and on the n-gon + // import path — which omits `aiProcess_CalcTangentSpace` because + // that flag forces triangulation — the declaration drops VES_TANGENT + // entirely. `Mesh::buildTangentVectors` re-adds the element if + // missing and recomputes values from positions/normals/UVs. + if (entityWantsTangents(ent)) + rebuildMeshTangents(ent->getMesh()); // Re-init the entity so its SubEntity caches (vertex / index // counts, skeleton anim buffers) sync to the resized mesh. @@ -2690,31 +2685,15 @@ void EditModeController::rewriteEntityAfterTopologyChange(Ogre::Entity* ent) { ent->getSubEntity(i)->setMaterialName(preMats[i]); } - // Re-attach the RTSS SRS_NORMALMAP sub-render-state per material that - // has a normal-map TUS. `invalidateMaterial` alone only drops the - // cached shader programs; the renderState's template list is - // preserved so the regenerated shader would normally inherit - // SRS_NORMALMAP — except `applyNormalMap` uses - // `removeShaderBasedTechnique + createShaderBasedTechnique` to start - // from a clean slate (some tangent-related state in the render state - // doesn't survive a buffer-format change), so we must run it again - // to guarantee the SRS_NORMALMAP is re-bound against the new - // tangents. Materials without a normal-map TUS are no-ops here. + // Re-attach the RTSS SRS_NORMALMAP sub-render-state per material + // that has a normal-map TUS, then drop any cached shader programs + // so the next render regenerates against the new vertex layout. + // `applyNormalMapsToEntity` uses `removeShaderBasedTechnique + + // createShaderBasedTechnique` to start from a clean slate (some + // tangent-related state doesn't survive a buffer-format change), + // so it must run AFTER the tangent rebuild + _initialise. MeshImporterExporter::applyNormalMapsToEntity(ent); - - // Final invalidate so any per-material cache that survived the - // applyNormalMap path drops too. validateMaterial inside - // applyNormalMap already kicks shader regen, but explicit - // invalidate keeps behaviour identical to other topology ops for - // materials that aren't bump-mapped. - if (auto* sg = Ogre::RTShader::ShaderGenerator::getSingletonPtr()) { - for (unsigned int i = 0; i < ent->getNumSubEntities(); ++i) { - const std::string& m = ent->getSubEntity(i)->getMaterialName(); - if (!m.empty()) - sg->invalidateMaterial( - Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, m); - } - } + invalidateEntityRtssMaterials(ent); } // Find global vertex indices of the survivor positions after toEditableMesh From c7da02ca6a40f766009da3d7828d374de59dcc44 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Apr 2026 23:14:37 -0400 Subject: [PATCH 22/34] fix(quads): knife restores n-gon faces on untouched submeshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 on PR #337: the knife workaround cleared `.faces` on EVERY submesh before building the half-edge mesh, so after `toEditableMesh` the write-back was triangle-only for the whole mesh — a single knife action could silently convert unrelated submeshes to fan triangles. Fix: snapshot `wasNGonSub[]` (which submeshes were originally n-gon- canonical) before the clear. After cutPath, walk the returned `cutVerts` and collect the set of submeshes the cut actually touched (via `facesAroundVertex` + `face.subMeshIndex`). For every submesh that was originally n-gon AND wasn't touched, restore it verbatim from `originalSubMeshes` before assigning back. Touched submeshes keep the post-cut triangulation (the workaround the PR already documented), and untouched submeshes preserve their quad topology. --- src/EditModeController.cpp | 40 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index d48c82d3e..b13a2ec50 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -2487,8 +2487,17 @@ bool EditModeController::commitKnife() // submesh the cut touches. Tracked as a follow-up. EditableMesh triOnly; triOnly.subMeshes() = m_editableMesh->subMeshes(); - for (auto& sub : triOnly.subMeshes()) + // Remember which submeshes were originally n-gon-canonical + // (`!faces.empty()`) so we can restore them post-write-back. Without + // this, a knife on submesh 0 would convert every untouched submesh + // to fan triangles globally (since toEditableMesh writes back from + // the all-triangulated HE). + std::vector wasNGonSub; + wasNGonSub.reserve(triOnly.subMeshes().size()); + for (auto& sub : triOnly.subMeshes()) { + wasNGonSub.push_back(!sub.faces.empty()); sub.faces.clear(); + } HalfEdgeMesh hm; if (!hm.buildFromEditableMesh(triOnly)) { cancelKnife(); @@ -2561,7 +2570,34 @@ bool EditModeController::commitKnife() cancelKnife(); return false; } - m_editableMesh->subMeshes() = std::move(updated.subMeshes()); + + // Determine which submeshes the cut actually touched. A cut creates + // new vertices either at edge clicks or at interior crossings; each + // new vertex lives on faces in the submesh(es) it pierced. + // toEditableMesh writes EVERY submesh as triangle-only (since the + // HE was triangulated up-front), so without restoring untouched + // n-gon submeshes back from the original snapshot, a single knife + // op would silently convert unrelated submeshes to fan triangles. + // (Codex P1 review on this PR.) + std::set touchedSubs; + for (int v : cutVerts) { + for (int f : hm.facesAroundVertex(v)) { + touchedSubs.insert(hm.face(f).subMeshIndex); + } + } + auto& outSubs = updated.subMeshes(); + for (size_t s = 0; + s < outSubs.size() && s < originalSubMeshes.size() && s < wasNGonSub.size(); + ++s) { + if (touchedSubs.count(static_cast(s))) continue; + if (!wasNGonSub[s]) continue; // already triangle-only — nothing to restore + // Untouched + originally n-gon → restore the original submesh + // verbatim. resizeEntityBuffers consumes both `triangles` and + // bone assignments, so we want the full pre-cut state. + outSubs[s] = originalSubMeshes[s]; + } + + m_editableMesh->subMeshes() = std::move(outSubs); m_editableMesh->resizeEntityBuffers(m_editEntity); rewriteEntityAfterTopologyChange(m_editEntity); From 7ac44be72dce7ea979bccd75771ac09f8c43dde2 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 29 Apr 2026 02:39:28 -0400 Subject: [PATCH 23/34] feat(quads): n-gon-aware splitEdge; extrude offset for n-gon caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes that drop the triangle-only workarounds the knife and extrude paths needed on quad-imported meshes. splitEdge now n-gon-aware Previously a triangle-only MVP: bailed when either adjacent face had arity != 3, so the knife (which builds on splitEdge → cutPath) had to convert the entire mesh to triangles up-front via a build-from- triangle-copy hack and restore untouched n-gon submeshes after. New behaviour: replace each adjacent face with ONE face that has vMid inserted between the shared edge's endpoints. A triangle becomes a quad, a quad becomes a pentagon, etc. — no fan diagonal. Contract change: two splitEdges on the same triangle no longer produce the m1↔m2 edge as a side-effect (they used to, via the vMid→vOpp diagonal in the old triangle-only code). Callers that want the cut materialised must call splitFace explicitly. cutPath's walk loop does this on every step now: when the next click vertex lands on a face that already contains the previous one, splitFace produces the connecting edge. Knife pipeline simplified Both `commitKnife` and `knifeHitTest` now build the HE directly from the (possibly n-gon) editable mesh — no more triangle-mode copy, no more touched-submesh restore dance. The knife produces real n-gon outputs on quad-imported assets and triangulation only happens on the faces the cut actually crosses (via splitFace, which already handled n-gons). splitFace also drops its `n > 4` cap. Extrude offset now n-gon-aware `extrudeSelection`'s per-vertex offset computation walked adjacent faces with `if (verts.size() != 3) continue;`, so on a quad- imported mesh the offset was zero — and the post-extrude selection- by-position then matched the OLD un-offset coords, leaving the user with the pre-extrude vertices selected instead of the new cap. Replace the triangle cross-product with Newell's method and accept any face arity ≥ 3. "Top face" detection switches from "is a triangle and all 3 verts are new" to "all N verts are new" so n-gon caps contribute correctly. The extruded cap now offsets along its averaged Newell normal, the position search finds the new vertices, and selection lands on the cap as expected. Tests - Updated SplitEdgeMidpointOfInteriorEdge → expects 2 quads (was 4 triangles); SplitEdgeBoundaryEdge → expects 1 quad (was 2 tris). - Updated TwoSplitEdgesOnOneTriangle to document the new contract: splitEdge inserts vMid into the loop, splitFace materialises the cut. - New SplitEdgeOnQuadMeshKeepsQuads: locks down "splitEdge on a quad's edge yields a pentagon, not 4 triangles" so future regressions can't reintroduce fan diagonals. - 235 standalone tests pass. Smoke-tested on FBX quad asset: knife cuts produce real n-gon outputs on the touched faces only, untouched submeshes keep their quads, extrude moves the new cap and selects the new vertices. --- src/EditModeController.cpp | 115 ++++++++------------ src/HalfEdgeMesh.cpp | 212 ++++++++++++++++++++++--------------- src/HalfEdgeMesh_test.cpp | 129 ++++++++++++++++++---- 3 files changed, 282 insertions(+), 174 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index b13a2ec50..051646201 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -1572,10 +1572,33 @@ bool EditModeController::extrudeSelection() if (newHEVertices.empty()) return false; - // Offset new vertices slightly along adjacent face normals so side-wall - // triangles have non-zero area (avoids NaN normals and bad shading). + // Offset new vertices slightly along the average normal of the + // top faces (the ones whose vertices are ALL new — i.e. the + // extruded caps). Side-wall faces, which mix old and new verts, + // are skipped so the offset only pushes the cap outward. Without + // this, the side-wall faces have zero area and shading goes bad. + // + // n-gon-aware: use Newell's method instead of the triangle-only + // cross product so quad / pentagon caps (the result of extruding + // an n-gon face) contribute correctly. The triangle path was a + // strict `verts.size() != 3` skip, which made every offset zero + // on quad-imported assets — and selection-by-position then + // matched the OLD un-offset vertex coords, leaving the user with + // the pre-extrude vertices selected. const float EXTRUDE_OFFSET = 0.01f; std::set newVertSet(newHEVertices.begin(), newHEVertices.end()); + auto newellNormal = [&](const std::vector& verts) { + Ogre::Vector3 nrm = Ogre::Vector3::ZERO; + const size_t N = verts.size(); + for (size_t i = 0; i < N; ++i) { + const auto& a = heMesh.vertex(verts[i]).position; + const auto& b = heMesh.vertex(verts[(i + 1) % N]).position; + nrm.x += (a.y - b.y) * (a.z + b.z); + nrm.y += (a.z - b.z) * (a.x + b.x); + nrm.z += (a.x - b.x) * (a.y + b.y); + } + return nrm; + }; std::map offsets; for (int heVert : newHEVertices) { auto adjFaces = heMesh.facesAroundVertex(heVert); @@ -1583,13 +1606,14 @@ bool EditModeController::extrudeSelection() int count = 0; for (int fi : adjFaces) { auto verts = heMesh.faceVertices(fi); - if (verts.size() != 3) continue; - if (!newVertSet.count(verts[0]) || !newVertSet.count(verts[1]) || !newVertSet.count(verts[2])) - continue; - Ogre::Vector3 v0 = heMesh.vertex(verts[0]).position; - Ogre::Vector3 v1 = heMesh.vertex(verts[1]).position; - Ogre::Vector3 v2 = heMesh.vertex(verts[2]).position; - Ogre::Vector3 n = (v1 - v0).crossProduct(v2 - v0); + if (verts.size() < 3) continue; + // Top face = every vertex is in the new-vertex set. + bool allNew = true; + for (int v : verts) { + if (!newVertSet.count(v)) { allNew = false; break; } + } + if (!allNew) continue; + Ogre::Vector3 n = newellNormal(verts); if (n.length() > 1e-8f) { n.normalise(); avgNormal += n; @@ -2476,30 +2500,13 @@ bool EditModeController::commitKnife() // of mesh edges. OnFace/OnVertex clicks aren't yet in scope — the // commit skips them and proceeds on the OnEdge subset. // - // splitEdge — the primitive cutPath uses — is a triangle-only MVP - // (n-gon support requires ear-clip-aware rewiring). On a quad- - // imported mesh, every face is an n-gon and splitEdge bails - // immediately, so knife silently fails. Workaround until a proper - // n-gon splitEdge lands: build the HE from a triangle-mode COPY - // (clear `.faces` so buildFromEditableMesh falls back to the - // fan-triangulated `.triangles` mirror). The user gets a working - // knife at the cost of materialising the fan diagonals on every - // submesh the cut touches. Tracked as a follow-up. - EditableMesh triOnly; - triOnly.subMeshes() = m_editableMesh->subMeshes(); - // Remember which submeshes were originally n-gon-canonical - // (`!faces.empty()`) so we can restore them post-write-back. Without - // this, a knife on submesh 0 would convert every untouched submesh - // to fan triangles globally (since toEditableMesh writes back from - // the all-triangulated HE). - std::vector wasNGonSub; - wasNGonSub.reserve(triOnly.subMeshes().size()); - for (auto& sub : triOnly.subMeshes()) { - wasNGonSub.push_back(!sub.faces.empty()); - sub.faces.clear(); - } + // splitEdge is now n-gon-aware: it inserts vMid into each adjacent + // face's loop without introducing a fan diagonal. cutPath's walk + // calls splitFace explicitly to materialise the cut between + // consecutive vertices. So we can build the HE directly from the + // (possibly n-gon) editable mesh — no triangle-mode workaround. HalfEdgeMesh hm; - if (!hm.buildFromEditableMesh(triOnly)) { + if (!hm.buildFromEditableMesh(*m_editableMesh)) { cancelKnife(); return false; } @@ -2570,34 +2577,7 @@ bool EditModeController::commitKnife() cancelKnife(); return false; } - - // Determine which submeshes the cut actually touched. A cut creates - // new vertices either at edge clicks or at interior crossings; each - // new vertex lives on faces in the submesh(es) it pierced. - // toEditableMesh writes EVERY submesh as triangle-only (since the - // HE was triangulated up-front), so without restoring untouched - // n-gon submeshes back from the original snapshot, a single knife - // op would silently convert unrelated submeshes to fan triangles. - // (Codex P1 review on this PR.) - std::set touchedSubs; - for (int v : cutVerts) { - for (int f : hm.facesAroundVertex(v)) { - touchedSubs.insert(hm.face(f).subMeshIndex); - } - } - auto& outSubs = updated.subMeshes(); - for (size_t s = 0; - s < outSubs.size() && s < originalSubMeshes.size() && s < wasNGonSub.size(); - ++s) { - if (touchedSubs.count(static_cast(s))) continue; - if (!wasNGonSub[s]) continue; // already triangle-only — nothing to restore - // Untouched + originally n-gon → restore the original submesh - // verbatim. resizeEntityBuffers consumes both `triangles` and - // bone assignments, so we want the full pre-cut state. - outSubs[s] = originalSubMeshes[s]; - } - - m_editableMesh->subMeshes() = std::move(outSubs); + m_editableMesh->subMeshes() = std::move(updated.subMeshes()); m_editableMesh->resizeEntityBuffers(m_editEntity); rewriteEntityAfterTopologyChange(m_editEntity); @@ -3647,18 +3627,11 @@ bool EditModeController::knifeHitTest(const QPoint& screenPos, OgreWidget* widge rD = worldToLocal.linear() * rD; } - // Build the HE from a triangle-mode COPY of the editable mesh, - // mirroring what the knife commit path does. Otherwise the HE - // edge indices we record on the click here (n-gon HE: one edge - // per polygon side) wouldn't match the indices `commitKnife` - // resolves against (triangle HE: one edge per fan side), and - // every cut would land on the wrong edge — manifesting as a - // knife that "does nothing" on quad-imported assets. - EditableMesh triOnly; - triOnly.subMeshes() = m_editableMesh->subMeshes(); - for (auto& sub : triOnly.subMeshes()) sub.faces.clear(); + // Build the HE directly from the (possibly n-gon) editable mesh. + // splitEdge is now n-gon-aware so commitKnife uses the same + // build, and edge indices line up between hit-test and commit. HalfEdgeMesh tmp; - if (tmp.buildFromEditableMesh(triOnly)) { + if (tmp.buildFromEditableMesh(*m_editableMesh)) { constexpr float kPixelRadius = 10.0f; float bestDepth = std::numeric_limits::infinity(); int bestEdgeIdx = -1; diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 22e490c1f..99556e672 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -3714,56 +3714,55 @@ int HalfEdgeMesh::splitEdge(int edgeIdx, float t) constexpr float kEps = 1e-4f; t = std::clamp(t, kEps, 1.0f - kEps); - // Collect the two adjacent triangle descriptions while the old - // topology is still intact. Both faces must be triangles for this - // MVP; n-gon splitting needs ear-clip-aware rewiring. - struct TriFace { - int face; // face index (for submesh lookup) - int subMeshIndex; - int vFrom; // edge endpoint (start) - int vTo; // edge endpoint (end) - int vOpp; // third triangle vertex - int heFrom, heTo, heOpp; // the three half-edges of the triangle + // Describe one adjacent face: capture its vertex loop, the index + // of the shared edge's start vertex within that loop, and the + // submesh. The shared edge runs (loop[shareIdx] → loop[shareIdx+1]) + // in this face's winding direction. n-gon-aware: any face arity + // ≥ 3 is supported. (The previous triangle-only MVP introduced fan + // diagonals on quads — see commit history.) + struct LoopFace { + int face = -1; + int subMeshIndex = 0; + std::vector verts; // face loop in winding order + int shareIdx = -1; // verts[shareIdx] == vFrom of shared edge }; - auto describeFace = [&](int he, TriFace& out) -> bool { + auto describeFace = [&](int he, LoopFace& out) -> bool { if (he < 0 || he >= static_cast(m_halfEdges.size())) return false; if (m_halfEdges[he].face < 0) return false; const int fIdx = m_halfEdges[he].face; - const auto verts = faceVertices(fIdx); - if (verts.size() != 3) return false; - - // Identify which HE of the triangle is the one carrying `edgeIdx`. - // Traverse the face loop starting at the face's HE; the HE pointing - // "to" heVertex of `he` is the one sharing edgeIdx. Fill TriFace - // in face-loop order so we keep winding. + if (fIdx >= static_cast(m_faces.size())) return false; const int startHE = m_faces[fIdx].halfEdge; - int he0 = startHE; - int he1 = m_halfEdges[he0].next; - int he2 = m_halfEdges[he1].next; - int heIndices[3] = {he0, he1, he2}; - - int shareIdx = -1; - for (int i = 0; i < 3; ++i) { - if (heIndices[i] == he) { shareIdx = i; break; } - } - if (shareIdx < 0) return false; - + if (startHE < 0) return false; + + // Walk the face HE ring; `loop[i]` = the "to" vertex of the i-th + // HE in face winding order. The HE found at position `pos` points + // TO `loop[pos]`, so the shared edge runs from `loop[pos-1]` to + // `loop[pos]` in this face's winding. We record the loop and the + // "from" position (pos-1) so insertions can place vMid right + // after that vertex. + std::vector loop; + int pos = -1; + int cursor = startHE; + int i = 0; + do { + if (cursor == he) pos = i; + loop.push_back(m_halfEdges[cursor].vertex); + cursor = m_halfEdges[cursor].next; + ++i; + if (i > 1024) return false; // runaway guard + } while (cursor != startHE); + if (pos < 0 || loop.size() < 3) return false; + + const int n = static_cast(loop.size()); out.face = fIdx; out.subMeshIndex = m_faces[fIdx].subMeshIndex; - out.heFrom = heIndices[shareIdx]; - out.heTo = heIndices[(shareIdx + 1) % 3]; - out.heOpp = heIndices[(shareIdx + 2) % 3]; - - // Vertex layout: heFrom.prev.vertex -> heFrom.vertex -> heTo.vertex. - // The shared edge goes vFrom -> vTo. The third vertex is heTo.vertex. - out.vFrom = m_halfEdges[out.heOpp].vertex; - out.vTo = m_halfEdges[out.heFrom].vertex; - out.vOpp = m_halfEdges[out.heTo].vertex; + out.verts = std::move(loop); + out.shareIdx = (pos - 1 + n) % n; // position of edge's "from" vertex return true; }; - TriFace fA{}, fB{}; + LoopFace fA{}, fB{}; const bool hasA = describeFace(heA, fA); const int heB = m_halfEdges[heA].twin; const bool hasValidB = (heB >= 0); @@ -3771,50 +3770,71 @@ int HalfEdgeMesh::splitEdge(int edgeIdx, float t) if (!hasA && !hasB) return -1; - // splitEdge is a triangle-only MVP: if either adjacent face exists - // and isn't a triangle, bail before we mutate. Partially splitting - // would desync the other side's half-edge pointers against a face - // that's still an n-gon, producing silent topology corruption. - // (A face "exists" here means its twin is non-boundary. Boundary - // half-edges — face == -1 — are fine.) - if (!hasA && m_halfEdges[heA].face >= 0) return -1; - if (!hasB && hasValidB && m_halfEdges[heB].face >= 0) return -1; - - // Edge direction is fA.vFrom -> fA.vTo (with t measured from vFrom). - // If only fB exists (the edge sits on the boundary on the A side), - // fB.vFrom / fB.vTo run in the opposite direction, so re-measure t. - int vFrom = hasA ? fA.vFrom : fB.vTo; - int vTo = hasA ? fA.vTo : fB.vFrom; - - // Create the midpoint vertex. + // Edge direction is fA.verts[shareIdx] → fA.verts[shareIdx+1] (with t + // measured from vFrom in this direction). If only fB exists (the edge + // sits on the boundary on the A side), fB winds the opposite way, so + // we read vFrom from its loop directly without flipping t. + auto edgeStart = [](const LoopFace& f) { + return f.verts[f.shareIdx]; + }; + auto edgeEnd = [](const LoopFace& f) { + return f.verts[(f.shareIdx + 1) % f.verts.size()]; + }; + int vFrom, vTo; + if (hasA) { + vFrom = edgeStart(fA); + vTo = edgeEnd(fA); + } else { + // fB winds opposite, so its "start of shared edge" is our vTo. + vFrom = edgeEnd(fB); + vTo = edgeStart(fB); + } + + // Create the midpoint vertex from the linearly-interpolated endpoint + // attributes (positions, normals, UVs, bone weights, tangents). HEVertex mid = interpolateVertex(m_vertices[vFrom], m_vertices[vTo], t); mid.halfEdge = -1; const int vMid = static_cast(m_vertices.size()); m_vertices.push_back(std::move(mid)); - // Retire a face: set each of its half-edges' face to -1 and clear the - // face slot's halfEdge pointer so the four-call cleanup drops them. - auto retireFace = [&](const TriFace& tf) { - m_halfEdges[tf.heFrom].face = -1; - m_halfEdges[tf.heTo].face = -1; - m_halfEdges[tf.heOpp].face = -1; - m_faces[tf.face].halfEdge = -1; + // Retire a face: walk its HE loop and clear face pointers so the + // four-call cleanup drops them. Generic over face arity. + auto retireFace = [&](int fIdx) { + if (fIdx < 0 || fIdx >= static_cast(m_faces.size())) return; + const int startHE = m_faces[fIdx].halfEdge; + if (startHE < 0) return; + int he = startHE; + do { + const int next = m_halfEdges[he].next; + m_halfEdges[he].face = -1; + he = next; + } while (he != startHE && he >= 0); + m_faces[fIdx].halfEdge = -1; + }; + + // Replace each adjacent face with ONE new face that has vMid + // inserted between the shared edge's endpoints. A triangle becomes + // a quad, a quad becomes a pentagon, etc. — no artificial fan + // diagonal. This is the change that lets knife / loop cut produce + // real quads instead of fan-triangulating the affected faces. + auto rebuildWithMid = [&](const LoopFace& f) { + std::vector loop; + loop.reserve(f.verts.size() + 1); + const int n = static_cast(f.verts.size()); + for (int i = 0; i < n; ++i) { + loop.push_back(f.verts[i]); + if (i == f.shareIdx) loop.push_back(vMid); + } + appendFace(loop, f.subMeshIndex); }; if (hasA) { - retireFace(fA); - // Old triangle [vFrom, vTo, vOpp] wound as - // heOpp: vOpp -> vFrom, heFrom: vFrom -> vTo, heTo: vTo -> vOpp. - // New triangles share the vMid → vOpp diagonal and keep the same winding. - appendFace({fA.vFrom, vMid, fA.vOpp}, fA.subMeshIndex); - appendFace({vMid, fA.vTo, fA.vOpp}, fA.subMeshIndex); + retireFace(fA.face); + rebuildWithMid(fA); } if (hasB) { - retireFace(fB); - // fB's winding is reversed relative to fA (opposite twin). Using - // fB's own vFrom/vTo keeps its orientation intact. - appendFace({fB.vFrom, vMid, fB.vOpp}, fB.subMeshIndex); - appendFace({vMid, fB.vTo, fB.vOpp}, fB.subMeshIndex); + retireFace(fB.face); + rebuildWithMid(fB); } rebuildEdgesAndTwins(); @@ -3834,7 +3854,7 @@ bool HalfEdgeMesh::splitFace(int faceIdx, int vA, int vB) if (m_faces[faceIdx].halfEdge < 0) return false; const auto verts = faceVertices(faceIdx); - if (verts.size() < 3 || verts.size() > 4) return false; + if (verts.size() < 3) return false; // n-gon-aware: any arity ≥ 3 // Require both vA and vB to be on the boundary loop. int posA = -1; @@ -4013,19 +4033,26 @@ std::vector HalfEdgeMesh::cutPath(const std::vector& points) for (int step = 0; step < kMaxWalkSteps; ++step) { if (vA == vTarget) break; - // Does vA already share a triangle with vTarget? Then the - // existing splitEdge semantics (two click splits on one tri - // produce the connecting edge automatically) already created - // the final cut edge — nothing more to do for this pair. + // Does vA already share a face with vTarget? In the n-gon- + // aware splitEdge era, each splitEdge inserts vMid into the + // adjacent face WITHOUT cutting it — so when two consecutive + // click vertices land on the same face, the connecting edge + // doesn't exist yet. Call splitFace explicitly to cut the + // shared face along the vA→vTarget diagonal. (Previously + // the triangle-only splitEdge produced the cut as a side- + // effect, but that introduced fan diagonals everywhere.) const auto facesAtA = facesAroundVertex(vA); - bool shareFace = false; + int sharedFace = -1; for (int f : facesAtA) { const auto fv = faceVertices(f); if (std::find(fv.begin(), fv.end(), vTarget) != fv.end()) { - shareFace = true; break; + sharedFace = f; break; } } - if (shareFace) break; + if (sharedFace >= 0) { + splitFace(sharedFace, vA, vTarget); + break; + } const Ogre::Vector3 pA = m_vertices[vA].position; const Ogre::Vector3 pT = m_vertices[vTarget].position; @@ -4075,13 +4102,28 @@ std::vector HalfEdgeMesh::cutPath(const std::vector& points) (void)bestFace; // Split the exit edge; the new vertex becomes the entry point - // for the next triangle. Because the previous iteration's vA - // and the new vertex now both live on the same triangle, the - // splitEdge pass already leaves the segment between them as a - // real mesh edge. + // for the next face. n-gon-aware splitEdge inserts vMid into + // each adjacent face's loop WITHOUT cutting it (no fan + // diagonals), so we explicitly call splitFace to materialise + // the cut on the face we just walked through. The face that + // contains BOTH vA and vMid post-splitEdge is the one that + // needs cutting; there will be exactly one such face since + // vMid was just inserted into the two faces adjacent to + // `bestEdge`, and only one of those (the one we walked + // through) contains vA. const int vMid = splitEdge(bestEdge, bestT); if (vMid < 0) break; newVertices.push_back(vMid); + + int faceToCut = -1; + for (int f : facesAroundVertex(vA)) { + const auto fv = faceVertices(f); + if (std::find(fv.begin(), fv.end(), vMid) != fv.end()) { + faceToCut = f; break; + } + } + if (faceToCut >= 0) splitFace(faceToCut, vA, vMid); + vA = vMid; } } diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index 3599a4e84..e6c88e143 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -3123,10 +3123,14 @@ static int activeFaceCount(const HalfEdgeMesh& he) return n; } -TEST(HalfEdgeMeshStandalone, SplitEdgeMidpointOfInteriorEdgeDoublesTriangles) { - // The quad mesh has two triangles sharing the v1↔v2 diagonal. Splitting - // that edge at t=0.5 should insert one new vertex, replace both triangles - // with two each (four tris total), and keep the mesh manifold. +TEST(HalfEdgeMeshStandalone, SplitEdgeMidpointOfInteriorEdgeInsertsVertexInBothFaces) { + // n-gon-aware splitEdge inserts vMid into each adjacent face's vertex + // loop without introducing a fan diagonal: a triangle adjacent to the + // split edge becomes a 4-vertex face (NOT two triangles). The quad + // mesh's two triangles each gain vMid, resulting in 2 active 4-vertex + // faces. (Pre-quads-followup this returned 4 triangles via fan + // diagonals on `vMid → vOpp`; the new behaviour preserves more + // topology and produces no artificial diagonals.) auto em = makeQuadMesh(); HalfEdgeMesh he; ASSERT_TRUE(he.buildFromEditableMesh(em)); @@ -3139,7 +3143,17 @@ TEST(HalfEdgeMeshStandalone, SplitEdgeMidpointOfInteriorEdgeDoublesTriangles) { EXPECT_TRUE(he.validate()); EXPECT_EQ(he.vertexCount(), 5u); - EXPECT_EQ(activeFaceCount(he), 4); + EXPECT_EQ(activeFaceCount(he), 2); + // Each surviving face should have 4 vertices now (the original + // triangle plus vMid inserted on the shared edge). + int quadCount = 0; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + auto fv = he.faceVertices(static_cast(f)); + if (fv.size() == 4) ++quadCount; + } + EXPECT_EQ(quadCount, 2) + << "n-gon-aware splitEdge: each adjacent triangle gains vMid → quad"; // Midpoint position should be the average of the endpoints. const auto mid = he.vertex(vMid).position; @@ -3195,10 +3209,11 @@ TEST(HalfEdgeMeshStandalone, SplitEdgeInterpolatesNormalAndUV) { EXPECT_NEAR(std::min(uvDist1, uvDist2) / uvSeg, 0.25f, 1e-3f); } -TEST(HalfEdgeMeshStandalone, SplitEdgeBoundaryEdgeProducesOneExtraTriangle) { - // A triangle's perimeter edge is a boundary edge (only one adjacent face). - // Splitting it should turn the single triangle into two triangles without - // creating any phantom face on the outside. +TEST(HalfEdgeMeshStandalone, SplitEdgeBoundaryEdgeInsertsVertexInTheTriangle) { + // A triangle's perimeter edge is a boundary edge (only one adjacent + // face). n-gon-aware splitEdge: the triangle gains vMid on its + // perimeter, becoming a 4-vertex face. No phantom face on the outside. + // (Pre-quads-followup this returned two triangles via a fan diagonal.) auto em = makeTriangleMesh(); HalfEdgeMesh he; ASSERT_TRUE(he.buildFromEditableMesh(em)); @@ -3212,7 +3227,14 @@ TEST(HalfEdgeMeshStandalone, SplitEdgeBoundaryEdgeProducesOneExtraTriangle) { EXPECT_TRUE(he.validate()); EXPECT_EQ(he.vertexCount(), 4u); - EXPECT_EQ(activeFaceCount(he), 2); + EXPECT_EQ(activeFaceCount(he), 1); + // The single surviving face must be a quad (3 original verts + vMid). + int quadCount = 0; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + if (he.faceVertices(static_cast(f)).size() == 4) ++quadCount; + } + EXPECT_EQ(quadCount, 1); } TEST(HalfEdgeMeshStandalone, SplitEdgeClampsExtremeT) { @@ -3243,12 +3265,15 @@ TEST(HalfEdgeMeshStandalone, SplitEdgeInvalidIndexReturnsMinusOne) { EXPECT_EQ(he.splitEdge(999, 0.5f), -1); } -TEST(HalfEdgeMeshStandalone, TwoSplitEdgesOnOneTriangleProduceMidpointEdge) { - // Real knife-tool scenario: the user cuts a triangle by clicking two of - // its edges. Two splitEdge calls on the same triangle should leave the - // M1↔M2 segment as a real edge of the resulting mesh — no follow-up - // splitFace needed. This is the invariant the knife commit pipeline - // relies on for the common "cut one face" case. +TEST(HalfEdgeMeshStandalone, TwoSplitEdgesOnOneTriangleNeedFollowupSplitFace) { + // n-gon-aware splitEdge: two splitEdge calls on the same triangle + // insert m1 and m2 into its loop, producing a pentagon + // [v0, m1, v1, m2, v2] — but they are NOT automatically connected. + // (The triangle-only splitEdge MVP previously cut the triangle into + // sub-triangles via fan diagonals, which incidentally produced the + // m1-m2 edge as a side-effect.) Call splitFace explicitly to + // materialise the cut. This is exactly what `cutPath` now does in + // its walk loop. auto em = makeTriangleMesh(); HalfEdgeMesh he; ASSERT_TRUE(he.buildFromEditableMesh(em)); @@ -3266,15 +3291,83 @@ TEST(HalfEdgeMeshStandalone, TwoSplitEdgesOnOneTriangleProduceMidpointEdge) { ASSERT_GE(m2, 0); EXPECT_TRUE(he.validate()); - // The knife cut ran from M1 to M2; that segment must now be an edge. + // After two splitEdges the triangle is now a pentagon; m1 and m2 + // are NOT yet connected — that's the new contract. + EXPECT_LT(findEdge(he, m1, m2), 0) + << "n-gon splitEdge does not auto-cut: explicit splitFace required"; + EXPECT_EQ(activeFaceCount(he), 1); + + // Find the pentagon and call splitFace to materialise the cut. + int pentagonIdx = -1; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + const auto fv = he.faceVertices(static_cast(f)); + if (fv.size() == 5 + && std::find(fv.begin(), fv.end(), m1) != fv.end() + && std::find(fv.begin(), fv.end(), m2) != fv.end()) { + pentagonIdx = static_cast(f); + break; + } + } + ASSERT_GE(pentagonIdx, 0); + EXPECT_TRUE(he.splitFace(pentagonIdx, m1, m2)); EXPECT_GE(findEdge(he, m1, m2), 0) - << "M1-M2 should be a real edge after two splitEdges on the same triangle"; + << "splitFace must materialise the m1-m2 cut on the pentagon"; EditableMesh back; ASSERT_TRUE(he.toEditableMesh(back)); EXPECT_TRUE(isManifold(back)); } +TEST(HalfEdgeMeshStandalone, SplitEdgeOnQuadMeshKeepsQuads) { + // Regression for the n-gon-aware splitEdge: split a quad-imported + // mesh's edge and confirm both adjacent quads become pentagons + // (NOT four triangles via fan diagonals as the old MVP would have + // produced). This is the topology guarantee knife / loop cut + // depend on for producing real quad outputs on quad-imported + // assets. + EditableMesh mesh; + EditableSubMesh sub; + sub.materialName = "M"; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + // Two adjacent quads sharing the v1-v2 edge. + sub.vertices = { mkV(0, 0), mkV(1, 0), mkV(1, 1), mkV(0, 1), + mkV(2, 0), mkV(2, 1) }; + EditableFace q1, q2; + q1.indices = {0, 1, 2, 3}; + q2.indices = {1, 4, 5, 2}; + sub.faces = { q1, q2 }; + triangulateFaces(sub); + mesh.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(mesh)); + ASSERT_EQ(activeFaceCount(he), 2); + + const int sharedEdge = findEdge(he, 1, 2); + ASSERT_GE(sharedEdge, 0); + + const int vMid = he.splitEdge(sharedEdge, 0.5f); + ASSERT_GE(vMid, 0); + EXPECT_TRUE(he.validate()); + + // Both quads should now be pentagons (5-vertex faces). NOT four + // triangles — that would mean fan diagonals slipped in. + EXPECT_EQ(activeFaceCount(he), 2); + int pentagonCount = 0; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + if (he.faceVertices(static_cast(f)).size() == 5) ++pentagonCount; + } + EXPECT_EQ(pentagonCount, 2) + << "n-gon-aware splitEdge: quad + new vertex on shared edge → pentagon"; +} + TEST(HalfEdgeMeshStandalone, SplitFaceRejectsAdjacentBoundaryVertices) { // A "diagonal" between two vertices that are already edge-adjacent on a // face boundary would duplicate the existing edge. splitFace should From 45b61524afa7d16f951ac17db8831dab1dded3d2 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 29 Apr 2026 03:15:08 -0400 Subject: [PATCH 24/34] feat(quads): n-gon-aware vertex/edge dissolve + merge cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bug fixes the FBX quad asset surfaced after the n-gon splitEdge work landed. dissolveEdges merges into a single n-gon Was a triangle-only MVP: bailed when either adjacent face had arity != 3, so the whole op was a no-op on quad-imported meshes. Now walks both face loops, removes the shared edge endpoints' duplicate contributions, and appends ONE merged n-gon face. Triangles+quads still merge cleanly; quad+quad → hexagon; etc. dissolveVertices replaces the umbrella with a single n-gon Same fix — was triangle-only (line bailed on `verts.size() != 3` and used hard-coded 3-element index arithmetic). Now collects each incident face's "non-v" boundary contribution (n-1 verts in winding order), chains them into a closed loop, and replaces the umbrella with one n-gon face. Hex fan center now collapses to a single hexagon (was 4 fan triangles). mergeVertices cleans up degenerate corners on n-gon faces Cleanup pass had `if (verts.size() != 3) continue;` — so a quad with consecutive-duplicate corners (the typical result of merging near a quad corner) was never retired or rebuilt, surfacing as a visible hole in the rendered mesh. Now collapses consecutive duplicates (incl. wrap-around) on any face arity, retires faces whose arity drops below 3, and queues a rebuild via retire + appendFace for faces that just need the duplicates removed. Duplicate-face detection key is sorted-verts + arity so quads and triangles aren't accidentally compared. hitTestVertex front-face culls User-reported follow-up to the chunk-4b edge / face hit-test polish: vertex selection on a dense FBX mesh could pull clicks to vertices on the back of the model. Mirrors the front-facing test (Newell normal vs camera direction) used elsewhere — only verts in at least one front-facing polygon are pickable. This also applies transitively to knife's vertex snap. Tests - DissolveEdgesQuadDiagonalMergesIntoSingleQuad: was 2 fan tris, now 1 quad. Asserts the diagonal is gone AND no fan diagonal replaces it. - DissolveEdgesMultipleDisjointEdgesAllProcessed: was 4 fan tris, now 2 quads. - DissolveVerticesHexFanCenterCollapsesToHexagon: was 4 fan tris, now 1 hexagon. Asserts the merged face has exactly 6 vertices. - 235 standalone tests still pass. Bevel n-gon path remains a follow-up: the existing `bevelEdges` / `bevelVertices` algorithms have triangle-only retriangulation built into multiple paths (effectiveWidth's "third vertex", inner-vertex position computation, retriangulateBeveledFace). The current bevel PR uses a triangle-mode HE copy with submesh-level n-gon restore as an accepted trade-off — touched submeshes triangulate fully, but unrelated submeshes preserve their quads. --- src/EditModeController.cpp | 132 +++++++++++++++++- src/HalfEdgeMesh.cpp | 274 ++++++++++++++++++++++--------------- src/HalfEdgeMesh_test.cpp | 48 +++++-- 3 files changed, 327 insertions(+), 127 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 051646201..b968022bc 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -943,6 +943,57 @@ int EditModeController::hitTestVertex(const QPoint& screenPos, if (!node) return -1; + // Build the front-facing vertex set so we never snap to a vertex + // hidden behind a visible front-facing face. Mirrors the chunk-4b + // edge / face hit-test behaviour. A polygon is front-facing when + // its Newell normal (rotated into world space) points toward the + // camera; a vertex is front-facing iff at least one incident + // polygon does. + const Ogre::Vector3 camPos = camera->getDerivedPosition(); + auto isPolygonFrontFacing = [&](const EditableSubMesh& sub, + const std::vector& corners) -> bool { + if (corners.size() < 3) return false; + Ogre::Vector3 nrm = Ogre::Vector3::ZERO; + for (size_t i = 0; i < corners.size(); ++i) { + if (corners[i] >= sub.vertices.size()) return false; + const auto& a = sub.vertices[corners[i]].position; + const auto& b = sub.vertices[corners[(i + 1) % corners.size()]].position; + nrm.x += (a.y - b.y) * (a.z + b.z); + nrm.y += (a.z - b.z) * (a.x + b.x); + nrm.z += (a.x - b.x) * (a.y + b.y); + } + const Ogre::Vector3 worldNrm = node->_getDerivedOrientation() * nrm; + const Ogre::Vector3 anyCornerWorld = + node->convertLocalToWorldPosition(sub.vertices[corners[0]].position); + return worldNrm.dotProduct(camPos - anyCornerWorld) > 0.0f; + }; + std::set frontVerts; + { + int vertOffset = 0; + for (size_t si = 0; si < m_editableMesh->subMeshes().size(); ++si) { + const auto& sub = m_editableMesh->subMeshes()[si]; + auto recordFrontPoly = [&](const std::vector& corners) { + for (unsigned int c : corners) + frontVerts.insert(vertOffset + static_cast(c)); + }; + if (!sub.faces.empty()) { + for (const auto& face : sub.faces) { + if (face.indices.size() < 3) continue; + if (!isPolygonFrontFacing(sub, face.indices)) continue; + recordFrontPoly(face.indices); + } + } else { + for (const auto& tri : sub.triangles) { + std::vector corners = { + tri.indices[0], tri.indices[1], tri.indices[2] }; + if (!isPolygonFrontFacing(sub, corners)) continue; + recordFrontPoly(corners); + } + } + vertOffset += static_cast(sub.vertices.size()); + } + } + float bestDistSq = pixelRadius * pixelRadius; int bestIndex = -1; int globalOffset = 0; @@ -952,6 +1003,9 @@ int EditModeController::hitTestVertex(const QPoint& screenPos, for (size_t vi = 0; vi < sub.vertices.size(); ++vi) { int globalIdx = globalOffset + static_cast(vi); + // Skip back-face vertices: not in any front-facing polygon. + if (!frontVerts.count(globalIdx)) continue; + // Transform from local space to world space Ogre::Vector3 worldPos = node->convertLocalToWorldPosition(sub.vertices[vi].position); @@ -1807,8 +1861,27 @@ bool EditModeController::applyBevelTopology( if (edges.empty() || width <= 0.0f) return false; + // bevelEdges still carries triangle-only assumptions internally + // (effectiveWidth uses face "third vertex", retriangulateBeveledFace + // assumes a 3-vertex source face). On quad-imported meshes the + // result is a no-op or invisible chamfer — the bevel runs but the + // topology never produces visible new geometry. Workaround until a + // proper n-gon-aware bevel lands: build the HE from a triangle-mode + // copy (clear `.faces` so buildFromEditableMesh falls back to the + // fan-triangulated `.triangles` mirror), and restore untouched + // n-gon submeshes verbatim after `toEditableMesh` writes back. + // Same shape as the pre-#41 knife workaround. + EditableMesh triOnly; + triOnly.subMeshes() = m_editableMesh->subMeshes(); + std::vector wasNGonSub; + wasNGonSub.reserve(triOnly.subMeshes().size()); + auto originalSubMeshes = m_editableMesh->subMeshes(); + for (auto& sub : triOnly.subMeshes()) { + wasNGonSub.push_back(!sub.faces.empty()); + sub.faces.clear(); + } HalfEdgeMesh heMesh; - if (!heMesh.buildFromEditableMesh(*m_editableMesh)) + if (!heMesh.buildFromEditableMesh(triOnly)) return false; // Convert (min,max) vertex-pair edges to HE edge indices. @@ -1840,7 +1913,30 @@ bool EditModeController::applyBevelTopology( if (!heMesh.toEditableMesh(newMesh)) return false; - m_editableMesh->subMeshes() = std::move(newMesh.subMeshes()); + // Submesh-level restore: bring back any submesh that was originally + // n-gon-canonical and the bevel didn't actually touch. This avoids + // multi-submesh assets losing their quads on submeshes the bevel + // never reached. On a single-submesh asset the touched-set covers + // everything, so the touched submesh is fully triangulated — that's + // an accepted trade-off until a properly n-gon-aware bevel lands + // (the bevel HE algorithm itself still has triangle-only retri- + // angulation paths). + std::set touchedSubs; + for (int v : newHEVertices) { + for (int f : heMesh.facesAroundVertex(v)) { + touchedSubs.insert(heMesh.face(f).subMeshIndex); + } + } + auto& outSubs = newMesh.subMeshes(); + for (size_t s = 0; + s < outSubs.size() && s < originalSubMeshes.size() && s < wasNGonSub.size(); + ++s) { + if (touchedSubs.count(static_cast(s))) continue; + if (!wasNGonSub[s]) continue; + outSubs[s] = originalSubMeshes[s]; + } + + m_editableMesh->subMeshes() = std::move(outSubs); // Full normal recompute — cheaper options (selective by proximity) turned // out to leave seams around the beveled region where the neighbor faces @@ -1903,8 +1999,19 @@ bool EditModeController::applyBevelVertexTopology( if (vertexIndices.empty() || width <= 0.0f) return false; + // Same triangle-mode workaround as applyBevelTopology — bevelVertices + // shares the triangle-only retriangulation path with bevelEdges. + EditableMesh triOnly; + triOnly.subMeshes() = m_editableMesh->subMeshes(); + std::vector wasNGonSub; + wasNGonSub.reserve(triOnly.subMeshes().size()); + auto originalSubMeshes = m_editableMesh->subMeshes(); + for (auto& sub : triOnly.subMeshes()) { + wasNGonSub.push_back(!sub.faces.empty()); + sub.faces.clear(); + } HalfEdgeMesh heMesh; - if (!heMesh.buildFromEditableMesh(*m_editableMesh)) + if (!heMesh.buildFromEditableMesh(triOnly)) return false; // Map global selection indices to HE indices by position match. @@ -1946,7 +2053,24 @@ bool EditModeController::applyBevelVertexTopology( if (!heMesh.toEditableMesh(newMesh)) return false; - m_editableMesh->subMeshes() = std::move(newMesh.subMeshes()); + // Submesh-level restore (see applyBevelTopology for the rationale + // and trade-off). + std::set touchedSubs; + for (int v : newHEVertices) { + for (int f : heMesh.facesAroundVertex(v)) { + touchedSubs.insert(heMesh.face(f).subMeshIndex); + } + } + auto& outSubs = newMesh.subMeshes(); + for (size_t s = 0; + s < outSubs.size() && s < originalSubMeshes.size() && s < wasNGonSub.size(); + ++s) { + if (touchedSubs.count(static_cast(s))) continue; + if (!wasNGonSub[s]) continue; + outSubs[s] = originalSubMeshes[s]; + } + + m_editableMesh->subMeshes() = std::move(outSubs); if (m_normalsMode == 0) m_editableMesh->recalculateNormals(); diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 99556e672..7c82e1166 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -4202,38 +4202,90 @@ int HalfEdgeMesh::mergeVertices(const std::vector& vertexIndices, m_faces[f].halfEdge = -1; }; - auto canonicalKey = [&](const std::vector& verts, int subIdx) - -> std::tuple { - std::array sorted = {verts[0], verts[1], verts[2]}; + // Sorted-vertex-set key for duplicate-face detection. Works for any + // face arity — arity-3 only sees triangles, arity-N sees n-gons, + // mixed arity is a different key entirely (so a quad and one of its + // fan triangles aren't accidentally treated as duplicates). + auto canonicalKey = [&](const std::vector& verts, int subIdx) { + std::vector sorted = verts; std::sort(sorted.begin(), sorted.end()); - return {subIdx, sorted[0], sorted[1], sorted[2]}; + std::vector key; + key.reserve(2 + sorted.size()); + key.push_back(subIdx); + key.push_back(static_cast(sorted.size())); + for (int v : sorted) key.push_back(v); + return key; }; + // Walk every live face. After the HE.vertex re-pointing, a face's + // vertex sequence may contain consecutive duplicates (e.g. a quad + // [a,b,b,c] where two adjacent verts merged into b). Collapse those + // — including wrap-around duplicates — and either retire the face + // (arity drops < 3) or rebuild it with the cleaned loop. Without + // this n-gon path, a merge near a quad corner leaves a face with a + // zero-area edge that surfaces as a visible hole in the mesh. int retiredFaces = 0; - std::set> seenFaces; + std::set> seenFaces; + + auto collapseConsecutive = [](const std::vector& verts) { + std::vector out; + out.reserve(verts.size()); + for (int v : verts) { + if (!out.empty() && out.back() == v) continue; + out.push_back(v); + } + // Wrap: if first == last after the consecutive collapse, drop last. + if (out.size() >= 2 && out.front() == out.back()) out.pop_back(); + return out; + }; + + // Collect faces that need rebuilding in a separate pass — we can't + // mutate m_faces / m_halfEdges via appendFace while iterating. + struct PendingRebuild { + int oldFace; + int subIdx; + std::vector newLoop; + }; + std::vector pendingRebuilds; + for (int f = 0; f < static_cast(m_faces.size()); ++f) { int startHE = m_faces[f].halfEdge; if (startHE < 0) continue; const auto verts = faceVertices(f); - if (verts.size() != 3) continue; - const bool degenerate = (verts[0] == verts[1]) - || (verts[1] == verts[2]) - || (verts[0] == verts[2]); - if (degenerate) { + if (verts.size() < 3) continue; + + const auto cleaned = collapseConsecutive(verts); + if (cleaned.size() < 3) { retireFace(f); ++retiredFaces; continue; } - // Duplicate-of-earlier check: triangle with same {subMesh, sorted-verts} + + // Duplicate-of-earlier check: same {subMesh, sorted-verts, arity} // collapses into the first occurrence. - const auto key = canonicalKey(verts, m_faces[f].subMeshIndex); + const auto key = canonicalKey(cleaned, m_faces[f].subMeshIndex); if (!seenFaces.insert(key).second) { retireFace(f); ++retiredFaces; + continue; + } + + if (cleaned.size() != verts.size()) { + // The face has at least one degenerate corner that the loop + // collapse fixed; queue a rebuild. + pendingRebuilds.push_back({f, m_faces[f].subMeshIndex, cleaned}); } } + // Apply pending rebuilds: retire the old face slot, append the + // clean n-gon. rebuildEdgesAndTwins below will re-derive the edge + // table for the new HEs. + for (const auto& pr : pendingRebuilds) { + retireFace(pr.oldFace); + appendFace(pr.newLoop, pr.subIdx); + } + // 5. Mark the doomed vertex slots as retired (halfEdge = -1) so // later operations skip them. Their physical entries stay in // m_vertices but become inert. @@ -4524,68 +4576,66 @@ int HalfEdgeMesh::dissolveEdges(const std::vector& edgeIndices) const auto vA = faceVertices(fA); const auto vB = faceVertices(fB); - if (vA.size() != 3 || vB.size() != 3) continue; // MVP: triangle pairs + if (vA.size() < 3 || vB.size() < 3) continue; const auto [eu, ev] = edgeVertices(e); - // Find the "third" vertex of each face — the one not on the shared edge. - auto thirdVert = [eu, ev](const std::vector& v) { - for (int x : v) if (x != eu && x != ev) return x; - return -1; - }; - const int oppA = thirdVert(vA); - const int oppB = thirdVert(vB); - if (oppA < 0 || oppB < 0 || oppA == oppB) continue; - - // Dissolving (eu, ev) means re-triangulating the {eu, oppA, ev, oppB} - // quad using the *other* diagonal (oppA, oppB). We need to preserve - // the original winding of fA so the outward normal stays sane: if - // fA traverses (eu -> ev -> oppA), the new triangles are - // (eu, oppB, oppA) and (ev, oppA, oppB) (winding flipped by the - // shared diagonal direction). Easier in practice: pull the boundary - // loop directly off fA and fB. + const int subIdx = m_faces[fA].subMeshIndex; + + // n-gon-aware dissolve: merge the two adjacent faces into ONE + // face whose loop concatenates fA's vertices (ending at eu) + // with fB's vertices (starting after eu, ending at ev) — minus + // the shared edge endpoints' duplicates. // - // Walk fA from oppA and collect the three boundary verts in order. - // Then concatenate fB's boundary starting at oppB. That gives a - // 4-vertex loop in correct CCW order, which we fan-triangulate - // (oppA, x, oppB) and (oppA, oppB, y) — but easier: just use - // (oppA, vA_next_after_oppA, oppB) and (oppA, oppB, vB_next_after_oppB). - auto faceLoopFrom = [this](int faceIdx, int startVert) -> std::vector { + // Concretely, walk fA's loop starting AFTER ev (so the loop + // ends at eu just before the shared edge), then walk fB's loop + // starting AFTER eu (so it ends at ev). The result is a single + // CCW loop with arity = arity(fA) + arity(fB) - 2. + auto loopFromVertex = [this](int faceIdx, int afterVert) -> std::vector { std::vector out; int startHE = m_faces[faceIdx].halfEdge; - int he = startHE; - // Find the HE whose `prev->vertex == startVert` (HE points away - // from startVert). - do { - int prev = m_halfEdges[he].prev; - if (prev >= 0 && m_halfEdges[prev].vertex == startVert) break; - he = m_halfEdges[he].next; - } while (he != startHE && he >= 0); - int cur = he; - for (int i = 0; i < 3 && cur >= 0; ++i) { - int prev = m_halfEdges[cur].prev; - if (prev < 0) break; - out.push_back(m_halfEdges[prev].vertex); + if (startHE < 0) return out; + // Find the HE whose target vertex == afterVert (the END + // vertex of the shared edge from this face's POV). The + // next HE points away from afterVert, so its target is the + // first vertex of our partial loop. + int afterHE = -1; + int cur = startHE; + for (int guard = 0; guard < 1024; ++guard) { + if (m_halfEdges[cur].vertex == afterVert) { afterHE = cur; break; } cur = m_halfEdges[cur].next; + if (cur == startHE) break; + } + if (afterHE < 0) return out; + int beginHE = m_halfEdges[afterHE].next; + // Walk from beginHE around the face, but stop BEFORE we'd + // re-collect afterHE's vertex (= afterVert). We collect every + // vertex except afterVert itself, in winding order. + int walk = beginHE; + for (int guard = 0; guard < 1024; ++guard) { + if (walk < 0 || walk == afterHE) break; + out.push_back(m_halfEdges[walk].vertex); + walk = m_halfEdges[walk].next; } return out; }; - const std::vector loopA = faceLoopFrom(fA, oppA); // [oppA, ?, ?] - const std::vector loopB = faceLoopFrom(fB, oppB); // [oppB, ?, ?] - if (loopA.size() != 3 || loopB.size() != 3) continue; + // fA loop after ev: [..., eu] — last vertex is eu (the start of shared edge) + std::vector partA = loopFromVertex(fA, ev); + // fB loop after eu: [..., ev] — last vertex is ev + std::vector partB = loopFromVertex(fB, eu); + if (partA.empty() || partB.empty()) continue; + // Merged loop: partA + partB. The boundary is consistent CCW + // (fB's twin orientation gives the right winding by construction). + std::vector merged; + merged.reserve(partA.size() + partB.size()); + merged.insert(merged.end(), partA.begin(), partA.end()); + merged.insert(merged.end(), partB.begin(), partB.end()); + // Sanity: must have no consecutive duplicates and arity ≥ 3. + if (merged.size() < 3) continue; - const int subIdx = m_faces[fA].subMeshIndex; retireFaceImpl(m_halfEdges, m_faces, fA); retireFaceImpl(m_halfEdges, m_faces, fB); - - // The merged quad's CCW loop is loopA followed by the two non-oppB - // vertices of loopB (which are eu/ev) — but those are already in - // loopA, so loopA + [oppB] in the right slot is what we want. Since - // we want the new diagonal (oppA, oppB), the two new triangles are - // (loopA[0], loopA[1], oppB) and (loopA[0], oppB, loopA[2]) i.e. - // (oppA, neighborA1, oppB) and (oppA, oppB, neighborA2). - appendTriangle(loopA[0], loopA[1], oppB, subIdx); - appendTriangle(loopA[0], oppB, loopA[2], subIdx); + appendFace(merged, subIdx); // Rebuild incrementally per dissolve so the next edge in the input // sees a coherent topology. This is O(|HE|) per dissolve; fine for @@ -4615,79 +4665,85 @@ int HalfEdgeMesh::dissolveVertices(const std::vector& vertexIndices) const auto incident = facesAroundVertex(v); if (incident.size() < 3) continue; // valence < 3 — nothing to dissolve - // All incident faces must share a submesh; otherwise dissolving would - // fuse material groups. Same constraint as mergeVertices. + // All incident faces must share a submesh. n-gon-aware: any face + // arity ≥ 3 is OK (the previous triangle-only check made vertex + // dissolve a no-op on quad-imported meshes). const int subIdx = m_faces[incident.front()].subMeshIndex; bool sameSub = true; for (int f : incident) { if (m_faces[f].subMeshIndex != subIdx) { sameSub = false; break; } - if (faceVertices(f).size() != 3) { sameSub = false; break; } + if (faceVertices(f).size() < 3) { sameSub = false; break; } } if (!sameSub) continue; - // Walk the boundary loop of the umbrella (the N-gon left after - // removing the central vertex). The standard half-edge trick: pick - // any outgoing HE from v, follow its next pointer (skipping v), - // then jump across the next HE's twin to walk to the next umbrella - // face. Each "next->vertex" along the way is one boundary vertex. - // - // We collect by walking the incident faces and pulling the two - // non-v vertices in winding order. With a manifold umbrella those - // pairs chain into a single closed loop. - std::vector loop; - loop.reserve(incident.size()); - // Build a small adjacency: face -> (boundaryStart, boundaryEnd) where - // the face's loop is (v -> boundaryStart -> boundaryEnd -> v). - std::map> faceEdges; + // For each incident face, the boundary contribution is the + // sequence of NON-v vertices in winding order. For a triangle + // [a, v, b] the contribution is [a, b]; for a quad [a, v, b, c] + // it's [a, b, c]; etc. Each contribution starts at "after v" + // and ends at "before v" in the face's loop. + struct FaceBoundary { + int face; + std::vector verts; // boundary verts in this face's winding + }; + std::vector boundaries; + boundaries.reserve(incident.size()); + bool ok = true; for (int f : incident) { const auto vs = faceVertices(f); + int n = static_cast(vs.size()); int idxOfV = -1; - for (int i = 0; i < 3; ++i) if (vs[i] == v) { idxOfV = i; break; } - if (idxOfV < 0) { faceEdges.clear(); break; } - int a = vs[(idxOfV + 1) % 3]; - int b = vs[(idxOfV + 2) % 3]; - faceEdges[f] = {a, b}; + for (int i = 0; i < n; ++i) if (vs[i] == v) { idxOfV = i; break; } + if (idxOfV < 0) { ok = false; break; } + FaceBoundary fb; + fb.face = f; + fb.verts.reserve(n - 1); + for (int k = 1; k < n; ++k) { + fb.verts.push_back(vs[(idxOfV + k) % n]); + } + if (fb.verts.size() < 2) { ok = false; break; } + boundaries.push_back(std::move(fb)); } - if (faceEdges.empty()) continue; + if (!ok) continue; - // Chain the (start, end) pairs into one loop. - // Pick a starting face arbitrarily; walk via shared boundary verts. - std::set remaining(incident.begin(), incident.end()); - int curFace = *remaining.begin(); - loop.push_back(faceEdges[curFace].first); - loop.push_back(faceEdges[curFace].second); - remaining.erase(curFace); + // Chain the contributions into one closed loop. Each + // contribution's last vertex is shared with the next + // contribution's first vertex (the umbrella's manifold edges + // chain that way). + std::set remaining; + for (size_t i = 0; i < boundaries.size(); ++i) remaining.insert(static_cast(i)); + std::vector loop; + // Start with any contribution. + int curIdx = *remaining.begin(); + for (int x : boundaries[curIdx].verts) loop.push_back(x); + remaining.erase(curIdx); - bool ok = true; while (!remaining.empty()) { - int needed = loop.back(); - int found = -1; - for (int f : remaining) { - if (faceEdges[f].first == needed) { found = f; break; } + int tail = loop.back(); + int foundIdx = -1; + for (int i : remaining) { + if (boundaries[i].verts.front() == tail) { foundIdx = i; break; } } - if (found < 0) { ok = false; break; } - // The new face contributes its `second` vertex. - // (Its `first` matches the existing tail.) - loop.push_back(faceEdges[found].second); - remaining.erase(found); + if (foundIdx < 0) { ok = false; break; } + const auto& fb = boundaries[foundIdx]; + // Append, skipping the first vertex (already at tail). + for (size_t k = 1; k < fb.verts.size(); ++k) loop.push_back(fb.verts[k]); + remaining.erase(foundIdx); } if (!ok) continue; - // The loop should close — last == first. Drop the duplicate. + // Loop should close: last == first. if (loop.size() < 4 || loop.front() != loop.back()) continue; loop.pop_back(); if (loop.size() < 3) continue; - // Sanity: no duplicated boundary verts (would mean a non-manifold - // umbrella we can't safely fan-triangulate). + // Sanity: no duplicated boundary verts. std::set uniqueLoop(loop.begin(), loop.end()); if (uniqueLoop.size() != loop.size()) continue; - // Retire the old faces, fan-triangulate from loop[0]. New triangles: - // (loop[0], loop[i], loop[i+1]) for i in [1, N-1) + // Retire the umbrella faces, append a single n-gon face for the + // boundary loop. (Pre-quads-followup this fan-triangulated the + // loop instead, which introduced fan diagonals.) for (int f : incident) retireFaceImpl(m_halfEdges, m_faces, f); - for (size_t i = 1; i + 1 < loop.size(); ++i) { - appendTriangle(loop[0], loop[i], loop[i + 1], subIdx); - } + appendFace(loop, subIdx); m_vertices[v].halfEdge = -1; // retire the dissolved center rebuildEdgesAndTwins(); diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index e6c88e143..1aac4a96b 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -3988,10 +3988,11 @@ TEST(HalfEdgeMeshStandalone, DissolveEdgesEmptyIsNoOp) { EXPECT_TRUE(he.validate()); } -TEST(HalfEdgeMeshStandalone, DissolveEdgesQuadDiagonalRetriangulatesOtherDiagonal) { - // Quad: tris (0,1,2) and (1,3,2) share diagonal v1↔v2. Dissolving the - // diagonal should keep face count at 2 (still triangulated as a quad) - // but with the OTHER diagonal active (0↔3). +TEST(HalfEdgeMeshStandalone, DissolveEdgesQuadDiagonalMergesIntoSingleQuad) { + // n-gon-aware dissolve: dissolving the shared edge between two + // triangles merges them into a SINGLE quad face — no diagonal at + // all. (The previous triangle-only impl just swapped to the OTHER + // diagonal, which left a fan diagonal in place.) auto em = makeQuadMesh(); HalfEdgeMesh he; ASSERT_TRUE(he.buildFromEditableMesh(em)); @@ -3999,13 +4000,20 @@ TEST(HalfEdgeMeshStandalone, DissolveEdgesQuadDiagonalRetriangulatesOtherDiagona ASSERT_GE(diag, 0); EXPECT_EQ(he.dissolveEdges({diag}), 1); - EXPECT_EQ(activeFaceCount(he), 2); + EXPECT_EQ(activeFaceCount(he), 1); EXPECT_TRUE(he.validate()); - // The new diagonal must connect v0 and v3. - EXPECT_GE(findEdge(he, 0, 3), 0); - // Old diagonal should be gone (or no longer exist as an edge). + // The shared diagonal is gone; neither (1,2) nor (0,3) is an edge, + // because the merged face is a quad with only its perimeter edges. EXPECT_EQ(findEdge(he, 1, 2), -1); + EXPECT_EQ(findEdge(he, 0, 3), -1); + // The single surviving face has 4 vertices. + int quadCount = 0; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + if (he.faceVertices(static_cast(f)).size() == 4) ++quadCount; + } + EXPECT_EQ(quadCount, 1); } TEST(HalfEdgeMeshStandalone, DissolveEdgesBoundaryEdgeIsSkipped) { @@ -4021,18 +4029,28 @@ TEST(HalfEdgeMeshStandalone, DissolveEdgesBoundaryEdgeIsSkipped) { } TEST(HalfEdgeMeshStandalone, DissolveVerticesHexFanCenterCollapsesToHexagon) { - // The hex fan's v0 has valence 6, all triangles. Dissolving v0 should - // produce 4 triangles (n-gon with n=6 fan-triangulated from one vertex). + // n-gon-aware dissolve: dissolving v0 (the hex fan's center) + // replaces the 6 incident triangles with a SINGLE hexagon face — + // no fan diagonals introduced. (Previous triangle-only impl + // fan-triangulated the resulting boundary loop into 4 triangles.) auto em = makeHexFan(); HalfEdgeMesh he; ASSERT_TRUE(he.buildFromEditableMesh(em)); ASSERT_EQ(activeFaceCount(he), 6); EXPECT_EQ(he.dissolveVertices({0}), 1); - EXPECT_EQ(activeFaceCount(he), 4) - << "hexagon fan-triangulated from one corner has n-2 = 4 triangles"; + EXPECT_EQ(activeFaceCount(he), 1) + << "hexagon merged from the umbrella, no fan diagonals"; EXPECT_LT(he.vertex(0).halfEdge, 0); EXPECT_TRUE(he.validate()); + + // The single surviving face has 6 vertices. + int hexCount = 0; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + if (he.faceVertices(static_cast(f)).size() == 6) ++hexCount; + } + EXPECT_EQ(hexCount, 1); } TEST(HalfEdgeMeshStandalone, DissolveVerticesBoundaryVertexIsSkipped) { @@ -4105,8 +4123,10 @@ TEST(HalfEdgeMeshStandalone, DissolveEdgesMultipleDisjointEdgesAllProcessed) { EXPECT_EQ(he.dissolveEdges({diagL, diagR}), 2) << "both interior diagonals must dissolve regardless of edge-slot reordering"; - EXPECT_EQ(activeFaceCount(he), 4) - << "two quads, fan-triangulated, still 4 triangles"; + // n-gon-aware dissolve merges each pair of triangles into ONE quad — + // 2 active quads total, NOT 4 fan-triangulated triangles. + EXPECT_EQ(activeFaceCount(he), 2) + << "two merged quads, no fan diagonals"; EXPECT_TRUE(he.validate()); // Both old diagonals should be gone. From 37e172dec67b90d073220dd7cb24eae4a7afdbcd Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 29 Apr 2026 03:45:54 -0400 Subject: [PATCH 25/34] feat(quads): n-gon-aware edge bevel (MVP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `HalfEdgeMesh::bevelEdgesNgon` handles arbitrary face arity. `applyBevelTopology` dispatches to it when the editable mesh actually has n-gon canonical faces; triangle-only meshes still go through the existing `bevelEdges` so we don't lose its quality features (crease detection, segments, profile curves). Algorithm Per beveled edge (v1, v2) with adjacent faces f1, f2: 1. Compute four "inner" vertices, each a perpendicular-in-face offset from one endpoint at distance w. Width is clamped per edge to 0.4 × shortest perimeter edge (walking the actual face perimeter; the triangle bevel guessed a "third vertex" which is the diagonal on a quad and gave wrong clamps). 2. Replace f1's loop: substitute v1 → innerV1F1, v2 → innerV2F1. A triangle stays a triangle, a quad stays a quad — no fan diagonals introduced. Same for f2. 3. Emit chamfer strip: - One central quad bridging f1's inner pair to f2's inner pair. - Two corner triangles, one at each endpoint, joining the inner vertices via the original endpoint. 4. Neighbor (non-beveled) faces aren't touched — v1/v2 stay valid for them; the corner triangle bridges the gap. This drops the previous "build from triangle copy + restore untouched submeshes" workaround: the n-gon path produces real quads + chamfer faces directly, no global triangulation of the touched submesh. MVP scope - Isolated bevels only — input edges sharing an endpoint are rejected (chained bevels need ring-aware logic; matches the triangle bevel's same restriction). - Single-segment flat chamfer; `segments > 1` and `profilePoints` are reserved for a future extension. - Vertex bevel (`bevelVertices`) keeps its triangle-mode workaround for now — will be the next follow-up. Tests - BevelEdgesNgonOnQuadEdgeKeepsQuads: two adjacent quads, bevel the shared edge → 4 inner vertices, 5 faces (2 modified quads + 1 chamfer + 2 corner caps), no fan diagonals introduced. - BevelEdgesNgonRejectsBoundaryEdge: boundary edge skipped. - BevelEdgesNgonRejectsChainedSelection: 4-quad cross arrangement, two interior edges sharing the center vertex; both rejected. - 238 standalone tests pass. --- src/EditModeController.cpp | 70 ++++----- src/HalfEdgeMesh.cpp | 296 +++++++++++++++++++++++++++++++++++++ src/HalfEdgeMesh.h | 22 +++ src/HalfEdgeMesh_test.cpp | 136 +++++++++++++++++ 4 files changed, 479 insertions(+), 45 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index b968022bc..5ebe2d144 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -1861,27 +1861,28 @@ bool EditModeController::applyBevelTopology( if (edges.empty() || width <= 0.0f) return false; - // bevelEdges still carries triangle-only assumptions internally - // (effectiveWidth uses face "third vertex", retriangulateBeveledFace - // assumes a 3-vertex source face). On quad-imported meshes the - // result is a no-op or invisible chamfer — the bevel runs but the - // topology never produces visible new geometry. Workaround until a - // proper n-gon-aware bevel lands: build the HE from a triangle-mode - // copy (clear `.faces` so buildFromEditableMesh falls back to the - // fan-triangulated `.triangles` mirror), and restore untouched - // n-gon submeshes verbatim after `toEditableMesh` writes back. - // Same shape as the pre-#41 knife workaround. - EditableMesh triOnly; - triOnly.subMeshes() = m_editableMesh->subMeshes(); - std::vector wasNGonSub; - wasNGonSub.reserve(triOnly.subMeshes().size()); - auto originalSubMeshes = m_editableMesh->subMeshes(); - for (auto& sub : triOnly.subMeshes()) { - wasNGonSub.push_back(!sub.faces.empty()); - sub.faces.clear(); + // Bevel has two implementations: + // - `bevelEdges` (triangle-only): the original, with crease / + // coplanar-sibling detection and segment / profile support. + // - `bevelEdgesNgon` (n-gon-aware MVP): handles arbitrary face + // arity but only single-segment flat chamfers. + // + // Pick the right one based on whether the editable mesh actually + // carries n-gon canonicalised faces. Triangle-only meshes + // (procedural primitives, post-edit re-entries, .scene.glb sub- + // entities) keep using `bevelEdges` so we don't lose its quality + // features. Quad-imported meshes (FBX, glTF) get `bevelEdgesNgon`, + // which produces visible chamfers without the "all-triangulated" + // workaround that previously triangulated entire submeshes. + bool meshHasNGons = false; + for (const auto& sub : m_editableMesh->subMeshes()) { + if (!sub.faces.empty()) { meshHasNGons = true; break; } } + + auto originalSubMeshes = m_editableMesh->subMeshes(); + HalfEdgeMesh heMesh; - if (!heMesh.buildFromEditableMesh(triOnly)) + if (!heMesh.buildFromEditableMesh(*m_editableMesh)) return false; // Convert (min,max) vertex-pair edges to HE edge indices. @@ -1898,8 +1899,9 @@ bool EditModeController::applyBevelTopology( } } - std::vector newHEVertices = - heMesh.bevelEdges(edgeIndices, width, segments, 0.5f, profilePoints); + std::vector newHEVertices = meshHasNGons + ? heMesh.bevelEdgesNgon(edgeIndices, width, segments, 0.5f, profilePoints) + : heMesh.bevelEdges(edgeIndices, width, segments, 0.5f, profilePoints); if (newHEVertices.empty()) return false; @@ -1912,31 +1914,9 @@ bool EditModeController::applyBevelTopology( EditableMesh newMesh; if (!heMesh.toEditableMesh(newMesh)) return false; + (void)originalSubMeshes; // n-gon path preserves quads natively - // Submesh-level restore: bring back any submesh that was originally - // n-gon-canonical and the bevel didn't actually touch. This avoids - // multi-submesh assets losing their quads on submeshes the bevel - // never reached. On a single-submesh asset the touched-set covers - // everything, so the touched submesh is fully triangulated — that's - // an accepted trade-off until a properly n-gon-aware bevel lands - // (the bevel HE algorithm itself still has triangle-only retri- - // angulation paths). - std::set touchedSubs; - for (int v : newHEVertices) { - for (int f : heMesh.facesAroundVertex(v)) { - touchedSubs.insert(heMesh.face(f).subMeshIndex); - } - } - auto& outSubs = newMesh.subMeshes(); - for (size_t s = 0; - s < outSubs.size() && s < originalSubMeshes.size() && s < wasNGonSub.size(); - ++s) { - if (touchedSubs.count(static_cast(s))) continue; - if (!wasNGonSub[s]) continue; - outSubs[s] = originalSubMeshes[s]; - } - - m_editableMesh->subMeshes() = std::move(outSubs); + m_editableMesh->subMeshes() = std::move(newMesh.subMeshes()); // Full normal recompute — cheaper options (selective by proximity) turned // out to leave seams around the beveled region where the neighbor faces diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 7c82e1166..6b6095665 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -2920,6 +2920,302 @@ std::vector HalfEdgeMesh::bevelEdges(const std::vector& edgeIndices, / return newVertices; } +// =========================================================================== +// bevelEdgesNgon — n-gon-aware edge bevel. +// =========================================================================== +// +// Simpler counterpart to `bevelEdges` for meshes whose faces incident to the +// beveled edges are arbitrary n-gons. The original `bevelEdges` was designed +// for triangle topology (it relies on each face having a single "third" +// vertex, fan-retriangulates the beveled face, and special-cases coplanar +// triangle siblings). On quad-imported assets those assumptions break: +// effectiveWidth picks the diagonal as the "third vertex" so the per-edge +// width clamp is wrong, retriangulateBeveledFace emits fan triangles instead +// of preserving the quad. +// +// This implementation handles ANY face arity ≥ 3 by treating each beveled +// face's vertex loop as opaque. The algorithm per beveled edge (v1, v2): +// +// 1. Compute four "inner" vertices, each a perpendicular-in-face offset +// from one endpoint at distance w: +// innerV1_f1, innerV1_f2 (from v1 into f1, into f2) +// innerV2_f1, innerV2_f2 (from v2 into f1, into f2) +// The width w is clamped per-edge to 0.4 × (shortest perimeter edge +// of f1 or f2). Same clamp shape as the triangle bevel, just walking +// the actual face perimeter instead of guessing a "third vertex". +// +// 2. Replace f1's loop: substitute v1 → innerV1_f1, v2 → innerV2_f1, +// keep all other vertices in their original order. A triangle +// [v1, v2, x] becomes [innerV1_f1, innerV2_f1, x] (still a triangle); +// a quad [v1, v2, x, y] becomes [innerV1_f1, innerV2_f1, x, y] +// (still a quad). Same for f2. +// +// 3. For each NEIGHBOR face g (other than f1, f2) that contains v1: +// v1 stays in place — neighbor faces aren't displaced. The chamfer +// strip below joins innerV1_f1 to innerV1_f2 across g's region. +// +// 4. Emit the chamfer strip: +// - One central quad per segment along the long edge: +// For segments=1: (innerV1_f1, innerV2_f1, innerV2_f2, innerV1_f2). +// For segments=N: subdivide along the cross-section using +// `profilePoints`. +// - Two corner triangles, one at each endpoint: +// (innerV1_f1, v1, innerV1_f2) and (innerV2_f2, v2, innerV2_f1). +// Winding chosen so the chamfer faces outward. +// +// MVP scope: isolated bevels only — input edges that share an endpoint with +// another selected edge are rejected (matches existing bevel's "second +// pass"). Profile / segments > 1 is also reserved for a future extension; +// MVP emits a flat single-segment chamfer. +std::vector HalfEdgeMesh::bevelEdgesNgon( + const std::vector& edgeIndices, + float width, + int segments, + float /*profile*/, + const std::vector& /*profilePoints*/) +{ + std::vector newVertices; + if (edgeIndices.empty() || width <= 0.0f) return newVertices; + segments = std::clamp(segments, 1, 16); + + // ---- 1. Gather per-edge info, filter to interior manifold edges ---- + struct EdgeInfo { + int edgeIdx; + int v1, v2; + int f1, f2; + int subMeshIndex; + std::vector loopF1; // f1's vertex loop in winding order + std::vector loopF2; // f2's vertex loop + }; + std::vector infos; + infos.reserve(edgeIndices.size()); + std::unordered_set seenEdges; + for (int ei : edgeIndices) { + if (ei < 0 || ei >= static_cast(m_edges.size())) continue; + if (!seenEdges.insert(ei).second) continue; + if (m_edges[ei].halfEdge < 0) continue; + const auto [v1, v2] = edgeVertices(ei); + if (v1 < 0 || v2 < 0) continue; + const auto [f1, f2] = edgeFaces(ei); + if (f1 < 0 || f2 < 0) continue; // boundary + const auto loopF1 = faceVertices(f1); + const auto loopF2 = faceVertices(f2); + if (loopF1.size() < 3 || loopF2.size() < 3) continue; + EdgeInfo info; + info.edgeIdx = ei; + info.v1 = v1; info.v2 = v2; + info.f1 = f1; info.f2 = f2; + info.subMeshIndex = m_faces[f1].subMeshIndex; + info.loopF1 = loopF1; + info.loopF2 = loopF2; + infos.push_back(std::move(info)); + } + if (infos.empty()) return newVertices; + + // ---- 2. Reject chained selections (sharing endpoints) ---- + std::map vertexUseCount; + for (const auto& info : infos) { + vertexUseCount[info.v1]++; + vertexUseCount[info.v2]++; + } + std::vector clean; + clean.reserve(infos.size()); + for (const auto& info : infos) { + if (vertexUseCount[info.v1] == 1 && vertexUseCount[info.v2] == 1) + clean.push_back(info); + } + if (clean.empty()) return newVertices; + + // ---- 3. Per-edge effective width: clamp to 0.4× shortest face edge ---- + auto edgeLen = [this](int va, int vb) { + return (m_vertices[vb].position - m_vertices[va].position).length(); + }; + auto effectiveWidth = [&](const EdgeInfo& info) { + float shortest = edgeLen(info.v1, info.v2); + auto walkLoop = [&](const std::vector& loop) { + const int n = static_cast(loop.size()); + for (int i = 0; i < n; ++i) { + shortest = std::min(shortest, edgeLen(loop[i], loop[(i + 1) % n])); + } + }; + walkLoop(info.loopF1); + walkLoop(info.loopF2); + const float cap = shortest * 0.4f; + return std::min(width, cap); + }; + + // ---- 4. Helper: perpendicular-in-face inward direction at v, in + // face's plane, perpendicular to edge (v, otherEndpoint). ---- + auto faceInwardDir = [this](int faceIdx, int v, int otherEndpoint) -> Ogre::Vector3 { + // Newell normal of the face. + const auto loop = faceVertices(faceIdx); + Ogre::Vector3 nrm = Ogre::Vector3::ZERO; + for (size_t i = 0; i < loop.size(); ++i) { + const auto& a = m_vertices[loop[i]].position; + const auto& b = m_vertices[loop[(i + 1) % loop.size()]].position; + nrm.x += (a.y - b.y) * (a.z + b.z); + nrm.y += (a.z - b.z) * (a.x + b.x); + nrm.z += (a.x - b.x) * (a.y + b.y); + } + if (nrm.length() < 1e-8f) return Ogre::Vector3::ZERO; + nrm.normalise(); + // Edge direction (v → otherEndpoint). + Ogre::Vector3 edge = m_vertices[otherEndpoint].position - m_vertices[v].position; + if (edge.length() < 1e-8f) return Ogre::Vector3::ZERO; + edge.normalise(); + // Inward = normal × edge (perpendicular to edge, in face plane, + // pointing into the face's interior assuming CCW winding). + Ogre::Vector3 inward = nrm.crossProduct(edge); + if (inward.length() < 1e-8f) return Ogre::Vector3::ZERO; + inward.normalise(); + return inward; + }; + + // ---- 5. Per-edge: create inner vertices, replace adjacent faces, + // emit chamfer + corners. Process one edge at a time and rebuild + // the HE between iterations so subsequent edges see consistent + // topology. (O(|HE|) per edge — fine for typical interactive + // selections.) + auto retireFace = [this](int fIdx) { + if (fIdx < 0 || fIdx >= static_cast(m_faces.size())) return; + const int startHE = m_faces[fIdx].halfEdge; + if (startHE < 0) return; + int he = startHE; + do { + const int next = m_halfEdges[he].next; + m_halfEdges[he].face = -1; + he = next; + } while (he != startHE && he >= 0); + m_faces[fIdx].halfEdge = -1; + }; + + auto appendInnerVertex = [&](int sourceVert, const Ogre::Vector3& pos) { + HEVertex nv = m_vertices[sourceVert]; + nv.position = pos; + nv.halfEdge = -1; + const int idx = static_cast(m_vertices.size()); + m_vertices.push_back(std::move(nv)); + newVertices.push_back(idx); + return idx; + }; + + for (const auto& info : clean) { + // Re-validate against the live mesh (previous iterations may have + // mutated submeshes, though the chained-edge check above prevents + // most interference). + if (info.f1 >= static_cast(m_faces.size())) continue; + if (info.f2 >= static_cast(m_faces.size())) continue; + if (m_faces[info.f1].halfEdge < 0) continue; + if (m_faces[info.f2].halfEdge < 0) continue; + // Re-fetch loops in case prior iterations changed orientation. + auto loopF1 = faceVertices(info.f1); + auto loopF2 = faceVertices(info.f2); + if (loopF1.size() < 3 || loopF2.size() < 3) continue; + + const float w = effectiveWidth(info); + if (w < 1e-6f) continue; + + // Compute inner positions. + const Ogre::Vector3 dirV1F1 = faceInwardDir(info.f1, info.v1, info.v2); + const Ogre::Vector3 dirV2F1 = faceInwardDir(info.f1, info.v2, info.v1); + const Ogre::Vector3 dirV1F2 = faceInwardDir(info.f2, info.v1, info.v2); + const Ogre::Vector3 dirV2F2 = faceInwardDir(info.f2, info.v2, info.v1); + if (dirV1F1.length() < 0.5f || dirV2F1.length() < 0.5f || + dirV1F2.length() < 0.5f || dirV2F2.length() < 0.5f) continue; + + const int innerV1F1 = appendInnerVertex(info.v1, + m_vertices[info.v1].position + dirV1F1 * w); + const int innerV2F1 = appendInnerVertex(info.v2, + m_vertices[info.v2].position + dirV2F1 * w); + const int innerV1F2 = appendInnerVertex(info.v1, + m_vertices[info.v1].position + dirV1F2 * w); + const int innerV2F2 = appendInnerVertex(info.v2, + m_vertices[info.v2].position + dirV2F2 * w); + + // Replace f1's loop: substitute v1 → innerV1F1, v2 → innerV2F1. + std::vector newLoopF1; + newLoopF1.reserve(loopF1.size()); + for (int v : loopF1) { + if (v == info.v1) newLoopF1.push_back(innerV1F1); + else if (v == info.v2) newLoopF1.push_back(innerV2F1); + else newLoopF1.push_back(v); + } + + // Replace f2's loop similarly. + std::vector newLoopF2; + newLoopF2.reserve(loopF2.size()); + for (int v : loopF2) { + if (v == info.v1) newLoopF2.push_back(innerV1F2); + else if (v == info.v2) newLoopF2.push_back(innerV2F2); + else newLoopF2.push_back(v); + } + + retireFace(info.f1); + retireFace(info.f2); + appendFace(newLoopF1, info.subMeshIndex); + appendFace(newLoopF2, info.subMeshIndex); + + // Chamfer strip + corner caps. Winding: pick whichever order + // matches f1's winding direction across the beveled edge so the + // chamfer faces outward consistently. f1 has v1→v2 in some + // direction; the chamfer should bridge from f1's side + // (innerV2F1, innerV1F1) outward to f2's side (innerV1F2, + // innerV2F2). + // + // Determine direction: walk f1's loop and check whether v1 + // immediately precedes v2 (CCW-from-f1) or v2 precedes v1. + bool f1WalksV1ToV2 = false; + for (size_t i = 0; i < loopF1.size(); ++i) { + if (loopF1[i] == info.v1 + && loopF1[(i + 1) % loopF1.size()] == info.v2) { + f1WalksV1ToV2 = true; break; + } + } + + // Chamfer face: a single quad joining the two pairs of inner + // vertices. f2's twin has the opposite winding by construction, + // so the chamfer's "f1 side" walks innerV1F1 → innerV2F1 (if + // f1WalksV1ToV2) and the "f2 side" walks innerV2F2 → innerV1F2. + // Glue them with the right CCW order: + std::vector chamferLoop; + if (f1WalksV1ToV2) { + // f1 walks v1 → v2, so f1's "outer" edge of the new chamfer + // is innerV1F1 → innerV2F1 (in f1's CCW). The chamfer face + // sits between f1 and f2 with f1 to its "left" — its CCW + // (looking from outside) goes innerV2F1 → innerV1F1 → + // innerV1F2 → innerV2F2. + chamferLoop = { innerV2F1, innerV1F1, innerV1F2, innerV2F2 }; + } else { + chamferLoop = { innerV1F1, innerV2F1, innerV2F2, innerV1F2 }; + } + appendFace(chamferLoop, info.subMeshIndex); + + // Corner caps at v1 and v2. Triangles bridging the inner + // vertices via the original endpoint, so v1 and v2 stay + // connected to neighbor (non-beveled) faces. + if (f1WalksV1ToV2) { + // Cap at v1: (innerV1F2, info.v1, innerV1F1) — winding + // matches the chamfer's f1WalksV1ToV2 case. + appendFace({innerV1F2, info.v1, innerV1F1}, info.subMeshIndex); + // Cap at v2: (innerV2F1, info.v2, innerV2F2). + appendFace({innerV2F1, info.v2, innerV2F2}, info.subMeshIndex); + } else { + appendFace({innerV1F1, info.v1, innerV1F2}, info.subMeshIndex); + appendFace({innerV2F2, info.v2, innerV2F1}, info.subMeshIndex); + } + + // Rebuild incrementally per edge so subsequent iterations see a + // consistent topology. This is O(|HE|) per bevel — fine for + // interactive selections (tens of edges). + rebuildEdgesAndTwins(); + compactBoundaryHalfEdges(); + buildBoundaryHalfEdges(); + fixVertexHalfEdges(); + } + + return newVertices; +} + // =========================================================================== // bevelVertices — corner cut at each selected vertex. // =========================================================================== diff --git a/src/HalfEdgeMesh.h b/src/HalfEdgeMesh.h index 5f10bf301..b3c68ba16 100644 --- a/src/HalfEdgeMesh.h +++ b/src/HalfEdgeMesh.h @@ -326,6 +326,28 @@ class HalfEdgeMesh float profile = 0.5f, const std::vector& profilePoints = {}); + /** + * @brief n-gon-aware edge bevel for meshes whose adjacent faces are + * arbitrary polygons. + * + * Simpler counterpart to `bevelEdges` — handles any face arity ≥ 3 + * by treating each beveled face's vertex loop as opaque. Use this + * when at least one face adjacent to a beveled edge is a quad or + * higher-arity n-gon; the triangle-only `bevelEdges` makes + * incorrect assumptions about "third vertex" in that case. + * + * MVP scope: isolated bevels only (input edges sharing endpoints + * are rejected); flat single-segment chamfer; `profile` and + * `profilePoints` are reserved for a future extension. + * + * @return Indices of the newly created vertices. Empty on failure. + */ + std::vector bevelEdgesNgon(const std::vector& edgeIndices, + float width, + int segments = 1, + float profile = 0.5f, + const std::vector& profilePoints = {}); + /** * @brief Bevel selected vertices (corner cut). * diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index 1aac4a96b..a26f15e39 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -3106,6 +3106,142 @@ TEST(HalfEdgeMeshStandalone, BevelVertexSegmentsClampsOverUpperLimit) { EXPECT_TRUE(isManifold(back)); } +// =========================================================================== +// bevelEdgesNgon — n-gon-aware edge bevel +// =========================================================================== + +TEST(HalfEdgeMeshStandalone, BevelEdgesNgonOnQuadEdgeKeepsQuads) { + // Two adjacent quads sharing edge v1-v2. Bevel that shared edge. + // Expected output: + // - 4 new "inner" vertices (innerV1F1, innerV2F1, innerV1F2, innerV2F2). + // - The two original quads become quads with vMid replacements + // (4-vertex faces still — same arity, just two corners moved). + // - 1 new chamfer quad bridging f1 to f2. + // - 2 corner-cap triangles, one at v1 and one at v2. + // Total active face count: 2 (modified quads) + 1 (chamfer) + 2 (caps) = 5. + EditableMesh mesh; + EditableSubMesh sub; + sub.materialName = "M"; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { mkV(0, 0), mkV(1, 0), mkV(1, 1), mkV(0, 1), + mkV(2, 0), mkV(2, 1) }; + EditableFace q1, q2; + q1.indices = {0, 1, 2, 3}; + q2.indices = {1, 4, 5, 2}; + sub.faces = { q1, q2 }; + triangulateFaces(sub); + mesh.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(mesh)); + ASSERT_EQ(he.faceCount(), 2u); + + const int sharedEdge = findEdge(he, 1, 2); + ASSERT_GE(sharedEdge, 0); + + auto newVerts = he.bevelEdgesNgon({sharedEdge}, 0.1f); + EXPECT_EQ(newVerts.size(), 4u) + << "expected 4 inner vertices (2 per face × 2 endpoints)"; + EXPECT_TRUE(he.validate()); + + // Active face count: 2 modified quads + 1 chamfer quad + 2 corner caps = 5. + int active = 0; + int quadCount = 0; + int triCount = 0; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + ++active; + const auto fv = he.faceVertices(static_cast(f)); + if (fv.size() == 4) ++quadCount; + if (fv.size() == 3) ++triCount; + } + EXPECT_EQ(active, 5); + EXPECT_EQ(quadCount, 3) << "2 modified original quads + 1 chamfer = 3"; + EXPECT_EQ(triCount, 2) << "2 corner caps at v1 and v2"; +} + +TEST(HalfEdgeMeshStandalone, BevelEdgesNgonRejectsBoundaryEdge) { + // Boundary edges (single adjacent face) are skipped — same as the + // triangle-bevel MVP. No crash, no new vertices. + EditableMesh mesh; + EditableSubMesh sub; + sub.materialName = "M"; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { mkV(0, 0), mkV(1, 0), mkV(1, 1), mkV(0, 1) }; + EditableFace q; + q.indices = {0, 1, 2, 3}; + sub.faces = { q }; + triangulateFaces(sub); + mesh.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(mesh)); + const int boundary = findEdge(he, 0, 1); + ASSERT_GE(boundary, 0); + auto newVerts = he.bevelEdgesNgon({boundary}, 0.1f); + EXPECT_TRUE(newVerts.empty()); + EXPECT_TRUE(he.validate()); +} + +TEST(HalfEdgeMeshStandalone, BevelEdgesNgonRejectsChainedSelection) { + // 4 quads in a + arrangement around a central vertex, so every + // selected edge is INTERIOR (not a boundary). Two edges chained + // through that center vertex must be skipped — chained bevels + // need ring-aware logic the MVP doesn't have. + // + // v6 - v7 - v8 + // | | | + // v3 - v4 - v5 + // | | | + // v0 - v1 - v2 + // + // Quads: (0,1,4,3), (1,2,5,4), (3,4,7,6), (4,5,8,7). + // The edges (1,4) and (4,5) share v4 (the center) and are both + // interior between quad pairs. + EditableMesh mesh; + EditableSubMesh sub; + sub.materialName = "M"; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { + mkV(0, 0), mkV(1, 0), mkV(2, 0), + mkV(0, 1), mkV(1, 1), mkV(2, 1), + mkV(0, 2), mkV(1, 2), mkV(2, 2), + }; + EditableFace qA, qB, qC, qD; + qA.indices = {0, 1, 4, 3}; + qB.indices = {1, 2, 5, 4}; + qC.indices = {3, 4, 7, 6}; + qD.indices = {4, 5, 8, 7}; + sub.faces = { qA, qB, qC, qD }; + triangulateFaces(sub); + mesh.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(mesh)); + const int eA = findEdge(he, 1, 4); // interior between qA, qB + const int eB = findEdge(he, 4, 5); // interior between qB, qD; shares v4 + ASSERT_GE(eA, 0); ASSERT_GE(eB, 0); + auto newVerts = he.bevelEdgesNgon({eA, eB}, 0.05f); + EXPECT_TRUE(newVerts.empty()) + << "chained selections (sharing an endpoint) are skipped in the MVP"; + EXPECT_TRUE(he.validate()); +} + // =========================================================================== // splitEdge / splitFace — knife-tool topology primitives // =========================================================================== From 4e65fbdd9504fdd7464e35b28b3ab905d752142c Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 29 Apr 2026 03:52:03 -0400 Subject: [PATCH 26/34] feat(quads): n-gon-aware vertex bevel + edge bevel segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the n-gon bevel work to (a) the vertex-bevel API and (b) multi-segment chamfers on the edge bevel. bevelVerticesNgon Single-segment flat-cap implementation. Per beveled vertex v of valence ≥ 3: - For each incident face f, create one inner vertex at distance `width` from v along the direction toward f's centroid. Width clamps to half the shortest incident edge. - Replace each incident face's loop: substitute v → inner_f. Original arity preserved (triangle stays triangle, quad stays quad — only the v-corner moves inward). - Cap the corner with a single n-gon face walking the inner vertices in ring order. applyBevelVertexTopology dispatches to the n-gon variant when the editable mesh has n-gon canonical faces, falls back to the existing triangle-only bevelVertices otherwise. bevelEdgesNgon segments support Per beveled edge, build a chain of N+1 vertices at each endpoint spanning innerVF1 → innerVF2 with N-1 intermediates. Each intermediate is a linear blend along the chord plus a profile- controlled bulge along the "outward" axis (toward the original endpoint, projected perpendicular to the chord). Chamfer becomes N segment quads instead of one; corner caps become N-triangle fans per endpoint. profilePoints / profile drive the bulge curve exactly as for the triangle bevel — sin-envelope synthesis when no per-point vector is supplied. Tests - BevelEdgesNgonSegments3ProducesRoundedChamfer: 4 inner + 4 intermediate verts, 11 active faces (2 quads + 3 chamfer segments + 6 corner-cap triangles). - BevelVerticesNgonOnQuadCornerKeepsQuads: 4 quads in a + cross around a valence-4 vertex; bevel produces 4 modified quads + 1 cap quad with no fan diagonals. - 240 standalone tests pass. --- src/EditModeController.cpp | 46 ++---- src/HalfEdgeMesh.cpp | 294 +++++++++++++++++++++++++++++++++---- src/HalfEdgeMesh.h | 22 +++ src/HalfEdgeMesh_test.cpp | 106 +++++++++++++ 4 files changed, 406 insertions(+), 62 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 5ebe2d144..35b3ac199 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -1979,19 +1979,18 @@ bool EditModeController::applyBevelVertexTopology( if (vertexIndices.empty() || width <= 0.0f) return false; - // Same triangle-mode workaround as applyBevelTopology — bevelVertices - // shares the triangle-only retriangulation path with bevelEdges. - EditableMesh triOnly; - triOnly.subMeshes() = m_editableMesh->subMeshes(); - std::vector wasNGonSub; - wasNGonSub.reserve(triOnly.subMeshes().size()); - auto originalSubMeshes = m_editableMesh->subMeshes(); - for (auto& sub : triOnly.subMeshes()) { - wasNGonSub.push_back(!sub.faces.empty()); - sub.faces.clear(); + // Same dispatch as applyBevelTopology: pick the n-gon path when the + // editable mesh has n-gon canonical faces, the triangle-only path + // otherwise. The n-gon variant is single-segment flat MVP; the + // triangle path keeps its segments / profile features. + bool meshHasNGons = false; + for (const auto& sub : m_editableMesh->subMeshes()) { + if (!sub.faces.empty()) { meshHasNGons = true; break; } } + auto originalSubMeshes = m_editableMesh->subMeshes(); + HalfEdgeMesh heMesh; - if (!heMesh.buildFromEditableMesh(triOnly)) + if (!heMesh.buildFromEditableMesh(*m_editableMesh)) return false; // Map global selection indices to HE indices by position match. @@ -2019,8 +2018,9 @@ bool EditModeController::applyBevelVertexTopology( } if (heIndices.empty()) return false; - std::vector newHEVertices = - heMesh.bevelVertices(heIndices, width, segments, 0.5f, profilePoints); + std::vector newHEVertices = meshHasNGons + ? heMesh.bevelVerticesNgon(heIndices, width, segments, 0.5f, profilePoints) + : heMesh.bevelVertices(heIndices, width, segments, 0.5f, profilePoints); if (newHEVertices.empty()) return false; @@ -2032,25 +2032,9 @@ bool EditModeController::applyBevelVertexTopology( EditableMesh newMesh; if (!heMesh.toEditableMesh(newMesh)) return false; + (void)originalSubMeshes; // n-gon path preserves quads natively - // Submesh-level restore (see applyBevelTopology for the rationale - // and trade-off). - std::set touchedSubs; - for (int v : newHEVertices) { - for (int f : heMesh.facesAroundVertex(v)) { - touchedSubs.insert(heMesh.face(f).subMeshIndex); - } - } - auto& outSubs = newMesh.subMeshes(); - for (size_t s = 0; - s < outSubs.size() && s < originalSubMeshes.size() && s < wasNGonSub.size(); - ++s) { - if (touchedSubs.count(static_cast(s))) continue; - if (!wasNGonSub[s]) continue; - outSubs[s] = originalSubMeshes[s]; - } - - m_editableMesh->subMeshes() = std::move(outSubs); + m_editableMesh->subMeshes() = std::move(newMesh.subMeshes()); if (m_normalsMode == 0) m_editableMesh->recalculateNormals(); diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 6b6095665..a97fc94c5 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -2971,12 +2971,37 @@ std::vector HalfEdgeMesh::bevelEdgesNgon( const std::vector& edgeIndices, float width, int segments, - float /*profile*/, - const std::vector& /*profilePoints*/) + float profile, + const std::vector& profilePointsIn) { std::vector newVertices; if (edgeIndices.empty() || width <= 0.0f) return newVertices; segments = std::clamp(segments, 1, 16); + profile = std::clamp(profile, 0.0f, 1.0f); + + // Build per-intermediate profile point vector. For segments=N there are + // N-1 intermediate vertices between innerA (f1-side) and innerB (f2-side) + // at each endpoint. profilePoints[i] in [0,1] controls the bulge along + // the "outward" axis (toward the original endpoint v) for intermediate + // i: 0.5 = flat (no bulge), > 0.5 = convex toward v, < 0.5 = concave. + // When the caller supplies profilePointsIn of the right size we use it + // directly; otherwise synthesize from `profile` with a sin envelope. + std::vector profilePoints; + if (segments > 1) { + profilePoints.resize(segments - 1, 0.5f); + if (profilePointsIn.size() == static_cast(segments - 1)) { + for (size_t i = 0; i < profilePoints.size(); ++i) + profilePoints[i] = std::clamp(profilePointsIn[i], 0.0f, 1.0f); + } else { + constexpr float kPi = 3.14159265358979323846f; + const float amp = profile - 0.5f; + for (int i = 1; i < segments; ++i) { + const float t = static_cast(i) + / static_cast(segments); + profilePoints[i - 1] = 0.5f + amp * std::sin(kPi * t); + } + } + } // ---- 1. Gather per-edge info, filter to interior manifold edges ---- struct EdgeInfo { @@ -3172,36 +3197,91 @@ std::vector HalfEdgeMesh::bevelEdgesNgon( } } - // Chamfer face: a single quad joining the two pairs of inner - // vertices. f2's twin has the opposite winding by construction, - // so the chamfer's "f1 side" walks innerV1F1 → innerV2F1 (if - // f1WalksV1ToV2) and the "f2 side" walks innerV2F2 → innerV1F2. - // Glue them with the right CCW order: - std::vector chamferLoop; - if (f1WalksV1ToV2) { - // f1 walks v1 → v2, so f1's "outer" edge of the new chamfer - // is innerV1F1 → innerV2F1 (in f1's CCW). The chamfer face - // sits between f1 and f2 with f1 to its "left" — its CCW - // (looking from outside) goes innerV2F1 → innerV1F1 → - // innerV1F2 → innerV2F2. - chamferLoop = { innerV2F1, innerV1F1, innerV1F2, innerV2F2 }; - } else { - chamferLoop = { innerV1F1, innerV2F1, innerV2F2, innerV1F2 }; + // Build the per-endpoint chain of intermediate vertices. At each + // endpoint v, the chain runs from f1-side innerVF1 to f2-side + // innerVF2 via segments-1 intermediates. For segments=1 the + // chain is just [innerVF1, innerVF2]. + // + // Intermediate i (i ∈ [1, segments-1]): linear blend along the + // chord innerVF1→innerVF2, plus a bulge along the "outward" + // axis (toward v's original position, projected perpendicular + // to the chord) controlled by profilePoints[i-1]. + auto buildChain = [&](int v, int innerVF1, int innerVF2) { + std::vector chain; + chain.reserve(segments + 1); + chain.push_back(innerVF1); + if (segments > 1) { + const auto& pA = m_vertices[innerVF1].position; + const auto& pB = m_vertices[innerVF2].position; + const auto& pV = m_vertices[v].position; + const Ogre::Vector3 chord = pB - pA; + Ogre::Vector3 outward = pV - (pA + pB) * 0.5f; + if (const float c2 = chord.squaredLength(); c2 > 1e-12f) + outward -= chord * (outward.dotProduct(chord) / c2); + if (outward.length() > 1e-6f) outward.normalise(); + else outward = Ogre::Vector3::ZERO; + for (int i = 1; i < segments; ++i) { + const float t = static_cast(i) + / static_cast(segments); + const float bulge = (profilePoints[i - 1] - 0.5f) * w; + const Ogre::Vector3 pos = pA + chord * t + outward * bulge; + HEVertex nv = m_vertices[v]; + nv.position = pos; + nv.halfEdge = -1; + const int idx = static_cast(m_vertices.size()); + m_vertices.push_back(std::move(nv)); + newVertices.push_back(idx); + chain.push_back(idx); + } + } + chain.push_back(innerVF2); + return chain; + }; + const std::vector chainV1 = buildChain(info.v1, innerV1F1, innerV1F2); + const std::vector chainV2 = buildChain(info.v2, innerV2F1, innerV2F2); + + // Chamfer strip: N quads bridging v1's chain to v2's chain. + // Winding matches the chamfer single-quad case extended per + // segment. f2's twin orientation already gives the right CCW + // — we just walk consecutive pairs along both chains. + for (int i = 0; i < segments; ++i) { + const int aV1 = chainV1[i]; + const int bV1 = chainV1[i + 1]; + const int aV2 = chainV2[i]; + const int bV2 = chainV2[i + 1]; + // f1WalksV1ToV2 selects the segment quad's CCW orientation. + std::vector seg; + if (f1WalksV1ToV2) { + // f1 walks v1 → v2, so this segment's "f1-side" edge runs + // v1 → v2 (in chain coords: aV1 → aV2). Outer-CCW: aV2 → + // aV1 → bV1 → bV2. + seg = { aV2, aV1, bV1, bV2 }; + } else { + seg = { aV1, aV2, bV2, bV1 }; + } + appendFace(seg, info.subMeshIndex); } - appendFace(chamferLoop, info.subMeshIndex); - - // Corner caps at v1 and v2. Triangles bridging the inner - // vertices via the original endpoint, so v1 and v2 stay - // connected to neighbor (non-beveled) faces. - if (f1WalksV1ToV2) { - // Cap at v1: (innerV1F2, info.v1, innerV1F1) — winding - // matches the chamfer's f1WalksV1ToV2 case. - appendFace({innerV1F2, info.v1, innerV1F1}, info.subMeshIndex); - // Cap at v2: (innerV2F1, info.v2, innerV2F2). - appendFace({innerV2F1, info.v2, innerV2F2}, info.subMeshIndex); - } else { - appendFace({innerV1F1, info.v1, innerV1F2}, info.subMeshIndex); - appendFace({innerV2F2, info.v2, innerV2F1}, info.subMeshIndex); + + // Corner fans at v1 and v2: a triangle fan from the original + // endpoint v through every consecutive chain pair. For + // segments=1 this is one triangle; for N segments it's N + // triangles per endpoint. + for (int i = 0; i < segments; ++i) { + const int aV1 = chainV1[i]; + const int bV1 = chainV1[i + 1]; + const int aV2 = chainV2[i]; + const int bV2 = chainV2[i + 1]; + if (f1WalksV1ToV2) { + // Cap at v1: (bV1 [f2-side end], info.v1, aV1 [f1-side end]). + // For segments > 1, the fan has aV1 at one end and bV1 at the + // other — winding matches the chamfer segment. + appendFace({bV1, info.v1, aV1}, info.subMeshIndex); + // Cap at v2: (aV2, info.v2, bV2). + appendFace({aV2, info.v2, bV2}, info.subMeshIndex); + } else { + appendFace({aV1, info.v1, bV1}, info.subMeshIndex); + appendFace({bV2, info.v2, aV2}, info.subMeshIndex); + } } // Rebuild incrementally per edge so subsequent iterations see a @@ -3216,6 +3296,158 @@ std::vector HalfEdgeMesh::bevelEdgesNgon( return newVertices; } +// =========================================================================== +// bevelVerticesNgon — n-gon-aware corner cut at each selected vertex. +// =========================================================================== +// +// Simpler counterpart to `bevelVertices` for meshes whose incident faces are +// arbitrary n-gons. The original `bevelVertices` is a 700+-line function +// with deep triangle assumptions (`fv.size() != 3` checks throughout). This +// implementation: +// +// 1. For each vertex v of valence ≥ 3, walks v's face ring (using the +// same `facesAroundVertex` accessor every other op uses). +// 2. For each incident face f, creates an "inner" vertex o_f at distance +// `width` from v along the direction (centroid_of_f - v). This works +// for any face arity and avoids the triangle-only "two edge offsets +// per face" trick. +// 3. Replaces each incident face f's loop: substitute v → o_f. Triangles +// stay triangles, quads stay quads — the only change is one corner +// moves inward. +// 4. Caps with a single n-gon face (one cap vertex per incident face) +// walking the o_f sequence in ring order. +// +// MVP scope: flat single-segment cap, isolated bevels, valence ≥ 3, +// non-boundary vertices only. Same shape limits as bevelEdgesNgon. +std::vector HalfEdgeMesh::bevelVerticesNgon( + const std::vector& vertexIndices, + float width, + int /*segments*/, + float /*profile*/, + const std::vector& /*profilePoints*/) +{ + std::vector newVertices; + if (vertexIndices.empty() || width <= 0.0f) return newVertices; + + // Process vertices sequentially, rebuilding the HE between each. + // Same pattern as the triangle bevel + the n-gon edge bevel above. + std::set processed; + for (int v : vertexIndices) { + if (!processed.insert(v).second) continue; + if (v < 0 || v >= static_cast(m_vertices.size())) continue; + if (m_vertices[v].halfEdge < 0) continue; + if (isVertexBoundary(v)) continue; + + const auto incident = facesAroundVertex(v); + if (incident.size() < 3) continue; + + // All incident faces must share a submesh — otherwise the new + // cap would silently fuse material groups. + const int subIdx = m_faces[incident.front()].subMeshIndex; + bool sameSub = true; + for (int f : incident) { + if (m_faces[f].subMeshIndex != subIdx) { sameSub = false; break; } + } + if (!sameSub) continue; + + // Per-vertex effective width: clamp to half the shortest edge + // incident to v. Walking each incident face's perimeter and + // finding edges adjacent to v. + float shortestIncident = std::numeric_limits::max(); + for (int f : incident) { + const auto loop = faceVertices(f); + for (size_t i = 0; i < loop.size(); ++i) { + if (loop[i] == v) { + const int prev = loop[(i + loop.size() - 1) % loop.size()]; + const int next = loop[(i + 1) % loop.size()]; + const float lp = (m_vertices[prev].position + - m_vertices[v].position).length(); + const float ln = (m_vertices[next].position + - m_vertices[v].position).length(); + shortestIncident = std::min(shortestIncident, lp); + shortestIncident = std::min(shortestIncident, ln); + } + } + } + if (shortestIncident < 1e-6f) continue; + const float w = std::min(width, shortestIncident * 0.49f); + + // Compute one inner vertex per incident face. Direction: + // (centroid_of_f - v), normalised. Cap face walks these inner + // vertices in the same order as `incident` (which is ring order). + std::vector innerVerts; + innerVerts.reserve(incident.size()); + bool ok = true; + for (int f : incident) { + const auto loop = faceVertices(f); + if (loop.size() < 3) { ok = false; break; } + Ogre::Vector3 centroid = Ogre::Vector3::ZERO; + for (int x : loop) centroid += m_vertices[x].position; + centroid /= static_cast(loop.size()); + Ogre::Vector3 dir = centroid - m_vertices[v].position; + if (dir.length() < 1e-6f) { ok = false; break; } + dir.normalise(); + HEVertex nv = m_vertices[v]; + nv.position = m_vertices[v].position + dir * w; + nv.halfEdge = -1; + const int idx = static_cast(m_vertices.size()); + m_vertices.push_back(std::move(nv)); + newVertices.push_back(idx); + innerVerts.push_back(idx); + } + if (!ok || innerVerts.size() != incident.size()) continue; + + // Build a face → innerVert mapping for the substitution step. + std::map faceToInner; + for (size_t k = 0; k < incident.size(); ++k) { + faceToInner[incident[k]] = innerVerts[k]; + } + + // Replace each incident face's loop: substitute v → o_f. + std::vector>> replacements; + replacements.reserve(incident.size()); + for (int f : incident) { + const auto loop = faceVertices(f); + std::vector newLoop; + newLoop.reserve(loop.size()); + for (int x : loop) { + if (x == v) newLoop.push_back(faceToInner[f]); + else newLoop.push_back(x); + } + replacements.push_back({f, std::move(newLoop)}); + } + + // Retire old faces, append replacements + cap. + auto retireFaceLocal = [this](int fIdx) { + const int startHE = m_faces[fIdx].halfEdge; + if (startHE < 0) return; + int he = startHE; + do { + const int next = m_halfEdges[he].next; + m_halfEdges[he].face = -1; + he = next; + } while (he != startHE && he >= 0); + m_faces[fIdx].halfEdge = -1; + }; + for (int f : incident) retireFaceLocal(f); + for (const auto& [_, loop] : replacements) { + appendFace(loop, subIdx); + } + // Cap face: innerVerts in ring order. + appendFace(innerVerts, subIdx); + + // Retire the original vertex slot. + m_vertices[v].halfEdge = -1; + + rebuildEdgesAndTwins(); + compactBoundaryHalfEdges(); + buildBoundaryHalfEdges(); + fixVertexHalfEdges(); + } + + return newVertices; +} + // =========================================================================== // bevelVertices — corner cut at each selected vertex. // =========================================================================== diff --git a/src/HalfEdgeMesh.h b/src/HalfEdgeMesh.h index b3c68ba16..f82167a18 100644 --- a/src/HalfEdgeMesh.h +++ b/src/HalfEdgeMesh.h @@ -389,6 +389,28 @@ class HalfEdgeMesh float profile = 0.5f, const std::vector& profilePoints = {}); + /** + * @brief n-gon-aware vertex bevel for meshes whose incident faces are + * arbitrary polygons. + * + * Simpler counterpart to `bevelVertices`. Replaces v with one inner + * vertex per incident face (placed at distance `width` along the + * direction toward each face's centroid) plus a single n-gon cap + * face walking those inner vertices in ring order. Each incident + * face keeps its original arity — only its v-corner moves inward. + * + * MVP scope: flat single-segment cap, valence ≥ 3, non-boundary + * vertices only. `segments` and `profilePoints` are reserved for a + * future rounded-cap extension. + * + * @return Indices of the newly created vertices. Empty on failure. + */ + std::vector bevelVerticesNgon(const std::vector& vertexIndices, + float width, + int segments = 1, + float profile = 0.5f, + const std::vector& profilePoints = {}); + /** * @brief Insert a new vertex on an edge at parametric position t, then * split each triangle that used the edge into two triangles. diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index a26f15e39..d0a7ce0b5 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -3193,6 +3193,112 @@ TEST(HalfEdgeMeshStandalone, BevelEdgesNgonRejectsBoundaryEdge) { EXPECT_TRUE(he.validate()); } +TEST(HalfEdgeMeshStandalone, BevelEdgesNgonSegments3ProducesRoundedChamfer) { + // segments > 1 builds a profile-driven chain at each endpoint: + // 4 chain vertices per endpoint (innerVF1, mid_1, mid_2, innerVF2), + // 3 chamfer-strip quads per beveled edge, 6 corner-cap triangles + // (3 per endpoint). + // + // Topology: 2 modified quads (the original f1, f2) + 3 chamfer + // segment quads + 6 corner caps = 11 active faces. + // New vertices: 4 inner (segments=1 baseline) + 2 × 2 intermediates + // (segments-1 = 2 per endpoint) = 8. + EditableMesh mesh; + EditableSubMesh sub; + sub.materialName = "M"; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { mkV(0, 0), mkV(1, 0), mkV(1, 1), mkV(0, 1), + mkV(2, 0), mkV(2, 1) }; + EditableFace q1, q2; + q1.indices = {0, 1, 2, 3}; + q2.indices = {1, 4, 5, 2}; + sub.faces = { q1, q2 }; + triangulateFaces(sub); + mesh.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(mesh)); + const int sharedEdge = findEdge(he, 1, 2); + ASSERT_GE(sharedEdge, 0); + + auto newVerts = he.bevelEdgesNgon({sharedEdge}, 0.1f, /*segments=*/3, /*profile=*/0.5f); + EXPECT_EQ(newVerts.size(), 8u) + << "4 inner + 2*(segments-1) intermediates = 8 new verts"; + EXPECT_TRUE(he.validate()); + + int active = 0; + int quads = 0; + int tris = 0; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + ++active; + const auto fv = he.faceVertices(static_cast(f)); + if (fv.size() == 4) ++quads; + if (fv.size() == 3) ++tris; + } + EXPECT_EQ(active, 11) + << "2 modified quads + 3 chamfer segment quads + 6 corner caps"; + EXPECT_EQ(quads, 5); + EXPECT_EQ(tris, 6); +} + +TEST(HalfEdgeMeshStandalone, BevelVerticesNgonOnQuadCornerKeepsQuads) { + // 4 quads in a + arrangement around a central vertex v4 of valence 4. + // Bevel v4. Expected: + // - 4 inner vertices (one per incident face), each placed toward + // that face's centroid. + // - 4 modified quads (each loses v4, gains its inner vertex — + // still a 4-vertex face). + // - 1 cap n-gon (4-vertex) walking the four inner vertices. + EditableMesh mesh; + EditableSubMesh sub; + sub.materialName = "M"; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { + mkV(0, 0), mkV(1, 0), mkV(2, 0), + mkV(0, 1), mkV(1, 1), mkV(2, 1), + mkV(0, 2), mkV(1, 2), mkV(2, 2), + }; + EditableFace qA, qB, qC, qD; + qA.indices = {0, 1, 4, 3}; + qB.indices = {1, 2, 5, 4}; + qC.indices = {3, 4, 7, 6}; + qD.indices = {4, 5, 8, 7}; + sub.faces = { qA, qB, qC, qD }; + triangulateFaces(sub); + mesh.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(mesh)); + ASSERT_EQ(he.faceCount(), 4u); + + auto newVerts = he.bevelVerticesNgon({4}, 0.1f); + EXPECT_EQ(newVerts.size(), 4u) + << "one inner vertex per incident face"; + EXPECT_TRUE(he.validate()); + + int active = 0; + int quads = 0; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + ++active; + if (he.faceVertices(static_cast(f)).size() == 4) ++quads; + } + EXPECT_EQ(active, 5) + << "4 modified quads + 1 cap quad"; + EXPECT_EQ(quads, 5); +} + TEST(HalfEdgeMeshStandalone, BevelEdgesNgonRejectsChainedSelection) { // 4 quads in a + arrangement around a central vertex, so every // selected edge is INTERIOR (not a boundary). Two edges chained From 96930e866fb9092230abe1a4e777a253820c27de Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 29 Apr 2026 03:55:45 -0400 Subject: [PATCH 27/34] fix(quads): bevelEdgesNgon resolves edge by vertex pair between iterations Per-iteration the function does retireFace + appendFace + rebuildEdgesAndTwins, which renumbers edge slots and may invalidate the captured face indices. The cached EdgeInfo's `edgeIdx`, `f1`, `f2` then no longer match the live mesh, so the second edge in a multi-edge selection used to operate on stale references and corrupt the topology. Fix: re-resolve the edge by (v1, v2) vertex pair against the live mesh at the top of each iteration. Vertex indices are append-only and stable across rebuilds. Refresh f1, f2, subMeshIndex, and the face loops from the resolved live edge before computing the bevel. Mirrors the same pattern cutPath / dissolveEdges already use for this class of staleness bug. --- src/HalfEdgeMesh.cpp | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index a97fc94c5..51d0613a5 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -3124,18 +3124,39 @@ std::vector HalfEdgeMesh::bevelEdgesNgon( return idx; }; - for (const auto& info : clean) { - // Re-validate against the live mesh (previous iterations may have - // mutated submeshes, though the chained-edge check above prevents - // most interference). - if (info.f1 >= static_cast(m_faces.size())) continue; - if (info.f2 >= static_cast(m_faces.size())) continue; - if (m_faces[info.f1].halfEdge < 0) continue; - if (m_faces[info.f2].halfEdge < 0) continue; - // Re-fetch loops in case prior iterations changed orientation. + for (auto info : clean) { + // Re-resolve the edge by vertex pair against the LIVE mesh. + // Previous iterations may have: + // - retired the captured edge index (face replacement adds + // new edges, rebuildEdgesAndTwins reorders), + // - retired the captured face indices (appendFace's slot + // allocation may now hold a different face). + // Vertex indices are append-only and stable, so look up the + // edge by (v1, v2) every time. + int liveEdge = -1; + for (size_t e = 0; e < m_edges.size(); ++e) { + if (m_edges[e].halfEdge < 0) continue; + const auto [a, b] = edgeVertices(static_cast(e)); + if ((a == info.v1 && b == info.v2) + || (a == info.v2 && b == info.v1)) { + liveEdge = static_cast(e); + break; + } + } + if (liveEdge < 0) continue; + info.edgeIdx = liveEdge; + const auto [liveF1, liveF2] = edgeFaces(liveEdge); + if (liveF1 < 0 || liveF2 < 0) continue; // boundary now + info.f1 = liveF1; + info.f2 = liveF2; + info.subMeshIndex = m_faces[info.f1].subMeshIndex; + + // Re-fetch loops in case prior iterations changed them. auto loopF1 = faceVertices(info.f1); auto loopF2 = faceVertices(info.f2); if (loopF1.size() < 3 || loopF2.size() < 3) continue; + info.loopF1 = loopF1; + info.loopF2 = loopF2; const float w = effectiveWidth(info); if (w < 1e-6f) continue; From 39018acd553e3c5824fbaccb46c25f7fab1d244c Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 29 Apr 2026 04:00:57 -0400 Subject: [PATCH 28/34] fix(quads): bevelEdgesNgon splices chain into neighbor faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Higher-valence endpoints (vertex on more than two faces) left a non- manifold gap with the previous implementation: the corner cap triangle bridged innerVF1 → v → innerVF2, but the OTHER incident faces (e.g. the cube's left/right faces when beveling its top edge) still terminated at v with the original v-edges. The new chamfer's side edges had no twin in those neighbor faces, so the result was non-manifold around v. Fix: for each neighbor face g of v1 / v2 (not f1 / f2), splice the appropriate inner vertex into g's loop right next to v. Walk g's loop; when an outgoing edge is the (v, sideNeighbor) edge that's shared with f1 or f2, insert the corresponding inner vertex between v and sideNeighbor. The neighbor's arity grows by one per splice, but the manifold stays closed: each new edge has exactly two adjacent faces. The standalone corner-cap triangle is now emitted ONLY when v is "isolated" (incident only to f1 and f2, no neighbor faces) — that's the 2-face local topology the previous version handled correctly. For higher valence the splicing replaces it. Tests - New BevelEdgesNgonOnQuadCubeProducesManifoldOutput: quad cube with valence-3 endpoints. Without the splicing fix the result was non-manifold; now it's clean. - 241 standalone tests pass. --- src/HalfEdgeMesh.cpp | 170 +++++++++++++++++++++++++++++++++----- src/HalfEdgeMesh_test.cpp | 47 +++++++++++ 2 files changed, 198 insertions(+), 19 deletions(-) diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 51d0613a5..325b0b541 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -3196,10 +3196,117 @@ std::vector HalfEdgeMesh::bevelEdgesNgon( else newLoopF2.push_back(v); } + // Find the f1-side and f2-side neighbors of v1 and v2 BEFORE we + // retire the original f1/f2. These tell us how to splice the + // chain into each neighbor face. + auto findAdjacentInLoop = [](const std::vector& loop, + int target) -> std::pair { + // Returns (prev, next) vertex of `target` in the loop's + // winding order, or {-1, -1} if not found. + const int n = static_cast(loop.size()); + for (int i = 0; i < n; ++i) { + if (loop[i] == target) { + return { loop[(i + n - 1) % n], loop[(i + 1) % n] }; + } + } + return { -1, -1 }; + }; + const auto [prevV1F1, nextV1F1] = findAdjacentInLoop(loopF1, info.v1); + const auto [prevV1F2, nextV1F2] = findAdjacentInLoop(loopF2, info.v1); + const auto [prevV2F1, nextV2F1] = findAdjacentInLoop(loopF1, info.v2); + const auto [prevV2F2, nextV2F2] = findAdjacentInLoop(loopF2, info.v2); + // The "non-edge neighbor" of v1 in f1 is whichever of prev/next + // ISN'T v2 (since the v1-v2 edge is the beveled one). + auto nonEdgeNeighbor = [](int prev, int next, int otherEnd) { + return (prev == otherEnd) ? next : prev; + }; + const int v1F1NonEdge = nonEdgeNeighbor(prevV1F1, nextV1F1, info.v2); + const int v1F2NonEdge = nonEdgeNeighbor(prevV1F2, nextV1F2, info.v2); + const int v2F1NonEdge = nonEdgeNeighbor(prevV2F1, nextV2F1, info.v1); + const int v2F2NonEdge = nonEdgeNeighbor(prevV2F2, nextV2F2, info.v1); + + // Collect ALL neighbor faces of v1 and v2 (those that contain + // v1 or v2 but aren't f1 or f2). We splice the bevel chain into + // each so the manifold stays closed at higher-valence vertices. + std::set neighborFaces; + for (int f : facesAroundVertex(info.v1)) { + if (f != info.f1 && f != info.f2) neighborFaces.insert(f); + } + for (int f : facesAroundVertex(info.v2)) { + if (f != info.f1 && f != info.f2) neighborFaces.insert(f); + } + + // Snapshot each neighbor's loop now (before retire), and + // compute the spliced loop. + struct NeighborRebuild { + int oldFace; + int subIdx; + std::vector newLoop; + }; + std::vector neighborRebuilds; + for (int g : neighborFaces) { + const auto loopG = faceVertices(g); + std::vector spliced; + spliced.reserve(loopG.size() + 2); + for (size_t i = 0; i < loopG.size(); ++i) { + const int cur = loopG[i]; + const int next = loopG[(i + 1) % loopG.size()]; + spliced.push_back(cur); + + // Splice points: when an outgoing edge (cur, next) is + // a v-edge of v1 or v2 that's shared with f1 or f2, + // insert the corresponding inner vertex between cur + // and next. + // + // For v1: if (cur, next) == (v1, v1F1NonEdge) or + // (v1F1NonEdge, v1) — i.e., the edge between v1 and + // its f1-side non-edge neighbor — insert innerV1F1 + // adjacent to v1 on the v1F1NonEdge side. + auto check = [&](int v, int innerSide, int sideNeighbor) { + // Edge (cur, next) is the (v, sideNeighbor) edge. + // Splice direction: if cur == v, then innerSide goes + // BETWEEN cur and next (after cur). If next == v, + // innerSide goes BEFORE next (after cur, before + // next when we get to the next iteration). For + // simplicity always insert on cur's side: if cur + // is v and next is sideNeighbor, push innerSide + // right now. If cur is sideNeighbor and next is v, + // also push innerSide right now (so the order is + // sideNeighbor, innerSide, v). + if (sideNeighbor < 0) return false; + if ((cur == v && next == sideNeighbor) + || (cur == sideNeighbor && next == v)) { + spliced.push_back(innerSide); + return true; + } + return false; + }; + if (check(info.v1, innerV1F1, v1F1NonEdge)) {} + else if (check(info.v1, innerV1F2, v1F2NonEdge)) {} + else if (check(info.v2, innerV2F1, v2F1NonEdge)) {} + else if (check(info.v2, innerV2F2, v2F2NonEdge)) {} + } + // Drop consecutive duplicates if any (defensive). + std::vector dedup; + dedup.reserve(spliced.size()); + for (int x : spliced) { + if (!dedup.empty() && dedup.back() == x) continue; + dedup.push_back(x); + } + if (!dedup.empty() && dedup.front() == dedup.back()) + dedup.pop_back(); + if (dedup.size() < 3) continue; + neighborRebuilds.push_back({g, m_faces[g].subMeshIndex, std::move(dedup)}); + } + retireFace(info.f1); retireFace(info.f2); appendFace(newLoopF1, info.subMeshIndex); appendFace(newLoopF2, info.subMeshIndex); + for (const auto& nr : neighborRebuilds) { + retireFace(nr.oldFace); + appendFace(nr.newLoop, nr.subIdx); + } // Chamfer strip + corner caps. Winding: pick whichever order // matches f1's winding direction across the beveled edge so the @@ -3283,25 +3390,50 @@ std::vector HalfEdgeMesh::bevelEdgesNgon( appendFace(seg, info.subMeshIndex); } - // Corner fans at v1 and v2: a triangle fan from the original - // endpoint v through every consecutive chain pair. For - // segments=1 this is one triangle; for N segments it's N - // triangles per endpoint. - for (int i = 0; i < segments; ++i) { - const int aV1 = chainV1[i]; - const int bV1 = chainV1[i + 1]; - const int aV2 = chainV2[i]; - const int bV2 = chainV2[i + 1]; - if (f1WalksV1ToV2) { - // Cap at v1: (bV1 [f2-side end], info.v1, aV1 [f1-side end]). - // For segments > 1, the fan has aV1 at one end and bV1 at the - // other — winding matches the chamfer segment. - appendFace({bV1, info.v1, aV1}, info.subMeshIndex); - // Cap at v2: (aV2, info.v2, bV2). - appendFace({aV2, info.v2, bV2}, info.subMeshIndex); - } else { - appendFace({aV1, info.v1, bV1}, info.subMeshIndex); - appendFace({bV2, info.v2, aV2}, info.subMeshIndex); + // Corner fans at v1 and v2: ONLY needed when the endpoint has + // no other incident faces (so neighbor splicing didn't close + // the manifold around it). For higher-valence vertices, the + // neighbor-rebuild step above already inserted innerVF1 / + // innerVF2 into each neighbor face's loop, so the chamfer's + // sides connect cleanly to the surrounding mesh. + bool v1Isolated = true; + bool v2Isolated = true; + for (const auto& nr : neighborRebuilds) { + // The original neighbor face's loop contained either v1 + // or v2 (or both). Re-fetch from the original loops we + // captured in `neighborFaces` collection — but we don't + // have them anymore. Easier proxy: if any neighbor's + // newLoop contains both v1 (still there) and innerV1F1 + // or innerV1F2, that neighbor closes around v1. + const auto& loop = nr.newLoop; + const bool containsV1 = + std::find(loop.begin(), loop.end(), info.v1) != loop.end(); + const bool containsV2 = + std::find(loop.begin(), loop.end(), info.v2) != loop.end(); + if (containsV1) v1Isolated = false; + if (containsV2) v2Isolated = false; + } + + if (v1Isolated) { + for (int i = 0; i < segments; ++i) { + const int aV1 = chainV1[i]; + const int bV1 = chainV1[i + 1]; + if (f1WalksV1ToV2) { + appendFace({bV1, info.v1, aV1}, info.subMeshIndex); + } else { + appendFace({aV1, info.v1, bV1}, info.subMeshIndex); + } + } + } + if (v2Isolated) { + for (int i = 0; i < segments; ++i) { + const int aV2 = chainV2[i]; + const int bV2 = chainV2[i + 1]; + if (f1WalksV1ToV2) { + appendFace({aV2, info.v2, bV2}, info.subMeshIndex); + } else { + appendFace({bV2, info.v2, aV2}, info.subMeshIndex); + } } } diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index d0a7ce0b5..0ade1982f 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -3247,6 +3247,53 @@ TEST(HalfEdgeMeshStandalone, BevelEdgesNgonSegments3ProducesRoundedChamfer) { EXPECT_EQ(tris, 6); } +TEST(HalfEdgeMeshStandalone, BevelEdgesNgonOnQuadCubeProducesManifoldOutput) { + // Quad cube: 8 verts, 6 quad faces. Bevel one of the cube's edges + // (top-back, between v2 and v3). The endpoints have valence 3 — so + // the test exercises neighbor-face splicing: each non-beveled face + // adjacent to v2 / v3 must absorb the corresponding inner vertex + // into its loop, otherwise the chamfer leaves a non-manifold gap. + EditableMesh em; + EditableSubMesh sub; + sub.materialName = "M"; + auto mkV = [](float x, float y, float z) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, z); + v.normal = Ogre::Vector3::UNIT_Y; v.hasNormal = true; + return v; + }; + sub.vertices = { + mkV(-1,-1,-1), mkV(1,-1,-1), mkV(-1,1,-1), mkV(1,1,-1), // 0..3 + mkV(-1,-1, 1), mkV(1,-1, 1), mkV(-1,1, 1), mkV(1,1, 1), // 4..7 + }; + EditableFace fBack, fFront, fTop, fBottom, fLeft, fRight; + fBack.indices = {0, 2, 3, 1}; // -Z (CCW from outside) + fFront.indices = {5, 7, 6, 4}; // +Z + fBottom.indices = {0, 1, 5, 4}; // -Y + fTop.indices = {2, 6, 7, 3}; // +Y + fLeft.indices = {0, 4, 6, 2}; // -X + fRight.indices = {1, 3, 7, 5}; // +X + sub.faces = { fBack, fFront, fBottom, fTop, fLeft, fRight }; + triangulateFaces(sub); + em.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + ASSERT_EQ(he.faceCount(), 6u); + const int topBackEdge = findEdge(he, 2, 3); + ASSERT_GE(topBackEdge, 0); + + auto newVerts = he.bevelEdgesNgon({topBackEdge}, 0.1f); + EXPECT_EQ(newVerts.size(), 4u); + EXPECT_TRUE(he.validate()); + + EditableMesh back; + ASSERT_TRUE(he.toEditableMesh(back)); + EXPECT_TRUE(isManifold(back)) + << "neighbor-face splicing must close the chamfer around the cube's " + << "valence-3 endpoints"; +} + TEST(HalfEdgeMeshStandalone, BevelVerticesNgonOnQuadCornerKeepsQuads) { // 4 quads in a + arrangement around a central vertex v4 of valence 4. // Bevel v4. Expected: From 587cc2e148cf8100bc635c9ef58fdd4ef7eb0c5b Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 29 Apr 2026 04:02:42 -0400 Subject: [PATCH 29/34] docs(quads): annotate triangle-only bevel functions as legacy path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document at the top of bevelEdges and bevelVertices that they are the triangle-only path, that controllers should dispatch to the n-gon variant when the editable mesh has n-gon canonical faces, and that splitting these big functions into phase-sized helpers is now lower priority since the common case (quad-imported assets) takes the simpler n-gon path. Pure docs change — no behaviour difference. --- src/HalfEdgeMesh.cpp | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 325b0b541..53bd0db39 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -1026,10 +1026,24 @@ std::vector HalfEdgeMesh::extrudeEdges(const std::vector& edgeIndices) // safe even if a caller bypasses the UI. static constexpr int kMaxBevelSegments = 16; +// IMPORTANT: this is the TRIANGLE-ONLY edge bevel. It assumes every face +// adjacent to a beveled edge is a triangle and uses that assumption +// throughout (effectiveWidth's "third vertex" clamp, retriangulateBeveled- +// Face's fan emission, the coplanar-sibling crease detection, etc.). On +// quad-imported meshes those assumptions silently produce wrong widths +// or invisible chamfers — controllers should dispatch to `bevelEdgesNgon` +// (below) instead when the input mesh has n-gon canonical faces. +// +// This function is preserved as-is because the triangle-bevel features +// it carries (crease detection, chained selections, coplanar-sibling +// merging, full segments + profile support) are deeper than the simple +// n-gon variant. See `EditModeController::applyBevelTopology` for the +// actual dispatch logic. +// // The Phase 1-7 bevel topology below has high cognitive complexity that -// predates this PR; splitting it into phase-sized helpers is tracked as -// a separate refactor. This PR only adds the optional profilePoints -// parameter and uses it inside buildSegmentVerts. +// predates the n-gon work; splitting it into phase-sized helpers is +// tracked as a separate refactor (low priority now that `bevelEdgesNgon` +// covers the common quad-mesh case). std::vector HalfEdgeMesh::bevelEdges(const std::vector& edgeIndices, // NOSONAR(cpp:S3776) float width, int segments, @@ -3605,7 +3619,15 @@ std::vector HalfEdgeMesh::bevelVerticesNgon( // bevelVertices — corner cut at each selected vertex. // =========================================================================== // -// For each vertex v of valence N >= 3: +// IMPORTANT: this is the TRIANGLE-ONLY vertex bevel. Like the triangle- +// only edge bevel above, it assumes incident faces are triangles and +// uses the "two edge offsets per face" trick to retriangulate. On +// quad-imported meshes its assumptions break — controllers should +// dispatch to `bevelVerticesNgon` (above) when the input mesh has +// n-gon canonical faces. See `EditModeController::applyBevelVertexTopology` +// for the actual dispatch. +// +// Algorithm (kept here for reference): // 1. Walk v's face ring, collecting the ring-ordered sequence of incident // faces and their half-edges. Skip boundary verts for the MVP. // 2. For each outgoing edge v->x_i (i = 0..N-1), create a new vertex o_i @@ -3618,9 +3640,9 @@ std::vector HalfEdgeMesh::bevelVerticesNgon( // 4. Emit a new "cap" face using all o_i in ring order. Valence 3 gives // a single triangle, higher valence an N-gon fanned from o_0. // -// This MVP emits a flat cap (segments=1). Shaped profiles and segments>1 -// are TODO (rounded dome). Unused params are accepted for forward -// compatibility with the edge-bevel API. +// Triangle-only bevel features kept here: shaped profiles, segments > 1 +// (rounded dome), crease detection. The n-gon variant is currently a +// flat single-segment MVP. std::vector HalfEdgeMesh::bevelVertices( const std::vector& vertexIndices, float width, From cf46f436b55e216600526ebd0672c7ca2d8dedd3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 29 Apr 2026 04:54:16 -0400 Subject: [PATCH 30/34] fix(quads): address Codex P1 + P2 review on PR #339 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — n-gon edge bevel left boundary edges at endpoints with neighbors. The previous "skip caps when v has neighbors" optimization meant the chamfer's v-side edge (innerVF1, innerVF2) had no twin face — neighbor-face splicing inserts those inner verts AROUND v in the neighbor loop, but never the innerVF1↔innerVF2 edge itself. Closed meshes (cube interior edges, valence ≥ 3) ended up with boundary seams at the chamfer endpoints. Fix: emit corner caps unconditionally. The cap closes the chamfer side-edge AND its other two edges twin against the neighbor splice's (v, innerVF) edges. Cap winding flipped to (v, bV1, aV1) (resp. (v, aV1, bV1)) so it walks edges in the opposite direction from the neighbor splice — twins match, no inverted-tri non-manifold. P2 — n-gon vertex bevel partially mutated state on failure. The inner-vertex append happened inline with the per-face validation loop, so a mid-loop `continue` could leave orphan vertices in m_vertices and report them in the return value, making callers think the bevel succeeded. Fix: two-phase. Validate every incident face FIRST and stash each inner position in a temporary vector. Only if all faces validate do we actually push the new HEVertex slots and grow newVertices. Tests - BevelEdgesNgonOnQuadCubeProducesManifoldOutput: previously passed by isManifold's permissive boundary check; with the cap-restore fix the result is now properly closed (no boundary edges at all on a closed input). - 241 standalone tests still pass. --- src/HalfEdgeMesh.cpp | 96 +++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 51 deletions(-) diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 53bd0db39..9135fe5b1 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -3404,50 +3404,33 @@ std::vector HalfEdgeMesh::bevelEdgesNgon( appendFace(seg, info.subMeshIndex); } - // Corner fans at v1 and v2: ONLY needed when the endpoint has - // no other incident faces (so neighbor splicing didn't close - // the manifold around it). For higher-valence vertices, the - // neighbor-rebuild step above already inserted innerVF1 / - // innerVF2 into each neighbor face's loop, so the chamfer's - // sides connect cleanly to the surrounding mesh. - bool v1Isolated = true; - bool v2Isolated = true; - for (const auto& nr : neighborRebuilds) { - // The original neighbor face's loop contained either v1 - // or v2 (or both). Re-fetch from the original loops we - // captured in `neighborFaces` collection — but we don't - // have them anymore. Easier proxy: if any neighbor's - // newLoop contains both v1 (still there) and innerV1F1 - // or innerV1F2, that neighbor closes around v1. - const auto& loop = nr.newLoop; - const bool containsV1 = - std::find(loop.begin(), loop.end(), info.v1) != loop.end(); - const bool containsV2 = - std::find(loop.begin(), loop.end(), info.v2) != loop.end(); - if (containsV1) v1Isolated = false; - if (containsV2) v2Isolated = false; - } - - if (v1Isolated) { - for (int i = 0; i < segments; ++i) { - const int aV1 = chainV1[i]; - const int bV1 = chainV1[i + 1]; - if (f1WalksV1ToV2) { - appendFace({bV1, info.v1, aV1}, info.subMeshIndex); - } else { - appendFace({aV1, info.v1, bV1}, info.subMeshIndex); - } - } - } - if (v2Isolated) { - for (int i = 0; i < segments; ++i) { - const int aV2 = chainV2[i]; - const int bV2 = chainV2[i + 1]; - if (f1WalksV1ToV2) { - appendFace({aV2, info.v2, bV2}, info.subMeshIndex); - } else { - appendFace({bV2, info.v2, aV2}, info.subMeshIndex); - } + // Corner fans at v1 and v2 — ALWAYS emitted (Codex P1: previous + // "skip caps when v has neighbors" optimization left chamfer + // side-edges as boundaries since the spliced neighbor faces + // don't include the (innerVF1, innerVF2) edge). + // + // Winding: cap is the *outward* face of the corner. Walking + // a CCW neighbor splice produces edges (innerVF2, v) and + // (v, innerVF1) on the f1WalksV1ToV2 case. The cap must + // traverse those edges in the opposite direction to match as + // twins, so the cap walks (v, innerVF2, innerVF1) — same + // information as the previous winding but the v vertex sits + // FIRST so the edges walk away from v in the cap. + for (int i = 0; i < segments; ++i) { + const int aV1 = chainV1[i]; // f1-side + const int bV1 = chainV1[i + 1]; // f2-side + const int aV2 = chainV2[i]; + const int bV2 = chainV2[i + 1]; + if (f1WalksV1ToV2) { + // v1 cap: walk v1 → bV1(f2-side) → aV1(f1-side). The + // splice into Left contains edges (v1 → aV1) and + // (bV1 → v1), so the cap's reversed (v1 → bV1) and + // (aV1 → v1) match as twins. + appendFace({info.v1, bV1, aV1}, info.subMeshIndex); + appendFace({info.v2, aV2, bV2}, info.subMeshIndex); + } else { + appendFace({info.v1, aV1, bV1}, info.subMeshIndex); + appendFace({info.v2, bV2, aV2}, info.subMeshIndex); } } @@ -3539,11 +3522,15 @@ std::vector HalfEdgeMesh::bevelVerticesNgon( if (shortestIncident < 1e-6f) continue; const float w = std::min(width, shortestIncident * 0.49f); - // Compute one inner vertex per incident face. Direction: - // (centroid_of_f - v), normalised. Cap face walks these inner - // vertices in the same order as `incident` (which is ring order). - std::vector innerVerts; - innerVerts.reserve(incident.size()); + // Validate every incident face FIRST (compute and stash each + // inner-vertex position), only commit the new vertices after + // the whole vertex passes validation. Without this two-phase + // approach, a partial failure mid-loop would leave orphan + // vertices in m_vertices and report them in `newVertices`, + // making callers think the bevel succeeded while topology + // is unchanged. (Codex P2 review.) + std::vector innerPositions; + innerPositions.reserve(incident.size()); bool ok = true; for (int f : incident) { const auto loop = faceVertices(f); @@ -3554,15 +3541,22 @@ std::vector HalfEdgeMesh::bevelVerticesNgon( Ogre::Vector3 dir = centroid - m_vertices[v].position; if (dir.length() < 1e-6f) { ok = false; break; } dir.normalise(); + innerPositions.push_back(m_vertices[v].position + dir * w); + } + if (!ok || innerPositions.size() != incident.size()) continue; + + // All faces validated — now actually create the inner vertices. + std::vector innerVerts; + innerVerts.reserve(incident.size()); + for (const auto& pos : innerPositions) { HEVertex nv = m_vertices[v]; - nv.position = m_vertices[v].position + dir * w; + nv.position = pos; nv.halfEdge = -1; const int idx = static_cast(m_vertices.size()); m_vertices.push_back(std::move(nv)); newVertices.push_back(idx); innerVerts.push_back(idx); } - if (!ok || innerVerts.size() != incident.size()) continue; // Build a face → innerVert mapping for the substitution step. std::map faceToInner; From 18a27770b91db19464861b3db689385e8b6e8a67 Mon Sep 17 00:00:00 2001 From: Fernando Tonon Date: Wed, 29 Apr 2026 10:24:44 -0400 Subject: [PATCH 31/34] fix(quads): n-gon bevel chamfer twist on imported meshes (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(quads): n-gon bevel chamfer no longer twists on imported meshes Two coupled bugs surfaced when beveling edges on the user's FBX character asset: the chamfer quad twisted (one corner flipped to the wrong side), and the profile bulge inverted between v1 and v2 chains so concave/convex profiles produced saddle-shaped chamfers instead of smooth curves. faceInwardDir was winding-dependent Original formula: `inward = NewellNormal × edge`. The Newell normal flips sign on CW-wound faces, which flips the cross product result. FBX imports can deliver mixed face winding, so some inner vertices ended up on the WRONG side of the beveled edge — causing the chamfer quad to fold across the original edge (visible as a "twist" connecting v2's pair to v1's pair through the mesh interior). Fix: use `(faceCentroid - v) projected perpendicular to edge` instead. The centroid is always inside a convex face regardless of winding direction, so (centroid - v) reliably points "into the face" from any corner. No dependence on Newell-normal sign. Profile bulge axis flipped between v1 and v2 The per-endpoint outward direction `(v - chordMidpoint)` lands on opposite sides of the chamfer surface at v1 vs v2 on non- axis-aligned meshes. The bulge sign then flipped: convex profile pushed v1's chain outward but v2's chain inward → saddle. Fix: anchor sign-consistency to the chamfer centroid (mean of the four inner vertices). The centroid sits inside the original corner; the correct outward direction has positive dot with `(v - centroid)` at BOTH endpoints. If a local outward computation flips, negate it. Both chains now bulge the same way in world space. This anchor is mesh-orientation-independent — no reliance on face-normal bisectors or per-endpoint reference passing. The centroid is geometrically meaningful: it's where the chamfer surface "centers" relative to the original corner. * fix(quads): use loop-neighbor anchor for faceInwardDir on concave n-gons Codex P2 on PR #340: my centroid-based `faceInwardDir` assumed the vertex-average centroid lies in the interior half-plane of every boundary edge, which is false for concave n-gons (the centroid can sit outside the local edge half-plane). On concave faces my inward vector flipped, pushing inner vertices outward — chamfer folded / self-intersected. Fix: replace the centroid anchor with v's non-edge LOOP NEIGHBOR. In any simple polygon (convex or concave), the third vertex of the corner at v topologically sits in the face's interior relative to the (v, otherEndpoint) edge. (toAnchor = nonEdgeNeighbor - v), projected perpendicular to the edge, gives a correct inward direction without depending on: - winding sign (Newell-normal-based formulas flip on CW), - convexity (centroid-based formulas mis-fire on concave). Tests still pass (241). --- src/HalfEdgeMesh.cpp | 78 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index 9135fe5b1..f6417b452 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -3086,25 +3086,48 @@ std::vector HalfEdgeMesh::bevelEdgesNgon( // ---- 4. Helper: perpendicular-in-face inward direction at v, in // face's plane, perpendicular to edge (v, otherEndpoint). ---- auto faceInwardDir = [this](int faceIdx, int v, int otherEndpoint) -> Ogre::Vector3 { - // Newell normal of the face. + // Inward direction at vertex v on face `faceIdx`, perpendicular + // to the edge (v, otherEndpoint), in the face's plane, pointing + // INTO the face's interior. + // + // Use v's "non-edge neighbor" in the face loop as the inside + // anchor: in any simple polygon (convex OR concave), the third + // vertex of the corner at v sits in the face's interior half- + // plane relative to the (v, otherEndpoint) edge. The vector + // (nonEdgeNeighbor - v), projected perpendicular to the edge, + // points reliably into the face. + // + // Robust on: + // - CW-wound faces (no Newell-normal sign dependency). + // - Concave faces (centroid-based formulas can place the + // anchor outside the local edge half-plane; the loop + // neighbor is always topologically adjacent). + // + // (Codex P2 review: replaced an earlier centroid-based + // formula that mis-fired on concave n-gons.) const auto loop = faceVertices(faceIdx); - Ogre::Vector3 nrm = Ogre::Vector3::ZERO; - for (size_t i = 0; i < loop.size(); ++i) { - const auto& a = m_vertices[loop[i]].position; - const auto& b = m_vertices[loop[(i + 1) % loop.size()]].position; - nrm.x += (a.y - b.y) * (a.z + b.z); - nrm.y += (a.z - b.z) * (a.x + b.x); - nrm.z += (a.x - b.x) * (a.y + b.y); + if (loop.size() < 3) return Ogre::Vector3::ZERO; + const int n = static_cast(loop.size()); + int posV = -1; + for (int i = 0; i < n; ++i) { + if (loop[i] == v) { posV = i; break; } } - if (nrm.length() < 1e-8f) return Ogre::Vector3::ZERO; - nrm.normalise(); - // Edge direction (v → otherEndpoint). - Ogre::Vector3 edge = m_vertices[otherEndpoint].position - m_vertices[v].position; + if (posV < 0) return Ogre::Vector3::ZERO; + const int prev = loop[(posV + n - 1) % n]; + const int next = loop[(posV + 1) % n]; + // The loop neighbor that ISN'T otherEndpoint is the corner + // anchor; it sits in the face's interior. + const int nonEdgeNeighbor = (prev == otherEndpoint) ? next : prev; + if (nonEdgeNeighbor == otherEndpoint) return Ogre::Vector3::ZERO; + + Ogre::Vector3 toAnchor = m_vertices[nonEdgeNeighbor].position + - m_vertices[v].position; + Ogre::Vector3 edge = m_vertices[otherEndpoint].position + - m_vertices[v].position; if (edge.length() < 1e-8f) return Ogre::Vector3::ZERO; edge.normalise(); - // Inward = normal × edge (perpendicular to edge, in face plane, - // pointing into the face's interior assuming CCW winding). - Ogre::Vector3 inward = nrm.crossProduct(edge); + // Project toAnchor perpendicular to edge. + Ogre::Vector3 inward = toAnchor - edge * toAnchor.dotProduct(edge); if (inward.length() < 1e-8f) return Ogre::Vector3::ZERO; inward.normalise(); return inward; @@ -3348,6 +3371,22 @@ std::vector HalfEdgeMesh::bevelEdgesNgon( // chord innerVF1→innerVF2, plus a bulge along the "outward" // axis (toward v's original position, projected perpendicular // to the chord) controlled by profilePoints[i-1]. + // Profile bulge axis. Compute the per-endpoint outward + // direction (in each endpoint's chamfer cross-section + // plane), then enforce sign-consistency between v1 and v2 + // using a global anchor: the average of the four inner + // vertices is the chamfer's centroid, which sits "inside" + // the original corner. The OUTWARD direction at each + // endpoint must point AWAY from that centroid — pV is + // outside the corner, the chamfer centroid is inside it, + // so `(pV - centroid)` is the world-space outward sense. + // We use it as the alignment reference. + const Ogre::Vector3 chamferCentroid = + (m_vertices[innerV1F1].position + + m_vertices[innerV1F2].position + + m_vertices[innerV2F1].position + + m_vertices[innerV2F2].position) * 0.25f; + auto buildChain = [&](int v, int innerVF1, int innerVF2) { std::vector chain; chain.reserve(segments + 1); @@ -3362,6 +3401,15 @@ std::vector HalfEdgeMesh::bevelEdgesNgon( outward -= chord * (outward.dotProduct(chord) / c2); if (outward.length() > 1e-6f) outward.normalise(); else outward = Ogre::Vector3::ZERO; + // Anchor against the global outward sense: the chamfer + // centroid is inside the corner; the correct outward + // direction has positive dot with (pV - centroid). + // Both endpoints agree on this anchor so both chains + // bulge the same way in world space. + const Ogre::Vector3 globalOutward = pV - chamferCentroid; + if (outward.dotProduct(globalOutward) < 0.0f) { + outward = -outward; + } for (int i = 1; i < segments; ++i) { const float t = static_cast(i) / static_cast(segments); From bf750f3672f2b1081c60cd1991339669775999f2 Mon Sep 17 00:00:00 2001 From: Fernando Tonon Date: Wed, 29 Apr 2026 10:49:10 -0400 Subject: [PATCH 32/34] feat(quads): loop cut (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop cut walks the perpendicular ring of quads adjacent to a selected edge, bisecting each one with a new midpoint chain. Quad- only operation — that's the geometric definition: triangles have no opposite-edge correspondence, so a "loop" through tri-tri pairs is ambiguous and not implemented in this MVP. Matches Blender's behaviour (loop cut on a triangulated mesh is also a no-op there). HalfEdgeMesh::loopCut(startEdgeIdx) Walk plan: - Start: the two faces adjacent to startEdgeIdx. - Each step: in the current quad, find the opposite edge (the side two positions away in the loop) and record the rail pair (entry edge + opposite). Cross opposite into the next face. - Stop on: closed loop (back to start edge), boundary edge (no second face), non-quad face (no opposite-edge correspondence). Walk both directions from the start edge so an interior edge of an open mesh produces cuts on both sides. visitedFaces guards against re-entry once a closed ring closes. Materialisation: collect rails up-front (vertex-pair-keyed since edge indices shift across splitEdge). Per step, ensure midpoint on each rail (shared between consecutive steps via map cache), then splitFace bisects the now-hexagonal face along the new diagonal. Returns the new midpoint vertex indices in walk order. EditModeController::loopCutSelection() Edge-mode only. Uses the FIRST selected edge as start (multi- edge loop cuts aren't in MVP scope — each cut is independent). Pushes one undo command labeled "Loop Cut". UI Toolbar button (‖ double vertical line) next to Fill, with Ctrl+R shortcut. The Ctrl modifier disambiguates from R = Scale mode (Unity convention). Falls through to Scale when the loop cut returns 0 (e.g. nothing selected, non-quad neighborhood), so Ctrl+R remains harmless outside the loop-cut context. Tests - LoopCutOnQuadStripCutsEachQuadOnce: 3-quad strip → 4 rail midpoints, 6 quads after cut. - LoopCutClosedRingOnQuadCubeReturnsToStart: quad cube → 4 rail midpoints (closed ring), 10 quads (6 + 4 cuts). - LoopCutFailsOnNonQuadAdjacency: triangle pair → empty. - LoopCutFailsOnInvalidEdgeIndex: -1 / out-of-range → empty. - 244 standalone tests pass. --- src/EditModeController.cpp | 73 ++++++++++++++++++ src/EditModeController.h | 19 +++++ src/HalfEdgeMesh.cpp | 152 +++++++++++++++++++++++++++++++++++++ src/HalfEdgeMesh.h | 28 +++++++ src/HalfEdgeMesh_test.cpp | 130 +++++++++++++++++++++++++++++++ src/mainwindow.cpp | 37 ++++++++- 6 files changed, 437 insertions(+), 2 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 35b3ac199..e90197d88 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -3398,6 +3398,79 @@ int EditModeController::subdivideSelection() return static_cast(targetFaces.size()); } +int EditModeController::loopCutSelection() +{ + if (!m_editModeActive || !m_editableMesh || !m_editEntity) return 0; + if (m_selectionMode != EdgeMode) return 0; + if (m_selectedEdges.empty()) return 0; + + // Cancel any active interactive preview before mutating topology. + if (m_bevelSession.active) cancelBevel(); + if (m_knifeSession.active) cancelKnife(); + + // Loop cut takes a SINGLE start edge — the first one in the + // selection if multiple are selected. Convert (min, max) global + // vertex pair to an HE edge index against the live mesh. + const auto firstEdge = *m_selectedEdges.begin(); + const int targetMinV = firstEdge.first; + const int targetMaxV = firstEdge.second; + + HalfEdgeMesh hm; + if (!hm.buildFromEditableMesh(*m_editableMesh)) return 0; + int startEdge = -1; + for (size_t e = 0; e < hm.edgeCount(); ++e) { + const auto [a, b] = hm.edgeVertices(static_cast(e)); + if (std::min(a, b) == targetMinV && std::max(a, b) == targetMaxV) { + startEdge = static_cast(e); + break; + } + } + if (startEdge < 0) return 0; + + auto originalSubMeshes = m_editableMesh->subMeshes(); + const auto preSelectedVerts = m_selectedVertices; + const auto preSelectedEdges = m_selectedEdges; + const auto preSelectedFaces = m_selectedFaces; + + const auto newHE = hm.loopCut(startEdge); + if (newHE.empty()) return 0; + + EditableMesh updated; + if (!hm.toEditableMesh(updated)) return 0; + m_editableMesh->subMeshes() = std::move(updated.subMeshes()); + + if (m_normalsMode == 0) m_editableMesh->recalculateNormals(); + else m_editableMesh->recalculateNormalsFlat(); + + m_editableMesh->resizeEntityBuffers(m_editEntity); + rewriteEntityAfterTopologyChange(m_editEntity); + + // Clear selection — pre-op edge IDs are stale after the topology + // mutation. The user can hit-test the new loop directly if they + // want to chain operations. + m_selectedVertices.clear(); + m_selectedEdges.clear(); + m_selectedFaces.clear(); + + auto* cmd = new EditMeshTopologyCommand( + std::move(originalSubMeshes), + m_editableMesh->subMeshes(), + preSelectedVerts, preSelectedEdges, preSelectedFaces, + m_selectedVertices, m_selectedEdges, m_selectedFaces, + QStringLiteral("Loop Cut")); + UndoManager::getSingleton()->push(cmd); + + validateMesh(); + SentryReporter::addBreadcrumb("edit_mode", + QString("Loop Cut (midpoints=%1)").arg(newHE.size())); + + updateSelectionOverlay(); + refreshNormalVisualizer(); + emit editSelectionChanged(); + emit meshDataChanged(); + return static_cast(newHE.size()); +} + int EditModeController::subdivideCatmullClarkAll() { if (!m_editModeActive || !m_editableMesh || !m_editEntity) return 0; diff --git a/src/EditModeController.h b/src/EditModeController.h index 7dec3d4f3..5f0d560cf 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -441,6 +441,25 @@ class EditModeController : public QObject */ Q_INVOKABLE int subdivideSelection(); + /** + * @brief Insert a loop cut starting from the first selected edge. + * + * Walks the chain of quads adjacent to the start edge via the + * "opposite edge" relation. Each quad in the ring is bisected by + * splitting two parallel edges at their midpoints and connecting + * the new midpoints with a new edge. The walk terminates at + * boundaries, non-quad faces, or when it loops back to the start. + * + * Requires Edge selection mode and at least one selected edge. + * Uses the FIRST selected edge as the start; multi-edge loop cuts + * are out of scope for the MVP (each cut is independent). + * + * Pushes one undo command labeled "Loop Cut". + * + * @return Number of new vertices inserted (0 on no-op / failure). + */ + Q_INVOKABLE int loopCutSelection(); + /** * @brief Subdivide the entire mesh by one Catmull-Clark step. * diff --git a/src/HalfEdgeMesh.cpp b/src/HalfEdgeMesh.cpp index f6417b452..5606dfaa3 100644 --- a/src/HalfEdgeMesh.cpp +++ b/src/HalfEdgeMesh.cpp @@ -4876,6 +4876,158 @@ std::vector HalfEdgeMesh::cutPath(const std::vector& points) return newVertices; } +// =========================================================================== +// loopCut — insert a perpendicular cut through a ring of quads. +// =========================================================================== +// +// Given a starting edge, walk the chain of quads adjacent to it via the +// "opposite edge" relation. In each quad [v0, v1, v2, v3] (where the +// shared edge with the previous step is one of its four edges, e.g. +// (v0, v1)), the OPPOSITE edge is (v2, v3). The loop cut splits both +// edges at their midpoints and connects the new midpoint vertices, +// bisecting the quad. +// +// The walk continues across each opposite edge into the NEXT quad in +// the ring (the second face adjacent to that edge). It stops when: +// - the walk returns to the starting edge (closed loop), OR +// - it encounters a non-quad face (loop cut needs the opposite-edge +// correspondence, only well-defined on quads), OR +// - it encounters a boundary edge (no second face to walk into). +// +// MVP: cuts at midpoint (t = 0.5) on each rail edge. A future +// extension can take a `t` parameter so the cut shifts along the +// loop. Profile / multi-cuts are out of scope. +// +// Returns the indices of every newly created vertex (in walk order). +// Empty on failure (bad start edge, immediate non-quad neighbor, etc). +std::vector HalfEdgeMesh::loopCut(int startEdgeIdx) +{ + std::vector newVertices; + if (startEdgeIdx < 0 + || startEdgeIdx >= static_cast(m_edges.size())) + return newVertices; + if (m_edges[startEdgeIdx].halfEdge < 0) return newVertices; + + auto oppositeEdgeInQuad = [this](int faceIdx, int va, int vb) + -> std::pair { + // For a quad face [w0, w1, w2, w3], the edge (va, vb) is one + // of its four sides. The opposite edge is two positions away + // in the loop. Return its endpoints in winding order. + const auto loop = faceVertices(faceIdx); + if (loop.size() != 4) return {-1, -1}; + for (int i = 0; i < 4; ++i) { + const int a = loop[i]; + const int b = loop[(i + 1) % 4]; + if ((a == va && b == vb) || (a == vb && b == va)) { + return { loop[(i + 2) % 4], loop[(i + 3) % 4] }; + } + } + return {-1, -1}; + }; + + auto findEdgeByVerts = [this](int va, int vb) -> int { + for (size_t e = 0; e < m_edges.size(); ++e) { + if (m_edges[e].halfEdge < 0) continue; + const auto [ea, eb] = edgeVertices(static_cast(e)); + if ((ea == va && eb == vb) || (ea == vb && eb == va)) + return static_cast(e); + } + return -1; + }; + + // Walk the ring up-front (vertex-pair-keyed, no edge-index + // dependence) and collect each quad's rail pair (entry edge + + // opposite edge). Two passes: across f1 from the starting edge, + // then across f2. visitedFaces handles closed loops. + struct WalkStep { + int face; + std::pair railA; + std::pair railB; + }; + std::vector walk; + std::set visitedFaces; + + const auto [startA, startB] = edgeVertices(startEdgeIdx); + if (startA < 0 || startB < 0) return newVertices; + + auto walkDirection = [&](int firstFace, int entryA, int entryB) { + if (firstFace < 0) return; + int curFace = firstFace; + int curA = entryA, curB = entryB; + for (int guard = 0; guard < 4096; ++guard) { + if (curFace < 0) return; + if (m_faces[curFace].halfEdge < 0) return; + if (!visitedFaces.insert(curFace).second) return; + const auto opp = oppositeEdgeInQuad(curFace, curA, curB); + if (opp.first < 0) return; // non-quad / not found + WalkStep step; + step.face = curFace; + step.railA = { curA, curB }; + step.railB = opp; + walk.push_back(step); + const int oppEdge = findEdgeByVerts(opp.first, opp.second); + if (oppEdge < 0) return; + const auto [fa, fb] = edgeFaces(oppEdge); + int nextFace = -1; + if (fa == curFace) nextFace = fb; + else if (fb == curFace) nextFace = fa; + if (nextFace < 0) return; // boundary + curFace = nextFace; + curA = opp.first; + curB = opp.second; + if ((curA == startA && curB == startB) + || (curA == startB && curB == startA)) { + return; + } + } + }; + + const auto [f1, f2] = edgeFaces(startEdgeIdx); + walkDirection(f1, startA, startB); + walkDirection(f2, startA, startB); + + if (walk.empty()) return newVertices; + + // Materialise the cut. Rails shared between consecutive steps must + // produce the SAME midpoint, so cache by vertex-pair. + auto pairKey = [](int a, int b) { + return std::make_pair(std::min(a, b), std::max(a, b)); + }; + std::map, int> railMidpoints; + + auto ensureMidpoint = [&](const std::pair& rail) -> int { + const auto k = pairKey(rail.first, rail.second); + auto it = railMidpoints.find(k); + if (it != railMidpoints.end()) return it->second; + const int eIdx = findEdgeByVerts(rail.first, rail.second); + if (eIdx < 0) return -1; + const int vMid = splitEdge(eIdx, 0.5f); + if (vMid < 0) return -1; + railMidpoints[k] = vMid; + newVertices.push_back(vMid); + return vMid; + }; + + for (const auto& step : walk) { + const int vMidA = ensureMidpoint(step.railA); + const int vMidB = ensureMidpoint(step.railB); + if (vMidA < 0 || vMidB < 0) continue; + // Re-resolve the face: splitEdge may have created replacement + // face slots. Find the live face that contains both midpoints. + int liveFace = -1; + for (int f : facesAroundVertex(vMidA)) { + const auto fv = faceVertices(f); + if (std::find(fv.begin(), fv.end(), vMidB) != fv.end()) { + liveFace = f; break; + } + } + if (liveFace < 0) continue; + splitFace(liveFace, vMidA, vMidB); + } + + return newVertices; +} + int HalfEdgeMesh::mergeVertices(const std::vector& vertexIndices, const Ogre::Vector3& targetPos) { diff --git a/src/HalfEdgeMesh.h b/src/HalfEdgeMesh.h index f82167a18..604b467d0 100644 --- a/src/HalfEdgeMesh.h +++ b/src/HalfEdgeMesh.h @@ -500,6 +500,34 @@ class HalfEdgeMesh */ std::vector cutPath(const std::vector& points); + /** + * @brief Loop cut: insert a perpendicular cut through a ring of quads. + * + * Given a starting edge, walks the chain of quads adjacent to it + * via the "opposite edge" relation. In each quad [v0, v1, v2, v3] + * (where the entry edge is one of its sides, e.g. (v0, v1)), the + * OPPOSITE edge is (v2, v3). The loop cut splits both edges at + * their midpoints and bisects the quad along the new diagonal. + * + * The walk continues across each opposite edge into the next + * quad in the ring. It terminates when: + * - the walk returns to the starting edge (closed loop), + * - it encounters a non-quad face (loop cut requires the + * opposite-edge correspondence, only well-defined on quads), + * - it encounters a boundary edge (no second face to cross). + * + * MVP scope: cuts at midpoint (t = 0.5) on each rail. A future + * extension can take a `t` parameter to slide the cut along + * the loop. Profile / multi-cuts are out of scope. + * + * @param startEdgeIdx The HE edge index to start from. Must be + * an interior edge whose adjacent faces are + * both quads; otherwise returns empty. + * @return Indices of every newly created vertex (in walk order). + * Empty on failure. + */ + std::vector loopCut(int startEdgeIdx); + /** * @brief Merge a set of vertices into a single survivor at `targetPos`. * diff --git a/src/HalfEdgeMesh_test.cpp b/src/HalfEdgeMesh_test.cpp index 0ade1982f..38e17fd59 100644 --- a/src/HalfEdgeMesh_test.cpp +++ b/src/HalfEdgeMesh_test.cpp @@ -3835,6 +3835,136 @@ TEST(HalfEdgeMeshStandalone, CutPathRollsBackWhenSecondEdgeDuplicatesFirst) { EXPECT_TRUE(he.validate()); } +// =========================================================================== +// loopCut — perpendicular ring cut through quads +// =========================================================================== + +TEST(HalfEdgeMeshStandalone, LoopCutOnQuadStripCutsEachQuadOnce) { + // 3-quad strip: q1=(0,1,5,4), q2=(1,2,6,5), q3=(2,3,7,6). + // + // v0─v1─v2─v3 + // │ q1│q2│q3│ + // v4─v5─v6─v7 + // + // Loop-cutting the (1,5) interior edge starts from q1, the + // opposite-edge walk crosses (1,5) into q2 then (2,6) into q3, + // terminates at the q3 boundary (3,7). 3 quads × 1 cut each = + // 3 new midpoints, 6 quads total after the cut. + EditableMesh mesh; + EditableSubMesh sub; + sub.materialName = "M"; + auto mkV = [](float x, float y) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, 0); + v.normal = Ogre::Vector3(0, 0, 1); v.hasNormal = true; + return v; + }; + sub.vertices = { + mkV(0, 1), mkV(1, 1), mkV(2, 1), mkV(3, 1), + mkV(0, 0), mkV(1, 0), mkV(2, 0), mkV(3, 0), + }; + EditableFace q1, q2, q3; + q1.indices = {0, 1, 5, 4}; + q2.indices = {1, 2, 6, 5}; + q3.indices = {2, 3, 7, 6}; + sub.faces = { q1, q2, q3 }; + triangulateFaces(sub); + mesh.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(mesh)); + const int startEdge = findEdge(he, 1, 5); + ASSERT_GE(startEdge, 0); + + auto newVerts = he.loopCut(startEdge); + // 4 rails get midpoints: (1,5) start, (0,4) on q1's other side, + // (2,6) interior, (3,7) on q3's other side. So 4 new vertices. + EXPECT_EQ(newVerts.size(), 4u) + << "open-ended loop cut: 4 rail midpoints across 3 quads"; + EXPECT_TRUE(he.validate()); + + int active = 0, quads = 0; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + ++active; + if (he.faceVertices(static_cast(f)).size() == 4) ++quads; + } + EXPECT_EQ(active, 6) << "each of the 3 original quads bisected → 6 quads"; + EXPECT_EQ(quads, 6) << "loop cut preserves quad topology"; +} + +TEST(HalfEdgeMeshStandalone, LoopCutFailsOnNonQuadAdjacency) { + // Triangles can't loop-cut: there's no opposite-edge correspondence. + // Starting from any edge of a triangle pair returns empty. + auto em = makeQuadMesh(); // two triangles, NOT quads + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + const int diag = findEdge(he, 1, 2); + ASSERT_GE(diag, 0); + auto newVerts = he.loopCut(diag); + EXPECT_TRUE(newVerts.empty()); + EXPECT_TRUE(he.validate()); +} + +TEST(HalfEdgeMeshStandalone, LoopCutClosedRingOnQuadCubeReturnsToStart) { + // Quad cube: 6 quad faces forming a closed manifold. Loop-cutting + // any edge produces a closed ring of cuts (4 rails total since + // the walk returns to the starting edge after traversing 4 faces). + EditableMesh em; + EditableSubMesh sub; + sub.materialName = "M"; + auto mkV = [](float x, float y, float z) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, z); + v.normal = Ogre::Vector3::UNIT_Y; v.hasNormal = true; + return v; + }; + sub.vertices = { + mkV(-1,-1,-1), mkV(1,-1,-1), mkV(-1,1,-1), mkV(1,1,-1), + mkV(-1,-1, 1), mkV(1,-1, 1), mkV(-1,1, 1), mkV(1,1, 1), + }; + EditableFace fBack, fFront, fTop, fBottom, fLeft, fRight; + fBack.indices = {0, 2, 3, 1}; + fFront.indices = {5, 7, 6, 4}; + fBottom.indices = {0, 1, 5, 4}; + fTop.indices = {2, 6, 7, 3}; + fLeft.indices = {0, 4, 6, 2}; + fRight.indices = {1, 3, 7, 5}; + sub.faces = { fBack, fFront, fBottom, fTop, fLeft, fRight }; + triangulateFaces(sub); + em.subMeshes().push_back(std::move(sub)); + + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + // Pick the front-bottom edge (4,5). Its perpendicular ring runs + // around the cube via Bottom → Right → Top → Left and closes. + const int startEdge = findEdge(he, 4, 5); + ASSERT_GE(startEdge, 0); + + auto newVerts = he.loopCut(startEdge); + EXPECT_EQ(newVerts.size(), 4u) + << "closed cube ring: 4 rails (one per traversed face), shared midpoints"; + EXPECT_TRUE(he.validate()); + + // 6 original faces + 4 cuts (one per cube face crossed) → 10 quads. + int active = 0, quads = 0; + for (size_t f = 0; f < he.faceCount(); ++f) { + if (he.face(static_cast(f)).halfEdge < 0) continue; + ++active; + if (he.faceVertices(static_cast(f)).size() == 4) ++quads; + } + EXPECT_EQ(active, 10); + EXPECT_EQ(quads, 10) << "loop cut preserves quad topology on closed manifolds"; +} + +TEST(HalfEdgeMeshStandalone, LoopCutFailsOnInvalidEdgeIndex) { + auto em = makeQuadMesh(); + HalfEdgeMesh he; + ASSERT_TRUE(he.buildFromEditableMesh(em)); + EXPECT_TRUE(he.loopCut(-1).empty()); + EXPECT_TRUE(he.loopCut(999).empty()); +} + // =========================================================================== // Merge vertices // =========================================================================== diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 729ecf3b9..54976bece 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -786,15 +786,28 @@ void MainWindow::initToolBar() }); QAction* fillAction = ui->objectsToolbar->addWidget(fillButton); + // Loop cut: edge mode only — pick one edge, the op walks the + // perpendicular ring of quads and bisects each one. + auto loopCutButton = new QToolButton(ui->objectsToolbar); + loopCutButton->setText(QStringLiteral("\u2551")); // ‖ double vertical line — "loop" + loopCutButton->setToolTip(tr("Loop Cut (Ctrl+R) — bisect quads perpendicular to the selected edge")); + loopCutButton->setFont(topoFont); + loopCutButton->setStyleSheet(topoBtnStyle); + connect(loopCutButton, &QToolButton::clicked, this, []() { + SentryReporter::addBreadcrumb("ui.action", "Toolbar: Loop Cut"); + EditModeController::instance()->loopCutSelection(); + }); + QAction* loopCutAction = ui->objectsToolbar->addWidget(loopCutButton); + // Context-aware visibility + enabled: // - Hidden entirely when NOT in edit mode. // - In edit mode: stay visible but only enable when the current // mode matches AND the relevant element type actually has a // non-empty selection. auto refreshTopoButtons = [extrudeButton, bevelButton, knifeButton, mergeButton, deleteButton, - subdivideButton, fillButton, + subdivideButton, fillButton, loopCutButton, extrudeAction, bevelAction, knifeAction, mergeAction, deleteAction, - subdivideAction, fillAction]() { + subdivideAction, fillAction, loopCutAction]() { auto* c = EditModeController::instance(); const bool active = c->isEditModeActive(); extrudeAction->setVisible(active); @@ -804,6 +817,7 @@ void MainWindow::initToolBar() deleteAction->setVisible(active); subdivideAction->setVisible(active); fillAction->setVisible(active); + loopCutAction->setVisible(active); if (!active) return; const int mode = c->selectionMode(); // 0 vertex, 1 edge, 2 face const bool hasFaces = c->selectedFaceCount() > 0; @@ -832,6 +846,10 @@ void MainWindow::initToolBar() // closed loop (edge mode — degree check happens at apply time). fillButton->setEnabled((mode == 0 && c->selectedVertexCount() >= 3) || (mode == 1 && c->selectedEdgeCount() >= 3)); + // Loop cut: edge mode with at least one selected edge. The op + // uses the first selected edge as the start; multi-edge loop + // cuts aren't in scope for the MVP. + loopCutButton->setEnabled(mode == 1 && hasEdges); }; refreshTopoButtons(); connect(editCtrlForTopo, &EditModeController::editModeChanged, @@ -1343,6 +1361,21 @@ void MainWindow::keyPressEvent(QKeyEvent *event) setTransformState(TransformOperator::TS_ROTATE); break; case Qt::Key_R: + // Ctrl+R in edit-mode + edge-selection: loop cut. Otherwise R + // is Scale mode (Unity convention). The Ctrl modifier + // disambiguates without disturbing the existing shortcut. + if (event->modifiers() & Qt::ControlModifier) { + auto* editCtrl = EditModeController::instance(); + if (editCtrl->isEditModeActive() + && editCtrl->selectionMode() == EditModeController::EdgeMode + && editCtrl->selectedEdgeCount() > 0) { + if (editCtrl->loopCutSelection() > 0) { + SentryReporter::addBreadcrumb("ui.shortcut", "Ctrl+R — Loop Cut"); + event->accept(); + return; + } + } + } SentryReporter::addBreadcrumb("ui.shortcut", "R — Scale mode"); setTransformState(TransformOperator::TS_SCALE); break; From 9c9bf511fce61c31566406ea767a84626d55e630 Mon Sep 17 00:00:00 2001 From: Fernando Tonon Date: Wed, 29 Apr 2026 16:02:13 -0400 Subject: [PATCH 33/34] =?UTF-8?q?feat(quads):=20tris=E2=86=92quads=20conve?= =?UTF-8?q?rter=20+=20quad-aware=20wireframe=20(#344)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mergeCoplanarTrianglesToQuads(EditableSubMesh&, angleDeg): walks edge adjacency, merges coplanar triangle pairs into 4-vert n-gon faces. Greedy first-fit, convexity-checked. +5 standalone tests. - EditModeController::convertToQuads exposed as Q_INVOKABLE; promotes legacy triangle-only submeshes into the n-gon path even when no merges happen, so downstream features start working. Pushes one "Convert to Quads" undo command. - New ▦ toolbar button in Edit Mode; auto-disables once the mesh is already n-gon-canonical (isMeshQuadBased predicate). meshDataChanged signal now refreshes the topology toolbar so undo flips state. - Loop cut hint: when the start edge has triangle adjacency the op short-circuits and emits editHintMessage so the status bar shows "Loop cut needs a quad mesh — try Mesh → Convert to Quads." - Quad-aware wireframe: when wireframeEnabled AND any submesh has .faces, render a separate ManualObject overlay along n-gon boundaries (skipping fan-triangulation diagonals) instead of PM_WIREFRAME. Pure-tri meshes keep the legacy material override. - Selected vertex/edge overlays bumped to RENDER_QUEUE_OVERLAY+1 and edge selection lines widened to 3px so selection still reads on top of the 2px boundary wireframe. Co-authored-by: Claude Opus 4.7 (1M context) --- src/EditModeController.cpp | 216 ++++++++++++++++++++++++++++++++++++- src/EditModeController.h | 47 ++++++++ src/EditableMesh.cpp | 154 ++++++++++++++++++++++++++ src/EditableMesh.h | 28 +++++ src/EditableMesh_test.cpp | 116 ++++++++++++++++++++ src/mainwindow.cpp | 34 +++++- 6 files changed, 590 insertions(+), 5 deletions(-) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index e90197d88..92cd0feaa 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -3433,7 +3433,23 @@ int EditModeController::loopCutSelection() const auto preSelectedFaces = m_selectedFaces; const auto newHE = hm.loopCut(startEdge); - if (newHE.empty()) return 0; + if (newHE.empty()) { + // The walk failed — most often because both faces adjacent to + // the start edge are triangles (loop cut needs the opposite-edge + // correspondence, well-defined only on quads/n-gons). Surface a + // hint to the user pointing at the converter. + const auto [fa, fb] = hm.edgeFaces(startEdge); + const bool faTri = fa >= 0 && hm.faceVertices(fa).size() == 3; + const bool fbTri = fb >= 0 && hm.faceVertices(fb).size() == 3; + if (faTri || fbTri) { + const QString hint = QStringLiteral( + "Loop cut needs a quad mesh — try Mesh → Convert to Quads."); + emit editHintMessage(hint); + SentryReporter::addBreadcrumb("edit_mode", + "Loop Cut no-op (tri adjacency)"); + } + return 0; + } EditableMesh updated; if (!hm.toEditableMesh(updated)) return 0; @@ -3471,6 +3487,89 @@ int EditModeController::loopCutSelection() return static_cast(newHE.size()); } +int EditModeController::convertToQuads(float angleThresholdDeg) +{ + if (!m_editModeActive || !m_editableMesh || !m_editEntity) return 0; + + if (m_bevelSession.active) cancelBevel(); + if (m_knifeSession.active) cancelKnife(); + + auto originalSubMeshes = m_editableMesh->subMeshes(); + const auto preSelectedVerts = m_selectedVertices; + const auto preSelectedEdges = m_selectedEdges; + const auto preSelectedFaces = m_selectedFaces; + + int totalMerges = 0; + for (auto& sub : m_editableMesh->subMeshes()) { + // Promote first if the submesh is in legacy triangle-only mode + // so the n-gon path takes over even when no merges happen — the + // wireframe overlay etc. branch on .faces being non-empty. + if (sub.faces.empty()) promoteTrianglesToFaces(sub); + totalMerges += mergeCoplanarTrianglesToQuads(sub, angleThresholdDeg); + } + + if (totalMerges == 0) { + // Promotion alone counts as a meaningful change (downstream + // n-gon-aware features start working) — but if every submesh + // already had .faces and no merges happened, this is a true + // no-op. Detect by comparing face counts pre/post. + bool topologyChanged = false; + const auto& cur = m_editableMesh->subMeshes(); + if (cur.size() != originalSubMeshes.size()) { + topologyChanged = true; + } else { + for (size_t i = 0; i < cur.size(); ++i) { + if (cur[i].faces.size() != originalSubMeshes[i].faces.size()) { + topologyChanged = true; + break; + } + } + } + if (!topologyChanged) { + // Restore — promote was a no-op too. + m_editableMesh->subMeshes() = std::move(originalSubMeshes); + return 0; + } + } + + if (m_normalsMode == 0) m_editableMesh->recalculateNormals(); + else m_editableMesh->recalculateNormalsFlat(); + + m_editableMesh->resizeEntityBuffers(m_editEntity); + rewriteEntityAfterTopologyChange(m_editEntity); + + // Vertex IDs are unchanged by the merge (we only restructured face + // groupings), so per-vertex selection survives. Edge/face IDs are + // not stable across the rebuild — clear them. + m_selectedEdges.clear(); + m_selectedFaces.clear(); + + auto* cmd = new EditMeshTopologyCommand( + std::move(originalSubMeshes), + m_editableMesh->subMeshes(), + preSelectedVerts, preSelectedEdges, preSelectedFaces, + m_selectedVertices, m_selectedEdges, m_selectedFaces, + QStringLiteral("Convert to Quads")); + UndoManager::getSingleton()->push(cmd); + + validateMesh(); + SentryReporter::addBreadcrumb("edit_mode", + QString("Convert to Quads (merges=%1)").arg(totalMerges)); + + // Wireframe path may have flipped from PM_WIREFRAME to the n-gon + // boundary overlay (or back, if undo is invoked) — re-apply. + if (m_wireframeEnabled) { + removeWireframeMaterials(); + applyWireframeMaterials(); + } + + updateSelectionOverlay(); + refreshNormalVisualizer(); + emit editSelectionChanged(); + emit meshDataChanged(); + return totalMerges; +} + int EditModeController::subdivideCatmullClarkAll() { if (!m_editModeActive || !m_editableMesh || !m_editEntity) return 0; @@ -4221,6 +4320,9 @@ void EditModeController::createOverlayMaterials() pass->setVertexColourTracking(Ogre::TVC_DIFFUSE); pass->setDepthCheckEnabled(false); pass->setDepthWriteEnabled(false); + // Thicker than the boundary-wireframe overlay (2px) so the + // selection still reads clearly when the two overlays overlap. + pass->setLineWidth(3.0f); } // Face selection material (semi-transparent overlay, no lighting) @@ -4236,6 +4338,26 @@ void EditModeController::createOverlayMaterials() pass->setDepthWriteEnabled(false); pass->setCullingMode(Ogre::CULL_NONE); } + + // Quad-aware wireframe overlay material — same render-mode profile + // as EditMode/EdgeSelection but kept distinct so its colour can + // diverge later (e.g. theme support, hover highlight). + if (!matMgr.getByName("EditMode/BoundaryWireframe")) + { + auto mat = matMgr.create("EditMode/BoundaryWireframe", + Ogre::ResourceGroupManager::INTERNAL_RESOURCE_GROUP_NAME); + auto* pass = mat->getTechnique(0)->getPass(0); + pass->setLightingEnabled(false); + pass->setVertexColourTracking(Ogre::TVC_DIFFUSE); + pass->setDepthCheckEnabled(true); + pass->setDepthWriteEnabled(false); + // Thicker boundary lines so the overlay reads cleanly against + // the underlying mesh shading. Note: many modern GL drivers + // cap line width to 1 in core profile — if this still looks + // hairline, the fallback is to extrude lines into camera- + // facing triangle strips (TODO if 2px is unreliable). + pass->setLineWidth(2.0f); + } } void EditModeController::updateSelectionOverlay() @@ -4262,7 +4384,8 @@ void EditModeController::updateSelectionOverlay() { m_overlayVertices = sceneMgr->createManualObject("EditMode_VertexOverlay"); m_overlayVertices->setDynamic(true); - m_overlayVertices->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY); + // Above the boundary wireframe so selected vertices read on top. + m_overlayVertices->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY + 1); m_overlayNode->attachObject(m_overlayVertices); } @@ -4307,7 +4430,9 @@ void EditModeController::updateSelectionOverlay() { m_overlayEdges = sceneMgr->createManualObject("EditMode_EdgeOverlay"); m_overlayEdges->setDynamic(true); - m_overlayEdges->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY); + // Draw selected edges AFTER the n-gon boundary wireframe overlay + // so the selection reads on top when the two coincide. + m_overlayEdges->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY + 1); m_overlayNode->attachObject(m_overlayEdges); } @@ -4389,6 +4514,9 @@ void EditModeController::updateSelectionOverlay() m_overlayFaces->end(); } + // -- Quad-aware wireframe overlay (n-gon meshes only) -- + updateBoundaryEdgeOverlay(); + // -- Soft selection radius sphere -- if (m_softSelectionEnabled && !m_selectedVertices.empty()) { Ogre::Vector3 centroid = getSelectedVerticesCentroid(); @@ -4460,6 +4588,10 @@ void EditModeController::destroySelectionOverlay() sceneMgr->destroyManualObject(m_overlayFaces); m_overlayFaces = nullptr; } + if (m_overlayBoundaryEdges) { + sceneMgr->destroyManualObject(m_overlayBoundaryEdges); + m_overlayBoundaryEdges = nullptr; + } if (m_softSelSphere) { if (m_softSelSphereNode) m_softSelSphereNode->detachAllObjects(); @@ -4481,11 +4613,34 @@ void EditModeController::destroySelectionOverlay() // Wireframe toggle // =========================================================================== +bool EditModeController::meshHasNgonFaces() const +{ + return isMeshQuadBased(); +} + +bool EditModeController::isMeshQuadBased() const +{ + if (!m_editableMesh) return false; + for (const auto& sub : m_editableMesh->subMeshes()) { + if (!sub.faces.empty()) return true; + } + return false; +} + void EditModeController::applyWireframeMaterials() { if (!m_editEntity) return; + // n-gon mesh: keep solid material, use the boundary-edge overlay + // (drawn from updateSelectionOverlay) to show face boundaries + // without the fan-triangulation diagonals. + if (meshHasNgonFaces()) { + m_savedMaterials.clear(); + updateBoundaryEdgeOverlay(); + return; + } + m_savedMaterials.clear(); for (unsigned int i = 0; i < m_editEntity->getNumSubEntities(); ++i) { auto* subEnt = m_editEntity->getSubEntity(i); @@ -4521,6 +4676,61 @@ void EditModeController::removeWireframeMaterials() } } m_savedMaterials.clear(); + + // Clear the n-gon boundary overlay too — the user toggled wireframe + // off, no PM_WIREFRAME or boundary lines should remain visible. + if (m_overlayBoundaryEdges) + m_overlayBoundaryEdges->clear(); +} + +void EditModeController::updateBoundaryEdgeOverlay() +{ + if (!m_editModeActive || !m_editableMesh || !m_editEntity) return; + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + if (!sceneMgr) return; + if (!m_overlayNode) return; // overlay setup hasn't run yet + + if (!m_overlayBoundaryEdges) { + m_overlayBoundaryEdges = sceneMgr->createManualObject( + "EditMode_BoundaryEdgeOverlay"); + m_overlayBoundaryEdges->setDynamic(true); + m_overlayBoundaryEdges->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY); + m_overlayNode->attachObject(m_overlayBoundaryEdges); + } + + m_overlayBoundaryEdges->clear(); + if (!m_wireframeEnabled || !meshHasNgonFaces()) return; + + m_overlayBoundaryEdges->begin("EditMode/BoundaryWireframe", + Ogre::RenderOperation::OT_LINE_LIST); + const Ogre::ColourValue lineColor(0.85f, 0.85f, 0.85f, 1.0f); + + // Build a deduped set of (min,max) vertex-pair edges from each + // submesh's n-gon faces. Submesh-local indexing — different + // submeshes don't share vertices. + for (const auto& sub : m_editableMesh->subMeshes()) { + if (sub.faces.empty()) continue; + std::set> emitted; + for (const auto& face : sub.faces) { + if (!face.isValid()) continue; + const auto& idx = face.indices; + const size_t n = idx.size(); + for (size_t i = 0; i < n; ++i) { + const unsigned int a = idx[i]; + const unsigned int b = idx[(i + 1) % n]; + const auto key = std::make_pair(std::min(a, b), std::max(a, b)); + if (!emitted.insert(key).second) continue; + if (a >= sub.vertices.size() || b >= sub.vertices.size()) + continue; + m_overlayBoundaryEdges->position(sub.vertices[a].position); + m_overlayBoundaryEdges->colour(lineColor); + m_overlayBoundaryEdges->position(sub.vertices[b].position); + m_overlayBoundaryEdges->colour(lineColor); + } + } + } + + m_overlayBoundaryEdges->end(); } void EditModeController::setWireframeEnabled(bool enabled) diff --git a/src/EditModeController.h b/src/EditModeController.h index 5f0d560cf..87b58e17d 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -122,6 +122,11 @@ class EditModeController : public QObject Q_PROPERTY(bool wireframeEnabled READ wireframeEnabled WRITE setWireframeEnabled NOTIFY wireframeChanged) bool wireframeEnabled() const { return m_wireframeEnabled; } void setWireframeEnabled(bool enabled); + + /// True if any submesh of the current edit mesh has non-empty + /// n-gon `.faces` — i.e. quad-based already. Drives the + /// "Convert to Quads" toolbar button enable state. + Q_INVOKABLE bool isMeshQuadBased() const; /// @} /// @name Mesh info (only valid when in edit mode) @@ -501,6 +506,28 @@ class EditModeController : public QObject * @return Number of triangles created (0 on no-op or rejection). */ Q_INVOKABLE int fillSelection(); + + /** + * @brief Merge coplanar adjacent triangle pairs into quads. + * + * Whole-mesh operation: walks every submesh, looks for triangle pairs + * that share an edge and are within `angleThresholdDeg` of coplanar + * (default 1°), and merges each qualifying pair into a single quad + * face. Promotes the legacy triangle-only representation into the + * n-gon canonical form. After the operation, downstream features + * that branch on n-gon `.faces` (loop cut, n-gon-aware bevel, + * quad-aware wireframe) start working. + * + * No-op when nothing qualifies (all tris are non-coplanar, or the + * mesh is already quad-dominant). Pushes one undo command labeled + * "Convert to Quads" iff merges happened. + * + * @param angleThresholdDeg Maximum dihedral angle (deg) to treat as + * coplanar. 0 = strict, ~5 = forgiving for float-quantised + * imports. Defaults to 1°. + * @return Number of triangle pairs merged across all submeshes. + */ + Q_INVOKABLE int convertToQuads(float angleThresholdDeg = 1.0f); /// @} /// @name Vertex transform support @@ -723,6 +750,12 @@ class EditModeController : public QObject /// so QML toolbar state and preview overlay refresh together. void knifeSessionChanged(); + /// Emitted when an edit-mode op short-circuits and wants to surface + /// a one-line explanation to the user (e.g. "Loop cut requires a + /// quad mesh — try Mesh → Convert to Quads"). QML overlays / + /// status-bar widgets can subscribe. + void editHintMessage(const QString& message); + private slots: void onSelectionChanged(); @@ -765,6 +798,12 @@ private slots: Ogre::ManualObject* m_overlayVertices = nullptr; Ogre::ManualObject* m_overlayEdges = nullptr; Ogre::ManualObject* m_overlayFaces = nullptr; + /// Quad-aware wireframe: lines along n-gon face boundaries only, + /// hiding the diagonals introduced by `triangulateFaces()`. Active + /// when `m_wireframeEnabled` AND any submesh has non-empty `.faces`. + /// On legacy triangle-only meshes this stays empty and the + /// PM_WIREFRAME material override does the work instead. + Ogre::ManualObject* m_overlayBoundaryEdges = nullptr; Ogre::SceneNode* m_overlayNode = nullptr; // Bevel session state — populated on beginBevel, consumed on commit/cancel. @@ -903,6 +942,14 @@ private slots: // Wireframe helpers void applyWireframeMaterials(); void removeWireframeMaterials(); + /// True if any submesh has non-empty `.faces` (n-gon canonical). + /// Drives the choice between PM_WIREFRAME (all submeshes pure tris) + /// and the boundary-edge overlay (any n-gon faces present). + bool meshHasNgonFaces() const; + /// (Re)build the n-gon boundary-edge overlay from `m_editableMesh`. + /// Active when `m_wireframeEnabled` AND `meshHasNgonFaces()` — + /// otherwise clears the overlay so it draws nothing. + void updateBoundaryEdgeOverlay(); public: /// Refresh an entity after a topology mutation: rebuild tangents diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 5a482ab40..bd424ff98 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -30,10 +30,12 @@ THE SOFTWARE. #include "SubMeshTransform.h" #include #include +#include #include #include #include #include +#include // Assimp re-import path (loadFromAssimpFile) — drops aiProcess_Triangulate // so source n-gons survive into EditableSubMesh::faces. Quad migration #326, @@ -81,6 +83,158 @@ void promoteTrianglesToFaces(EditableSubMesh& sub) // already holds. } +namespace { + +Ogre::Vector3 triangleNormal(const EditableSubMesh& sub, + const EditableTriangle& tri) +{ + if (tri.indices[0] >= sub.vertices.size() + || tri.indices[1] >= sub.vertices.size() + || tri.indices[2] >= sub.vertices.size()) + return Ogre::Vector3::ZERO; + const auto& a = sub.vertices[tri.indices[0]].position; + const auto& b = sub.vertices[tri.indices[1]].position; + const auto& c = sub.vertices[tri.indices[2]].position; + Ogre::Vector3 n = (b - a).crossProduct(c - a); + if (n.squaredLength() < 1e-12f) return Ogre::Vector3::ZERO; + n.normalise(); + return n; +} + +// Build the merged quad winding from two adjacent triangles sharing +// edge (sharedA, sharedB). Returns the 4-vertex loop in the winding of +// the first triangle, or empty if the merge would produce a degenerate +// or non-convex result. +std::vector buildQuadLoop(const EditableSubMesh& sub, + const EditableTriangle& t1, + const EditableTriangle& t2, + unsigned int sharedA, + unsigned int sharedB) +{ + // Find the apex (non-shared vertex) of each triangle. + auto apexOf = [&](const EditableTriangle& t) -> unsigned int { + for (int i = 0; i < 3; ++i) { + if (t.indices[i] != sharedA && t.indices[i] != sharedB) + return t.indices[i]; + } + return UINT_MAX; + }; + const unsigned int apex1 = apexOf(t1); + const unsigned int apex2 = apexOf(t2); + if (apex1 == UINT_MAX || apex2 == UINT_MAX) return {}; + if (apex1 == apex2) return {}; // degenerate + + // Walk t1's winding to figure out the order: starting from apex1, + // is the next vertex sharedA or sharedB? That determines whether + // the quad is [apex1, sharedA, apex2, sharedB] or [apex1, sharedB, + // apex2, sharedA]. The diagonal we drop is (sharedA, sharedB). + int apexPos = -1; + for (int i = 0; i < 3; ++i) { + if (t1.indices[i] == apex1) { apexPos = i; break; } + } + if (apexPos < 0) return {}; + const unsigned int after = t1.indices[(apexPos + 1) % 3]; + + std::vector loop; + loop.reserve(4); + loop.push_back(apex1); + loop.push_back(after); // sharedA or sharedB + loop.push_back(apex2); + loop.push_back(after == sharedA ? sharedB : sharedA); + + // Convexity check: for each consecutive triple in the loop, the + // cross product (next - cur) × (prev - cur) must point in the same + // direction as the triangle normal. If any flips, the quad is + // non-convex (or self-intersecting) — reject the merge. + const Ogre::Vector3 nRef = triangleNormal(sub, t1); + if (nRef == Ogre::Vector3::ZERO) return {}; + for (int i = 0; i < 4; ++i) { + const auto& a = sub.vertices[loop[(i + 3) % 4]].position; + const auto& b = sub.vertices[loop[i]].position; + const auto& c = sub.vertices[loop[(i + 1) % 4]].position; + const Ogre::Vector3 cross = (c - b).crossProduct(a - b); + if (cross.dotProduct(nRef) <= 0.0f) return {}; + } + + return loop; +} + +} // namespace + +int mergeCoplanarTrianglesToQuads(EditableSubMesh& sub, + float angleThresholdDeg) +{ + if (sub.triangles.empty()) return 0; + + // Edge → list of triangle indices. An interior edge between two + // triangles has exactly two entries. + using EdgeKey = std::pair; + auto makeKey = [](unsigned int a, unsigned int b) { + return EdgeKey{ std::min(a, b), std::max(a, b) }; + }; + std::map> edgeToTris; + for (size_t ti = 0; ti < sub.triangles.size(); ++ti) { + const auto& t = sub.triangles[ti]; + for (int e = 0; e < 3; ++e) { + edgeToTris[makeKey(t.indices[e], t.indices[(e + 1) % 3])] + .push_back(ti); + } + } + + const float cosThreshold = std::cos( + Ogre::Math::DegreesToRadians(std::max(0.0f, angleThresholdDeg))); + + std::vector consumed(sub.triangles.size(), false); + std::vector outFaces; + outFaces.reserve(sub.triangles.size()); + int merges = 0; + + for (size_t ti = 0; ti < sub.triangles.size(); ++ti) { + if (consumed[ti]) continue; + const auto& t = sub.triangles[ti]; + + bool merged = false; + for (int e = 0; e < 3 && !merged; ++e) { + const unsigned int a = t.indices[e]; + const unsigned int b = t.indices[(e + 1) % 3]; + const auto& neighbours = edgeToTris[makeKey(a, b)]; + if (neighbours.size() != 2) continue; // boundary or non-manifold + + const size_t other = (neighbours[0] == ti) ? neighbours[1] + : neighbours[0]; + if (other == ti || consumed[other]) continue; + + const Ogre::Vector3 n1 = triangleNormal(sub, t); + const Ogre::Vector3 n2 = triangleNormal(sub, sub.triangles[other]); + if (n1 == Ogre::Vector3::ZERO || n2 == Ogre::Vector3::ZERO) + continue; + if (n1.dotProduct(n2) < cosThreshold) continue; + + const auto loop = buildQuadLoop(sub, t, sub.triangles[other], a, b); + if (loop.size() != 4) continue; + + EditableFace face; + face.indices.assign(loop.begin(), loop.end()); + outFaces.push_back(std::move(face)); + consumed[ti] = true; + consumed[other] = true; + ++merges; + merged = true; + } + + if (!merged) { + EditableFace face; + face.indices = {t.indices[0], t.indices[1], t.indices[2]}; + outFaces.push_back(std::move(face)); + consumed[ti] = true; + } + } + + sub.faces = std::move(outFaces); + triangulateFaces(sub); + return merges; +} + bool EditableMesh::loadFromEntity(Ogre::Entity* entity) { if (!entity) diff --git a/src/EditableMesh.h b/src/EditableMesh.h index b88c39334..64cf317f8 100644 --- a/src/EditableMesh.h +++ b/src/EditableMesh.h @@ -161,6 +161,34 @@ void triangulateFaces(EditableSubMesh& sub); */ void promoteTrianglesToFaces(EditableSubMesh& sub); +/** + * @brief Merge coplanar adjacent triangle pairs into quads in `faces`. + * + * Walks the submesh's triangles, builds an edge→triangle adjacency map, + * and for each interior edge between two unmerged triangles checks whether + * the pair is coplanar (face normals' dot product ≥ cos(angleThresholdDeg)) + * and forms a convex quad. Matching pairs are written to `sub.faces` as + * 4-vertex EditableFace entries; unmerged triangles are written as 3-vertex + * faces. After the call, `sub.faces` is canonical and `sub.triangles` is + * resynced via `triangulateFaces(sub)`. + * + * Each triangle is merged at most once. A greedy first-fit scan is used: + * when multiple neighbours qualify, the first one walked wins. This is + * good enough for axis-aligned tessellated quads and tri-pair-strip + * imports — the typical "I exported a quad mesh as triangles" case. + * + * @param sub Submesh to convert. Read-write — `sub.faces` is overwritten, + * `sub.triangles` is rebuilt. + * @param angleThresholdDeg Maximum dihedral angle (degrees) between the + * two triangle normals for them to be considered coplanar. + * Defaults to 1° (very strict). Pass 0 to require perfect + * coplanarity, or e.g. 5° to merge near-coplanar imports + * from float-quantised exporters. + * @return Number of triangle pairs merged into quads. + */ +int mergeCoplanarTrianglesToQuads(EditableSubMesh& sub, + float angleThresholdDeg = 1.0f); + /** * @brief Re-triangulate every submesh whose `faces` is non-empty. * diff --git a/src/EditableMesh_test.cpp b/src/EditableMesh_test.cpp index 2ec09e6c0..0e7e7966f 100644 --- a/src/EditableMesh_test.cpp +++ b/src/EditableMesh_test.cpp @@ -1309,3 +1309,119 @@ TEST(EditableMeshStandalone, FaceIndexForTriangleSkipsInvalidFaces) { EXPECT_EQ(firstTri, 0u); EXPECT_EQ(count, 2u); } + +// ============================================================================ +// mergeCoplanarTrianglesToQuads +// ============================================================================ + +namespace { +EditableVertex mkPosV(float x, float y, float z) { + EditableVertex v; + v.position = Ogre::Vector3(x, y, z); + v.normal = Ogre::Vector3::UNIT_Z; + v.hasNormal = true; + return v; +} +} // namespace + +TEST(EditableMeshStandalone, MergeCoplanarTrianglesProducesQuad) { + // Two triangles forming a planar unit quad on the XY plane. + EditableSubMesh sub; + sub.vertices = { + mkPosV(0, 0, 0), mkPosV(1, 0, 0), mkPosV(1, 1, 0), mkPosV(0, 1, 0), + }; + EditableTriangle t1{}, t2{}; + t1.indices[0] = 0; t1.indices[1] = 1; t1.indices[2] = 2; + t2.indices[0] = 0; t2.indices[1] = 2; t2.indices[2] = 3; + sub.triangles = {t1, t2}; + + const int merged = mergeCoplanarTrianglesToQuads(sub); + EXPECT_EQ(merged, 1); + ASSERT_EQ(sub.faces.size(), 1u); + EXPECT_EQ(sub.faces[0].indices.size(), 4u); + // triangulation mirror is rebuilt + EXPECT_EQ(sub.triangles.size(), 2u); +} + +TEST(EditableMeshStandalone, MergeCoplanarLeavesNonCoplanarAsTris) { + // Two triangles sharing edge (0,1) but bent at 90°. + EditableSubMesh sub; + sub.vertices = { + mkPosV(0, 0, 0), mkPosV(1, 0, 0), mkPosV(0, 1, 0), mkPosV(0, 0, 1), + }; + EditableTriangle t1{}, t2{}; + t1.indices[0] = 0; t1.indices[1] = 1; t1.indices[2] = 2; + // Second tri lifted off the XY plane along Z — 90° dihedral. + t2.indices[0] = 1; t2.indices[1] = 0; t2.indices[2] = 3; + sub.triangles = {t1, t2}; + + const int merged = mergeCoplanarTrianglesToQuads(sub, 1.0f); + EXPECT_EQ(merged, 0); + ASSERT_EQ(sub.faces.size(), 2u); + EXPECT_EQ(sub.faces[0].indices.size(), 3u); + EXPECT_EQ(sub.faces[1].indices.size(), 3u); +} + +TEST(EditableMeshStandalone, MergeCoplanarHandlesTriangulatedCube) { + // Standard 8-vert cube triangulated as 12 tris (2 per face). + // mergeCoplanarTrianglesToQuads should reconstruct 6 quads. + EditableSubMesh sub; + sub.vertices = { + mkPosV(-1,-1,-1), mkPosV( 1,-1,-1), mkPosV( 1, 1,-1), mkPosV(-1, 1,-1), + mkPosV(-1,-1, 1), mkPosV( 1,-1, 1), mkPosV( 1, 1, 1), mkPosV(-1, 1, 1), + }; + auto T = [](unsigned a, unsigned b, unsigned c) { + EditableTriangle t{}; t.indices[0]=a; t.indices[1]=b; t.indices[2]=c; + return t; + }; + // Each face split along a single diagonal — winding outward. + sub.triangles = { + T(0,2,1), T(0,3,2), // back (-Z) + T(4,5,6), T(4,6,7), // front (+Z) + T(0,1,5), T(0,5,4), // bottom (-Y) + T(2,3,7), T(2,7,6), // top (+Y) + T(0,4,7), T(0,7,3), // left (-X) + T(1,2,6), T(1,6,5), // right (+X) + }; + + const int merged = mergeCoplanarTrianglesToQuads(sub, 1.0f); + EXPECT_EQ(merged, 6); + EXPECT_EQ(sub.faces.size(), 6u); + for (const auto& f : sub.faces) { + EXPECT_EQ(f.indices.size(), 4u); + } + // 6 quads × 2 fan tris = 12 — same as input. + EXPECT_EQ(sub.triangles.size(), 12u); +} + +TEST(EditableMeshStandalone, MergeCoplanarRespectsAngleThreshold) { + // Two triangles bent by ~5° dihedral. With strict threshold (1°), + // they don't merge; with loose threshold (10°), they do. + EditableSubMesh sub; + sub.vertices = { + mkPosV(0, 0, 0), + mkPosV(1, 0, 0), + mkPosV(1, 1, 0), + // 4th vertex tilted up in z by tan(5°) ≈ 0.0875 + mkPosV(0, 1, 0.0875f), + }; + EditableTriangle t1{}, t2{}; + t1.indices[0] = 0; t1.indices[1] = 1; t1.indices[2] = 2; + t2.indices[0] = 0; t2.indices[1] = 2; t2.indices[2] = 3; + sub.triangles = {t1, t2}; + + EditableSubMesh strict = sub; + EXPECT_EQ(mergeCoplanarTrianglesToQuads(strict, 1.0f), 0); + EXPECT_EQ(strict.faces.size(), 2u); + + EditableSubMesh loose = sub; + EXPECT_EQ(mergeCoplanarTrianglesToQuads(loose, 10.0f), 1); + EXPECT_EQ(loose.faces.size(), 1u); +} + +TEST(EditableMeshStandalone, MergeCoplanarEmptySubMesh) { + EditableSubMesh sub; + EXPECT_EQ(mergeCoplanarTrianglesToQuads(sub), 0); + EXPECT_TRUE(sub.faces.empty()); + EXPECT_TRUE(sub.triangles.empty()); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 54976bece..82f806756 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -162,6 +162,13 @@ MainWindow::MainWindow(QWidget *parent) : this, &MainWindow::updateEditModeIndicator); updateEditModeIndicator(); + // Surface edit-mode hint messages (e.g. "Loop cut needs a quad mesh") in + // the status bar for ~5s. + connect(EditModeController::instance(), &EditModeController::editHintMessage, + this, [this](const QString& msg) { + statusBar()->showMessage(msg, 5000); + }); + // Auto-start MCP HTTP server if enabled in settings QSettings mcpSettings; bool mcpEnabled = mcpSettings.value("MCP/enabled", false).toBool(); @@ -799,15 +806,29 @@ void MainWindow::initToolBar() }); QAction* loopCutAction = ui->objectsToolbar->addWidget(loopCutButton); + // Convert to Quads: walks the mesh and merges coplanar adjacent + // triangle pairs into n-gon quads. Useful when an imported tri + // mesh blocks loop cut / n-gon-aware bevel. + auto convertToQuadsButton = new QToolButton(ui->objectsToolbar); + convertToQuadsButton->setText(QStringLiteral("\u25A6")); // ▦ — "tessellated/quad grid" + convertToQuadsButton->setToolTip(tr("Convert to Quads — merge coplanar triangle pairs into quads")); + convertToQuadsButton->setFont(topoFont); + convertToQuadsButton->setStyleSheet(topoBtnStyle); + connect(convertToQuadsButton, &QToolButton::clicked, this, []() { + SentryReporter::addBreadcrumb("ui.action", "Toolbar: Convert to Quads"); + EditModeController::instance()->convertToQuads(); + }); + QAction* convertToQuadsAction = ui->objectsToolbar->addWidget(convertToQuadsButton); + // Context-aware visibility + enabled: // - Hidden entirely when NOT in edit mode. // - In edit mode: stay visible but only enable when the current // mode matches AND the relevant element type actually has a // non-empty selection. auto refreshTopoButtons = [extrudeButton, bevelButton, knifeButton, mergeButton, deleteButton, - subdivideButton, fillButton, loopCutButton, + subdivideButton, fillButton, loopCutButton, convertToQuadsButton, extrudeAction, bevelAction, knifeAction, mergeAction, deleteAction, - subdivideAction, fillAction, loopCutAction]() { + subdivideAction, fillAction, loopCutAction, convertToQuadsAction]() { auto* c = EditModeController::instance(); const bool active = c->isEditModeActive(); extrudeAction->setVisible(active); @@ -818,6 +839,7 @@ void MainWindow::initToolBar() subdivideAction->setVisible(active); fillAction->setVisible(active); loopCutAction->setVisible(active); + convertToQuadsAction->setVisible(active); if (!active) return; const int mode = c->selectionMode(); // 0 vertex, 1 edge, 2 face const bool hasFaces = c->selectedFaceCount() > 0; @@ -850,6 +872,9 @@ void MainWindow::initToolBar() // uses the first selected edge as the start; multi-edge loop // cuts aren't in scope for the MVP. loopCutButton->setEnabled(mode == 1 && hasEdges); + // Convert to Quads: whole-mesh; disable once the mesh already + // has n-gon canonical faces (no work to do). + convertToQuadsButton->setEnabled(!c->isMeshQuadBased()); }; refreshTopoButtons(); connect(editCtrlForTopo, &EditModeController::editModeChanged, @@ -858,6 +883,11 @@ void MainWindow::initToolBar() this, refreshTopoButtons); connect(editCtrlForTopo, &EditModeController::editSelectionChanged, this, refreshTopoButtons); + // Mesh-data changes (extrude/bevel/convertToQuads/undo) flip the + // n-gon-vs-tri state — refresh so "Convert to Quads" disables once + // the mesh has been promoted. + connect(editCtrlForTopo, &EditModeController::meshDataChanged, + this, refreshTopoButtons); connect(pAddCube, SIGNAL(triggered()),m_pPrimitivesWidget,SLOT(createCube())); connect(pAddSphere, SIGNAL(triggered()),m_pPrimitivesWidget,SLOT(createSphere())); From 31ed94cc944ec45147c2c396e5f58b591a589040 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 29 Apr 2026 19:44:27 -0400 Subject: [PATCH 34/34] fix(quads): Ctrl+R consume + dedicated edit-hint label (#347 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (Major): Ctrl+R fell through to Scale when loopCutSelection short-circuited (e.g. on triangle meshes). Once the user is in edit-mode + edge-mode + has an edge selected, Ctrl+R always means "loop cut" — the no-op case must still consume the key, not silently switch tools. CodeRabbit (Major): editHintMessage routed through statusBar()->showMessage, which frameEnded() rewrites every frame — the 5s hint lasted ~16ms in practice. Use a dedicated permanent QLabel (m_editHintLabel) with a QTimer::singleShot(5000) auto-hide. Italic amber styling distinguishes it from the normal selection-summary slot. Eight other review items are real bugs but pre-existing in feat/quads, not regressions introduced by this merge. Tracked as master follow-ups. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mainwindow.cpp | 30 ++++++++++++++++++++++-------- src/mainwindow.h | 5 +++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 0fcc7116c..ae2413d71 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -172,11 +172,21 @@ MainWindow::MainWindow(QWidget *parent) : this, &MainWindow::updateEditModeIndicator); updateEditModeIndicator(); - // Surface edit-mode hint messages (e.g. "Loop cut needs a quad mesh") in - // the status bar for ~5s. + // Surface edit-mode hint messages (e.g. "Loop cut needs a quad mesh") + // via a dedicated permanent label. The status bar's main message slot + // is rewritten every frame by frameEnded(), so showMessage() lasted + // ~16ms in practice. This label persists for 5s via QTimer::singleShot. + m_editHintLabel = new QLabel(this); + m_editHintLabel->setStyleSheet( + "QLabel { color: #ffaa33; font-style: italic; padding: 2px 8px; }"); + m_editHintLabel->setVisible(false); + statusBar()->addPermanentWidget(m_editHintLabel); connect(EditModeController::instance(), &EditModeController::editHintMessage, this, [this](const QString& msg) { - statusBar()->showMessage(msg, 5000); + m_editHintLabel->setText(msg); + m_editHintLabel->setVisible(true); + QTimer::singleShot(5000, m_editHintLabel, + [this]() { m_editHintLabel->setVisible(false); }); }); // Auto-start MCP HTTP server if enabled in settings @@ -1581,11 +1591,15 @@ void MainWindow::keyPressEvent(QKeyEvent *event) if (editCtrl->isEditModeActive() && editCtrl->selectionMode() == EditModeController::EdgeMode && editCtrl->selectedEdgeCount() > 0) { - if (editCtrl->loopCutSelection() > 0) { - SentryReporter::addBreadcrumb("ui.shortcut", "Ctrl+R — Loop Cut"); - event->accept(); - return; - } + // Always consume Ctrl+R when loop cut is the active + // shortcut — even when the op short-circuits (e.g. tri + // mesh). Falling through to Scale would silently switch + // tools mid-loop-cut, which is what the user actually + // pressed but isn't what they meant. + editCtrl->loopCutSelection(); + SentryReporter::addBreadcrumb("ui.shortcut", "Ctrl+R — Loop Cut"); + event->accept(); + return; } } SentryReporter::addBreadcrumb("ui.shortcut", "R — Scale mode"); diff --git a/src/mainwindow.h b/src/mainwindow.h index 729c49873..60bd9ba6b 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -160,6 +160,11 @@ public slots: void repositionWelcomeScreen(); QLabel* m_editModeLabel = nullptr; + /// Permanent status-bar widget for transient edit-mode hint + /// messages (e.g. "Loop cut needs a quad mesh"). A dedicated + /// label avoids the every-frame `showMessage()` race that + /// `frameEnded()` causes on the status bar's main slot. + QLabel* m_editHintLabel = nullptr; void updateEditModeIndicator(); };