Add half-edge surface mesh class with bidirectional fv_surface_mesh conversion#49
Add half-edge surface mesh class with bidirectional fv_surface_mesh conversion#49
Conversation
… conversion and tests Agent-Logs-Url: https://github.com/hdclark/Ygor/sessions/709d4ce9-9149-4cb9-8375-2bfcb59fe693 Co-authored-by: hdclark <934858+hdclark@users.noreply.github.com>
Agent-Logs-Url: https://github.com/hdclark/Ygor/sessions/709d4ce9-9149-4cb9-8375-2bfcb59fe693 Co-authored-by: hdclark <934858+hdclark@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a half-edge (DCEL) surface mesh implementation (he_surface_mesh<T,I>) to complement the existing fv_surface_mesh and provide faster adjacency queries, plus bidirectional conversion utilities and unit tests.
Changes:
- Introduces
he_surface_mesh<T,I>with adjacency/boundary queries, normals, surface area, and metadata helpers. - Adds
convert_fv_to_he_surface_mesh()andhe_surface_mesh::convert_to_fv_surface_mesh()for round-trip conversion. - Adds a dedicated doctest suite and wires it into
tests2/compile_and_run.sh.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| tests2/YgorMeshesHalfEdge.cc | New doctest coverage for conversion, adjacency, invariants, and error cases. |
| tests2/compile_and_run.sh | Adds the new test translation unit to the explicit compile list. |
| src/YgorMeshesHalfEdge.h | Declares the new half-edge mesh type and conversion API. |
| src/YgorMeshesHalfEdge.cc | Implements the half-edge mesh operations and fv↔he conversion logic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| I he_idx = static_cast<I>(0); | ||
| for(size_t f = 0; f < N_faces; ++f){ | ||
| const auto &fv = fvsm.faces[f]; | ||
| const auto n = fv.size(); | ||
| if(n < 2) continue; // Degenerate face, skip. | ||
|
|
||
| const I first_he = he_idx; | ||
| out.face_halfedges[f] = first_he; |
There was a problem hiding this comment.
convert_fv_to_he_surface_mesh() skips faces with n < 2, but halfedges was pre-sized using the original face sizes and face_halfedges was resized for all faces. This leaves extra (default-initialized) halfedges at the end and leaves face_halfedges[f] unset for skipped faces, which can corrupt num_halfedges()/adjacency queries and can make face_vertices()/convert_to_fv_surface_mesh() index invalid data. Consider either (a) rejecting/throwing on non-triangular/degenerate faces up front, or (b) compacting the halfedge buffer (push_back) and setting face_halfedges[f]=sentinel for skipped faces, then resizing halfedges to he_idx at the end.
There was a problem hiding this comment.
reject/throw on non-triangular/degenerate faces up front
There was a problem hiding this comment.
Done — convert_fv_to_he_surface_mesh() now throws std::runtime_error for faces with < 3 vertices. Test added in b76d689.
| // The mesh may represent manifold surfaces, manifold surfaces with boundary, | ||
| // or be empty. Non-manifold meshes cannot be faithfully represented and | ||
| // construction from such a mesh will throw. |
There was a problem hiding this comment.
The header comment states that construction from “non-manifold meshes … will throw”, but convert_fv_to_he_surface_mesh() currently only detects repeated directed edges. Non-manifold vertices (e.g., a bow-tie / multiple disconnected fans around the same vertex) can still be created without triggering this check and will break vertex fan traversals. Either broaden validation to detect non-manifold vertices or narrow/clarify this statement to match the actual guarantees.
There was a problem hiding this comment.
broaden validation to detect non-manifold vertices
There was a problem hiding this comment.
Done — after twin linking, the converter now walks each vertex's one-ring fan and compares the count against total outgoing half-edges. A mismatch (disconnected fans / bow-tie) throws std::runtime_error. Test added in b76d689.
| const auto N_faces = this->face_halfedges.size(); | ||
| for(size_t f = 0; f < N_faces; ++f){ | ||
| const auto verts = this->face_vertices(static_cast<I>(f)); | ||
| if(verts.size() < 3) continue; | ||
|
|
||
| const auto P_A = this->vertices.at( verts[0] ); | ||
| const auto P_B = this->vertices.at( verts[1] ); | ||
| const auto P_C = this->vertices.at( verts[2] ); | ||
|
|
There was a problem hiding this comment.
compute_vertex_normals() implicitly assumes triangular faces (uses verts[0..2] to compute C) but then applies that normal to all vertices in verts. If a face has >3 vertices, this silently produces incorrect results (and differs from surface_area(), which explicitly throws on non-triangular faces). Consider either validating verts.size()==3 here (and throwing) or implementing a polygon-normal/triangulation strategy consistent with the rest of the API.
There was a problem hiding this comment.
Validate verts.size()==3 here (and throw if not valid)
There was a problem hiding this comment.
Done — compute_vertex_normals() now throws std::runtime_error if any face has verts.size() != 3. Applied in b76d689.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@copilot apply changes based on the comments in this thread |
Agent-Logs-Url: https://github.com/hdclark/Ygor/sessions/e406e7fa-47e0-4b99-b168-5679860bd5c6 Co-authored-by: hdclark <934858+hdclark@users.noreply.github.com>
|
@copilot apply changes based on the comments in this thread |
…y vertex_neighbours walk, add error path tests Agent-Logs-Url: https://github.com/hdclark/Ygor/sessions/33312766-f381-4a3f-bf5f-853bba54fa2c Co-authored-by: hdclark <934858+hdclark@users.noreply.github.com>
Applied all three changes from the review in commit b76d689:
Also fixed a bug in Added test cases for all three new error paths (degenerate face, invalid vertex index, bow-tie vertex). Full suite passes: 290 cases / 99884 assertions. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // The half-edge data structure provides efficient adjacency queries for | ||
| // manifold (or manifold-with-boundary) triangle meshes. Each directed edge | ||
| // is stored as a "half-edge" that knows its origin vertex, incident face, |
There was a problem hiding this comment.
The file-level comment says this half-edge structure is for manifold triangle meshes, but convert_fv_to_he_surface_mesh() only rejects faces with <3 vertices and will happily build non-triangular (n>3) face loops. Either tighten the converter to require exactly 3 vertices per face (so downstream APIs like surface_area()/compute_vertex_normals() don’t fail later), or update the header documentation to explicitly allow polygonal faces and document which operations require triangulation.
There was a problem hiding this comment.
tighten the converter to require exactly 3 vertices per face
| const auto N_verts = fvsm.vertices.size(); | ||
| const auto N_faces = fvsm.faces.size(); | ||
|
|
||
| out.vertex_halfedges.assign(N_verts, sentinel); | ||
| out.face_halfedges.resize(N_faces); | ||
|
|
||
| // First pass: count the total number of half-edges needed. | ||
| size_t total_he = 0; | ||
| for(const auto &fv : fvsm.faces){ | ||
| total_he += fv.size(); | ||
| } | ||
| out.halfedges.resize(total_he); |
There was a problem hiding this comment.
convert_fv_to_he_surface_mesh() uses size_t for N_verts/N_faces/total_he but then drives indexing with I (he_idx, first_he, he.next/prev). If the input exceeds numeric_limits::max(), he_idx/indices can wrap and lead to out-of-bounds access/undefined behavior. Consider validating that vertices/faces/total half-edges all fit in I (and throwing) and/or using size_t for internal counters while only casting to I at assignment points after bounds checks.
There was a problem hiding this comment.
use size_t for internal counters while only casting to I at assignment points after bounds checks
| // Insert into edge map for twin lookup. | ||
| const I v_from = v_idx; | ||
| const I v_to = fv[(j + 1) % n]; | ||
| auto key = std::make_pair(v_from, v_to); | ||
|
|
||
| auto [it, inserted] = edge_to_he.emplace(key, he_idx); | ||
| if(!inserted){ | ||
| throw std::runtime_error("Non-manifold edge detected: directed edge (" | ||
| + std::to_string(static_cast<uint64_t>(v_from)) + " -> " | ||
| + std::to_string(static_cast<uint64_t>(v_to)) | ||
| + ") appears in multiple faces. Cannot build half-edge mesh."); | ||
| } | ||
|
|
||
| ++he_idx; | ||
| } | ||
| } | ||
|
|
||
| // Third pass: link twins. | ||
| for(auto &[key, idx] : edge_to_he){ | ||
| auto twin_key = std::make_pair(key.second, key.first); | ||
| auto it = edge_to_he.find(twin_key); | ||
| if(it != edge_to_he.end()){ | ||
| out.halfedges[idx].twin = it->second; | ||
| out.halfedges[it->second].twin = idx; | ||
| } |
There was a problem hiding this comment.
The converter does not reject degenerate edges like v_from==v_to or repeated vertex indices within a face. Those cases can create self-loop directed edges where twin_key==key, causing a half-edge to become its own twin (or twins within the same face), which breaks half-edge invariants and can corrupt adjacency queries. Add validation per face to reject self-loop edges / duplicate vertices (and/or guard the twin-linking pass against self-twinning).
|
@copilot apply changes based on the comments in this thread |
|
@copilot apply changes based on the comments in this thread |
|
@copilot apply changes based on the comments in this thread |
|
@copilot apply changes based on the comments in this thread |
…nds checks, reject duplicate vertices Agent-Logs-Url: https://github.com/hdclark/Ygor/sessions/8426bb94-691c-45b7-ada7-acaba582d5cf Co-authored-by: hdclark <934858+hdclark@users.noreply.github.com>
Agent-Logs-Url: https://github.com/hdclark/Ygor/sessions/8426bb94-691c-45b7-ada7-acaba582d5cf Co-authored-by: hdclark <934858+hdclark@users.noreply.github.com>
Applied all three changes from the review in commits d4c6156 and b2b8d36:
Tests added for non-triangular faces and duplicate vertices. Full suite passes: 290 cases / 99886 assertions. |
Adds
he_surface_mesh<T,I>insrc/YgorMeshesHalfEdge.{h,cc}— a half-edge (DCEL) representation complementingfv_surface_meshwith O(1) adjacency queries.Data structure
half_edge_t<I>record:vertex,face,twin,next,prevstd::numeric_limits<I>::max()) for boundary/missing referencesAPI surface (mirrors
fv_surface_meshwhere appropriate)operator==/!=,swapsurface_area(),compute_vertex_normals(),pack/unpack_RGBA32_colourMetadataKeyPresent(),GetMetadataValueAs<U>()Half-edge specific
vertex_faces(),vertex_neighbours(),face_vertices(),face_neighbours()is_boundary_vertex(),is_boundary_halfedge()num_vertices(),num_faces(),num_halfedges(),num_edges()Bidirectional conversion
Input validation
convert_fv_to_he_surface_mesh()requires exactly 3 vertices per face (triangular meshes only), rejects invalid vertex indices, duplicate vertex indices within a face (self-loop edges), non-manifold edges (repeated directed edges), and non-manifold vertices (bow-tie / disconnected fans) — all throwstd::runtime_errorwith descriptive messages.Ibefore conversion proceeds, preventing index overflow.compute_vertex_normals()validates that all faces are triangular and throws for non-triangular faces.size_twith bounds-checked casts toIat assignment points.Tests
99886 assertions in
tests2/YgorMeshesHalfEdge.cccovering round-trip fidelity, adjacency correctness, Euler invariant, half-edge structural consistency, boundary handling, and error cases (degenerate faces, non-triangular faces, duplicate vertex indices, invalid vertex indices, non-manifold edges, bow-tie vertices). Full suite (290 cases / 99886 assertions) passes.Original prompt