Skip to content

quads: n-gon-aware bevel (edges + vertices) - #339

Merged
fernandotonon merged 6 commits into
feat/quadsfrom
feat/ngon-bevel
Apr 29, 2026
Merged

quads: n-gon-aware bevel (edges + vertices)#339
fernandotonon merged 6 commits into
feat/quadsfrom
feat/ngon-bevel

Conversation

@fernandotonon

Copy link
Copy Markdown
Owner

Summary

N-gon-aware bevel for quad-imported meshes. Replaces the previous "triangle-mode HE copy + restore untouched submeshes" workaround that triangulated the entire touched submesh.

HalfEdgeMesh::bevelEdgesNgon

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 clamps per-edge to 0.4 × shortest perimeter edge of either adjacent face. (The triangle bevel guessed a "third vertex" — wrong on quads where it picks the diagonal.)
  2. Replace f1's loop: substitute v1 → innerV1F1, v2 → innerV2F1. Triangle stays a triangle, quad stays a quad — same arity, just two corners moved inward. No fan diagonal introduced.
  3. Same for f2.
  4. Build per-endpoint chains spanning innerVF1 → innerVF2 with segments-1 intermediates. Each intermediate gets a linear blend along the chord plus a profile-controlled bulge.
  5. Emit chamfer strip (N segment quads) + corner fans at each endpoint (N triangles per endpoint, joining the inner vertices via the original v).

HalfEdgeMesh::bevelVerticesNgon

Per beveled vertex v of valence ≥ 3:

  1. For each incident face f, create one inner vertex at distance w from v along the direction toward f's centroid. Width clamps to half the shortest incident edge.
  2. Replace each incident face's loop: substitute v → inner_f. Original arity preserved.
  3. Cap the corner with a single n-gon face walking the inner vertices in ring order.

Dispatcher

applyBevelTopology and applyBevelVertexTopology pick the n-gon variant when any submesh has n-gon canonical faces. Triangle-only meshes (procedural primitives, post-edit re-entries) keep using the existing well-tested bevelEdges / bevelVertices so we don't lose their crease detection / coplanar-sibling logic.

MVP scope

  • Isolated bevels only — input edges sharing an endpoint are rejected (chained bevels need ring-aware logic; matches existing bevel's same restriction).
  • Edge bevel: full segments + profile support.
  • Vertex bevel: single-segment flat cap; segments / profile reserved for a future rounded-cap extension.

Test plan

  • 240 standalone tests pass.
  • BevelEdgesNgonOnQuadEdgeKeepsQuads: 2 quads sharing an edge → 4 inner verts, 5 faces (2 modified quads + 1 chamfer + 2 corner caps), no fan diagonals.
  • BevelEdgesNgonSegments3ProducesRoundedChamfer: segments=3 → 8 new verts, 11 active faces (2 quads + 3 chamfer segments + 6 corner caps).
  • BevelEdgesNgonRejectsBoundaryEdge / BevelEdgesNgonRejectsChainedSelection.
  • BevelVerticesNgonOnQuadCornerKeepsQuads: 4-quad cross around valence-4 vertex → 4 modified quads + 1 cap quad.
  • Smoke test on FBX quad asset — bevel produces visible chamfer that preserves surrounding quads.

Known follow-ups

  • Vertex bevel rounded cap (segments > 1).
  • Chained bevels (edges sharing an endpoint).

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

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2f25be41-731d-41ca-b7b2-3c8f9d3def54

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ngon-bevel

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

❤️ Share

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

…tions

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.
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.
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.
@fernandotonon
fernandotonon marked this pull request as ready for review April 29, 2026 08:02

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 587cc2e148

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread src/HalfEdgeMesh.cpp Outdated
Comment on lines +3413 to +3417
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Emit endpoint caps for beveled edges with neighbors

The n-gon edge bevel suppresses corner fans whenever an endpoint has any neighboring face, but the chamfer strip still creates side-chain edges that are not inserted into those neighbor loops. On interior edges of closed meshes (for example cube edges where endpoint valence is 3+), this leaves open boundary seams at the bevel endpoints because innerVF1 and innerVF2 are never connected by any non-chamfer face. The endpoint fan should still be emitted (or the full chain must be spliced into neighbors) to keep the result closed.

Useful? React with 👍 / 👎.

Comment thread src/HalfEdgeMesh.cpp
Comment on lines +3560 to +3564
const int idx = static_cast<int>(m_vertices.size());
m_vertices.push_back(std::move(nv));
newVertices.push_back(idx);
innerVerts.push_back(idx);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Roll back partial vertex appends on n-gon vertex-bevel failure

This loop appends new inner vertices to m_vertices and newVertices before all incident faces are validated, then exits early on continue if a later face is invalid or produces a zero-length direction. In that case the function can report created vertices even though no face replacement/cap was applied, so callers may treat the bevel as successful while topology is unchanged. Buffering candidate vertices until validation succeeds (or explicitly removing them on failure) avoids this inconsistent state.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown

@fernandotonon
fernandotonon merged commit 7ab0b04 into feat/quads Apr 29, 2026
34 of 35 checks passed
@fernandotonon
fernandotonon deleted the feat/ngon-bevel branch April 29, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant