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();