From eea49922bdab85755775edf382f8e8422d2113a1 Mon Sep 17 00:00:00 2001 From: Mqrius Date: Wed, 14 Oct 2026 01:27:47 +0200 Subject: [PATCH] fix: face projection walks through pinch points (#899) Where two boundaries of a projected face family touch at a single vertex, an even number of boundary edges meet there. The walk now stays on the face it came along (or takes the first edge in geometric order), so every loop closes; an odd count is still refused as a real branch. Adds a test with two squares meeting at a corner. --- crates/doc/src/project_order.rs | 62 +++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/project_order.rs b/crates/doc/src/project_order.rs index 16717692..d98164d1 100644 --- a/crates/doc/src/project_order.rs +++ b/crates/doc/src/project_order.rs @@ -1,7 +1,7 @@ //! Stable face projection order: outer boundary first, then holes by geometry. //! B-rep edge indices depend on face enumeration and must not choose sketch curve IDs. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use solidcraft_geom::{Mesh, Plane, Vec3}; @@ -17,13 +17,13 @@ pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result Result> { - boundary_edges(mesh, mesh.face_edges(face), plane) + boundary_edges(mesh, mesh.face_edges(face), &[face as usize].into_iter().collect(), plane) } /// Exterior of an exact current face family. Shared topological edges are internal; @@ -42,10 +42,13 @@ pub(super) fn family_edges(mesh: &Mesh, selected: &std::collections::BTreeSet, plane: &Plane) -> Result> { +/// `faces`: the faces the edges bound. Where boundaries touch at a vertex (a pinch: an even number of +/// boundary edges meet there), the walk stays on the face it came along, or else takes the first +/// edge in geometric order; either way every loop closes. An odd number is a real branch. +fn boundary_edges(mesh: &Mesh, mut edges: Vec, faces: &BTreeSet, plane: &Plane) -> Result> { let bounds = mesh.bounds(); let origin = bounds.min; let local_origin = plane.to_local(origin); @@ -111,26 +114,36 @@ fn boundary_edges(mesh: &Mesh, mut edges: Vec, plane: &Plane) -> Result = incident.iter().copied().filter(|e| !used.get(*e).copied().unwrap_or(true)).collect(); if candidates.len() > 1 { - return Err(DocError::Invalid("face projection has a branched boundary".into())); + if incident.len() % 2 != 0 { + return Err(DocError::Invalid("face projection has a branched boundary".into())); + } + let face_of = |e: usize| -> Vec { + mesh.edge_faces.get(e).map(|fs| fs.iter().copied().filter(|f| faces.contains(&(*f as usize))).collect()).unwrap_or_default() + }; + let here = face_of(edge); + let same: Vec = candidates.iter().copied().filter(|c| face_of(*c).iter().any(|f| here.contains(f))).collect(); + if same.len() == 1 { + candidates = same; + } } let Some(next) = candidates.first() else { break }; edge = *next; @@ -183,6 +196,27 @@ mod tests { assert!(family_edges(&mesh, &selected, &Plane::XY).is_err()); } + #[test] + fn boundaries_touching_at_a_vertex_project() { + // Two squares of one face family meet only at the corner (1, 1): four boundary edges meet + // there (a pinch), and the walk keeps each square's loop on its own face. + let mut mesh = Mesh::default(); + for (face, x, y) in [(0, 0., 0.), (1, 1., 1.)] { + let corners = [Vec3::new(x, y, 0.), Vec3::new(x + 1., y, 0.), Vec3::new(x + 1., y + 1., 0.), Vec3::new(x, y + 1., 0.)]; + mesh.positions.extend(corners); + for i in 0..4 { + mesh.edges.push(vec![corners[i], corners[(i + 1) % 4]]); + mesh.edge_faces.push(vec![face]); + } + } + let edges = family_edges(&mesh, &[0, 1].into_iter().collect(), &Plane::XY).unwrap(); + assert_eq!(edges.len(), 8); + // A third edge ending at the corner leaves an odd number there: a real branch. + mesh.edges.push(vec![Vec3::new(1., 1., 0.), Vec3::new(3., 0., 0.)]); + mesh.edge_faces.push(vec![1]); + assert!(family_edges(&mesh, &[0, 1].into_iter().collect(), &Plane::XY).unwrap_err().to_string().contains("branched")); + } + #[test] fn distinct_crossing_boundaries_are_not_geometrically_cancelled() { let mut mesh = Mesh::default();