From b126c2293b0e251759033c7058b35318ffcb051a Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:35:41 -0400 Subject: [PATCH] geometry: virtual faces, adjacent faces merged for meshing only (#505) A Merge makes adjacent faces of a body one virtual face in what meshers take from it (tessellation tags, faces, surfaces, edge and vertex names): the edges inside it are dropped and the pieces of an edge it leaves are joined; the B-rep is unchanged. Design::merged applies merges after the partitions and lists the ones that fail as feature errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/geometry/src/design.rs | 16 +- crates/geometry/src/features.rs | 9 +- crates/geometry/src/lib.rs | 30 +- crates/geometry/src/partition.rs | 11 +- crates/geometry/src/virtual_faces.rs | 417 +++++++++++++++++++++++++++ 5 files changed, 463 insertions(+), 20 deletions(-) create mode 100644 crates/geometry/src/virtual_faces.rs diff --git a/crates/geometry/src/design.rs b/crates/geometry/src/design.rs index 4ea17e7..f671685 100644 --- a/crates/geometry/src/design.rs +++ b/crates/geometry/src/design.rs @@ -27,6 +27,9 @@ pub struct Design { pub cuts: Vec, /// The bodies as opened, before any partition: what [`Design::partitioned`] starts from. pub(crate) source: std::sync::Arc>, + /// The bodies as opened with the face merges on them ([`Design::merged`]): what + /// [`Design::source_body`] and [`Design::features`] describe. + pub(crate) opened: std::sync::Arc>, /// The timeline the design was rebuilt from ([`Design::features`]). pub(crate) history: Option>, } @@ -79,9 +82,9 @@ impl Design { self.bodies.iter().find(|b| b.name == name) } - /// The body `name` of the design as opened (before any partition). + /// The body `name` of the design as opened (before any partition), with its merged faces. pub fn source_body(&self, name: &str) -> Option<&Shape> { - self.source.iter().find(|b| b.name == name) + self.opened.iter().find(|b| b.name == name) } /// The body of the design as opened that `name` is, or that partitions cut it from @@ -120,9 +123,13 @@ pub(crate) fn rebuild(doc: &Document) -> Result { for b in &state.bodies { let faces = guard("name faces", || { let names = naming::face_names(b); - Ok(names.iter().enumerate().map(|(i, name)| Face { name: name.clone(), exact: naming::face_name_exact(b, i) }).collect()) + Ok(names + .iter() + .enumerate() + .map(|(i, name)| Face { name: name.clone(), exact: naming::face_name_exact(b, i), members: Vec::new() }) + .collect()) })?; - bodies.push(Shape { name: b.name.clone(), body: b.body.clone(), faces }); + bodies.push(Shape { name: b.name.clone(), body: b.body.clone(), faces, face_map: Vec::new() }); } // A feature that failed leaves a different solid than SolidCraft's: each one names the bodies // it would have changed, so opening never hands back a silently different solid. @@ -152,6 +159,7 @@ pub(crate) fn rebuild(doc: &Document) -> Result { Ok(Design { name: doc.name.clone(), source: std::sync::Arc::new(bodies.clone()), + opened: std::sync::Arc::new(bodies.clone()), bodies, errors, cuts: Vec::new(), diff --git a/crates/geometry/src/features.rs b/crates/geometry/src/features.rs index 26ee084..c0a051e 100644 --- a/crates/geometry/src/features.rs +++ b/crates/geometry/src/features.rs @@ -374,19 +374,20 @@ impl Design { let shapes: Vec<&Shape> = match body { Some(name) => { let source = self.source_of(name).ok_or_else(|| crate::Error::Invalid(format!("no body `{name}`")))?; - self.source.iter().filter(|b| b.name == source).collect() + self.opened.iter().filter(|b| b.name == source).collect() } - None => self.source.iter().collect(), + None => self.opened.iter().collect(), }; shapes.into_iter().map(|shape| guard("feature graph", || Ok(body_features(history, shape))).map_err(crate::Error::from)?).collect() } } fn body_features(history: &History, shape: &Shape) -> Result { - // Faces by the features their names mention. + // Faces by the features their names mention (a merged face: the names of its B-rep faces). let mut faces: BTreeMap> = BTreeMap::new(); for f in shape.faces() { - let ids: BTreeSet = feature_ids(&f.name).collect(); + let ids: BTreeSet = + if f.members.is_empty() { feature_ids(&f.name).collect() } else { f.members.iter().flat_map(|m| feature_ids(m)).collect() }; for id in ids { faces.entry(id).or_default().push(f.name.clone()); } diff --git a/crates/geometry/src/lib.rs b/crates/geometry/src/lib.rs index 3dd6e3e..e5419ce 100644 --- a/crates/geometry/src/lib.rs +++ b/crates/geometry/src/lib.rs @@ -25,11 +25,13 @@ pub mod partition; pub mod sample; #[cfg(feature = "scripts")] mod script; +pub mod virtual_faces; pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design}; pub use edges::{EDGE_TOLERANCE, Edge, Vertex}; pub use features::{BodyFeatures, Curve, FeatureNode, ProfileGeometry, SketchGeometry}; pub use partition::{Cut, CutReport}; +pub use virtual_faces::Merge; pub mod tagged_mesh; #[cfg(feature = "scripts")] pub use script::{MAX_SCRIPT_BYTES, script_design}; @@ -62,8 +64,11 @@ const LINEAR_TOLERANCE: f64 = solidcraft_geom::LINEAR_TOL; pub struct Shape { pub name: String, body: Body, - /// By B-rep face index (the [`SurfaceMesh::tri_face`] tags); empty for shapes built in code. + /// By face index (the [`SurfaceMesh::tri_face`] tags); empty for shapes built in code. With + /// merged faces ([`virtual_faces`]) these are the virtual faces, not the B-rep's. faces: Vec, + /// The virtual face (index into `faces`) of each B-rep face; empty: they are the same. + face_map: Vec, } /// A B-rep face's persistent identity. @@ -75,6 +80,8 @@ pub struct Face { /// The name follows the history SolidCraft recorded; `false` when it was worked out from the /// geometry, which an upstream edit can fool. pub exact: bool, + /// A merged face's B-rep faces, by persistent name ([`virtual_faces`]); empty for a B-rep face. + pub members: Vec, } /// The analytic surface a B-rep face lies on, when it is one of the elementary kinds. Directions @@ -148,7 +155,7 @@ fn xyz(v: solidcraft_geom::Vec3) -> [f64; 3] { pub fn block(a: [f64; 3], b: [f64; 3]) -> Result { let v = |p: [f64; 3]| solidcraft_geom::Vec3 { x: p[0], y: p[1], z: p[2] }; let body = guard("block", || solidcraft_kernel::box_solid(v(a), v(b)))?; - Ok(Shape { name: "Block".into(), body, faces: Vec::new() }) + Ok(Shape { name: "Block".into(), body, faces: Vec::new(), face_map: Vec::new() }) } impl Shape { @@ -188,8 +195,15 @@ impl Shape { } /// The analytic surface of each B-rep face, by face index (the [`SurfaceMesh::tri_face`] - /// tags): `None` for a free-form face. Meshers project nodes onto these surfaces. + /// tags): `None` for a free-form face. Meshers project nodes onto these surfaces. A merged + /// face has the surface its B-rep faces share, `None` when they lie on different ones. pub fn face_surfaces(&self) -> Result>> { + let brep = self.brep_face_surfaces()?; + self.virtual_surfaces(brep) + } + + /// [`Shape::face_surfaces`] by B-rep face index. + pub(crate) fn brep_face_surfaces(&self) -> Result>> { guard("face surfaces", || { Ok((0..self.body.face_count()) .map(|f| { @@ -231,24 +245,26 @@ impl Shape { centroid: xyz(m.centroid), bbox_min: xyz(m.bbox.min), bbox_max: xyz(m.bbox.max), - faces: m.faces, + faces: if self.face_map.is_empty() { m.faces } else { self.faces.len() }, }) } - /// Triangulate the boundary within `tolerance` (chordal deviation, mm). + /// Triangulate the boundary within `tolerance` (chordal deviation, mm). Triangles are tagged + /// by face index ([`Shape::faces`]): merged faces are one face, without the edges between + /// their B-rep faces. pub fn tessellate(&self, tolerance: f64) -> Result { if !(tolerance > 0.0 && tolerance.is_finite()) { return Err(Error::Invalid(format!("tessellation tolerance must be a positive number, got {tolerance}"))); } let m = guard("tessellate", || self.body.tessellate(tolerance))?; - Ok(SurfaceMesh { + Ok(self.virtual_mesh(SurfaceMesh { positions: m.positions.into_iter().map(xyz).collect(), triangles: m.triangles, tri_face: m.tri_face, weld_distance: 2.0 * LINEAR_TOLERANCE, edges: m.edges.into_iter().map(|e| e.into_iter().map(xyz).collect()).collect(), edge_faces: m.edge_faces, - }) + })) } } diff --git a/crates/geometry/src/partition.rs b/crates/geometry/src/partition.rs index f1489f0..a1a79fa 100644 --- a/crates/geometry/src/partition.rs +++ b/crates/geometry/src/partition.rs @@ -101,7 +101,7 @@ impl Design { } let planes: Vec<(Plane, [f64; 3])> = cuts.iter().map(plane_of).collect::>()?; let mut bodies: Vec = self.source.as_ref().clone(); - let mut errors: Vec = self.errors.iter().filter(|e| !e.feature.starts_with("partition ")).cloned().collect(); + let mut errors: Vec = self.errors.iter().filter(|e| !crate::virtual_faces::derived(&e.feature)).cloned().collect(); let mut reports = Vec::with_capacity(cuts.len()); for (cut, (plane, n)) in cuts.iter().zip(&planes) { let feature = format!("partition {}", cut.name); @@ -176,6 +176,7 @@ impl Design { errors, cuts: reports, source: std::sync::Arc::clone(&self.source), + opened: std::sync::Arc::clone(&self.source), history: self.history.clone(), }) } @@ -276,10 +277,10 @@ fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result { let parent = Faces::of(body, tol)?; let mut cells: Vec<(bool, [f64; 3], Shape)> = Vec::with_capacity(parts.len()); for part in parts { - let mut shape = Shape { name: String::new(), body: part, faces: Vec::new() }; + let mut shape = Shape { name: String::new(), body: part, faces: Vec::new(), face_map: Vec::new() }; let m = shape.measure()?; let negative = dot(sub(m.centroid, cut.point), n) < 0.0; - shape.faces = (0..m.faces).map(|_| Face { name: String::new(), exact: false }).collect(); + shape.faces = (0..m.faces).map(|_| Face { name: String::new(), exact: false, members: Vec::new() }).collect(); cells.push((negative, m.centroid, shape)); } cells.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| lexical(&a.1, &b.1))); @@ -345,7 +346,7 @@ impl Shape { /// Whether two analytic surfaces are the same surface (within `tol`); free-form faces match any /// free-form face. -fn same_surface(a: Option, b: Option, tol: f64) -> bool { +pub(crate) fn same_surface(a: Option, b: Option, tol: f64) -> bool { let close = |x: [f64; 3], y: [f64; 3]| dot(sub(x, y), sub(x, y)).sqrt() <= tol; let parallel = |x: [f64; 3], y: [f64; 3]| dot(cross(x, y), cross(x, y)).sqrt() <= 1e-6; let on_line = |p: [f64; 3], o: [f64; 3], axis: [f64; 3]| { @@ -414,7 +415,7 @@ fn point_triangle(p: [f64; 3], a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 { } /// The name without a trailing piece number (`F2:top#1` → `F2:top`). -fn base_name(name: &str) -> &str { +pub(crate) fn base_name(name: &str) -> &str { match name.rsplit_once('#') { Some((b, k)) if !k.is_empty() && k.bytes().all(|c| c.is_ascii_digit()) => b, _ => name, diff --git a/crates/geometry/src/virtual_faces.rs b/crates/geometry/src/virtual_faces.rs new file mode 100644 index 0000000..f0119ba --- /dev/null +++ b/crates/geometry/src/virtual_faces.rs @@ -0,0 +1,417 @@ +//! Virtual topology: faces merged for meshing only. +//! +//! SolidCraft's kernel cannot heal a B-rep (no face merge or split), and a mesher should not have +//! to: a fillet the kernel left in pieces, or a face an imprint split, only needs to *mesh* as one +//! face. A [`Merge`] makes adjacent faces of a body one virtual face in what the meshers take from +//! the body ([`Shape::tessellate`], [`Shape::faces`], [`Shape::face_surfaces`], edge and vertex +//! names): the triangles of its B-rep faces carry one tag, the edges between them are gone (an +//! edge they leave split in pieces along a neighbour is joined into one), and its surface is the +//! one its B-rep faces share (none when they lie on different surfaces: meshers then keep to the +//! tessellation). The B-rep itself is unchanged. +//! +//! Merges live in the FieldCraft model, by persistent face names, and are applied again after the +//! partitions whenever the design is opened, so an edit of the design followed by a re-open +//! replays them. A merge whose faces are gone or no longer touch is listed in [`Design::errors`] +//! (feature `merge `), as a feature that failed to rebuild; the others still apply. +//! +//! The merged face is named `merge:`. A face name in a merge selects the face of that name, +//! or the pieces a partition cut it into (`#0`, `#1`, …), as sets do. + +use std::collections::BTreeSet; + +use crate::edges::EDGE_TOLERANCE; +use crate::{Design, Error, Face, FaceSurface, FeatureError, Result, Shape, SurfaceMesh}; + +/// Most merges a design takes. +pub const MAX_MERGES: usize = 1024; +/// Most faces one merge names. +pub const MAX_MERGE_FACES: usize = 1024; +/// Most edges of one pair of faces joined end to end (more are left as they are). +const MAX_JOINED: usize = 256; + +/// Faces of one body made one face for meshing. +#[derive(Debug, Clone, PartialEq)] +pub struct Merge { + /// Names the merged face (`merge:`). + pub name: String, + pub body: String, + /// The faces, by persistent name (a name also selects the pieces a partition cut it into). + pub faces: Vec, +} + +/// The name of the face merge `name` makes. +pub fn merged_face_name(name: &str) -> String { + format!("merge:{name}") +} + +/// Whether a feature error comes from the model's partitions or merges (applied again from the +/// design as opened) rather than from the design's own timeline. +pub(crate) fn derived(feature: &str) -> bool { + feature.starts_with("partition ") || feature.starts_with("merge ") +} + +impl Design { + /// The design with `merges` applied in order, on top of its partitions. A merge that fails on + /// this design (a body or face that is not there, faces that do not touch) is listed in + /// [`Design::errors`] (feature `merge `) and changes nothing; the rest still apply. A + /// body no partition cut is also merged as opened ([`Design::source_body`], the feature graph). + pub fn merged(mut self, merges: &[Merge]) -> Result { + if merges.len() > MAX_MERGES { + return Err(Error::Invalid(format!("a design takes at most {MAX_MERGES} face merges"))); + } + for m in merges { + let feature = format!("merge {}", m.name); + let Some(body) = self.bodies.iter_mut().find(|b| b.name == m.body) else { + self.errors.push(FeatureError { feature, message: format!("no body `{}`", m.body), bodies: vec![m.body.clone()] }); + continue; + }; + match body.merge_faces(&merged_face_name(&m.name), &m.faces) { + Ok(merged) => *body = merged, + Err(e) => self.errors.push(FeatureError { feature, message: e.to_string(), bodies: vec![m.body.clone()] }), + } + } + if self.bodies.iter().any(|b| !b.face_map.is_empty()) { + let opened = self.source.iter().map(|s| self.bodies.iter().find(|b| b.name == s.name).unwrap_or(s).clone()).collect(); + self.opened = std::sync::Arc::new(opened); + } + Ok(self) + } +} + +impl Shape { + /// The faces with this name: the face itself, else the pieces a partition cut it into + /// (`#k`). + pub fn faces_named(&self, name: &str) -> Vec { + let exact: Vec = + (0..self.faces.len()).filter(|&i| self.faces.get(i).is_some_and(|f| f.name == name)).filter_map(|i| u32::try_from(i).ok()).collect(); + if !exact.is_empty() { + return exact; + } + (0..self.faces.len()) + .filter(|&i| self.faces.get(i).is_some_and(|f| crate::partition::base_name(&f.name) == name && f.name != name)) + .filter_map(|i| u32::try_from(i).ok()) + .collect() + } + + /// The faces made of several B-rep faces (by face index), each with how many. + pub fn merged_faces(&self) -> Vec<(u32, usize)> { + let mut count = vec![0usize; self.faces.len()]; + for &f in &self.face_map { + if let Some(c) = count.get_mut(f as usize) { + *c += 1; + } + } + count.into_iter().enumerate().filter(|(_, c)| *c > 1).filter_map(|(i, c)| Some((u32::try_from(i).ok()?, c))).collect() + } + + /// This shape with the faces named `faces` (two or more, touching along edges) made one face + /// named `name`, in place of the first of them. + pub fn merge_faces(&self, name: &str, faces: &[String]) -> Result { + if faces.len() > MAX_MERGE_FACES { + return Err(Error::Invalid(format!("a merge takes at most {MAX_MERGE_FACES} faces"))); + } + if self.faces.iter().any(|f| f.name == name) { + return Err(Error::Invalid(format!("body `{}` already has a face `{name}`", self.name))); + } + let mut members = BTreeSet::new(); + for f in faces { + let found = self.faces_named(f); + if found.is_empty() { + return Err(Error::Invalid(format!("body `{}` has no face `{f}`", self.name))); + } + members.extend(found); + } + if members.len() < 2 { + return Err(Error::Invalid("a merge needs two or more faces".into())); + } + self.connected(&members)?; + // The faces left, the merged one where its first face was; the old index → the new one. + let mut kept: Vec = Vec::with_capacity(self.faces.len()); + let mut renumber = Vec::with_capacity(self.faces.len()); + let mut merged: Option = None; + for (i, f) in self.faces.iter().enumerate() { + let index = u32::try_from(kept.len()).map_err(|_| Error::Invalid("too many faces".into()))?; + if !u32::try_from(i).is_ok_and(|i| members.contains(&i)) { + renumber.push(index); + kept.push(f.clone()); + continue; + } + match merged { + Some(m) => { + renumber.push(m); + if let Some(face) = kept.get_mut(m as usize) { + face.exact &= f.exact; + face.members.extend(members_of(f)); + } + } + None => { + merged = Some(index); + renumber.push(index); + kept.push(Face { name: name.to_string(), exact: f.exact, members: members_of(f) }); + } + } + } + let brep = if self.face_map.is_empty() { (0..u32::try_from(self.faces.len()).unwrap_or(0)).collect() } else { self.face_map.clone() }; + let face_map = brep.iter().map(|&v| renumber.get(v as usize).copied().unwrap_or(v)).collect(); + Ok(Shape { name: self.name.clone(), body: self.body.clone(), faces: kept, face_map }) + } + + /// An error unless `members` touch one another along edges, all in one piece. + fn connected(&self, members: &BTreeSet) -> Result<()> { + let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?; + let mut reached = BTreeSet::new(); + let mut todo: Vec = members.first().copied().into_iter().collect(); + while let Some(f) = todo.pop() { + if !reached.insert(f) { + continue; + } + for faces in mesh.edge_faces.iter().filter(|fs| fs.contains(&f)) { + todo.extend(faces.iter().filter(|g| members.contains(g) && !reached.contains(g))); + } + } + match members.iter().find(|f| !reached.contains(f)) { + None => Ok(()), + Some(&f) => Err(Error::Invalid(format!( + "face `{}` of body `{}` does not touch the others along an edge: a merge makes adjacent faces one", + self.face_label(f), + self.name + ))), + } + } + + /// The surfaces of the B-rep faces face `face` is made of (one for a face not merged); `None` + /// for a free-form one. + pub fn member_surfaces(&self, face: u32) -> Result>> { + let brep = self.brep_face_surfaces()?; + if self.face_map.is_empty() { + return Ok(brep.get(face as usize).copied().into_iter().collect()); + } + Ok(self.face_map.iter().zip(brep).filter(|(v, _)| **v == face).map(|(_, s)| s).collect()) + } + + /// `brep` (surfaces by B-rep face) by face: a merged face has its B-rep faces' surface when + /// they share one, else none. + pub(crate) fn virtual_surfaces(&self, brep: Vec>) -> Result>> { + if self.face_map.is_empty() { + return Ok(brep); + } + let tol = (self.size()? * 1e-6).max(1e-9); + let mut out: Vec>> = vec![None; self.faces.len()]; + for (b, &v) in self.face_map.iter().enumerate() { + let surface = brep.get(b).copied().flatten(); + if let Some(slot) = out.get_mut(v as usize) { + *slot = match *slot { + None => Some(surface), + Some(s) if crate::partition::same_surface(s, surface, tol) && s.is_some() => Some(s), + Some(_) => Some(None), + }; + } + } + Ok(out.into_iter().map(Option::flatten).collect()) + } + + /// `mesh` (a tessellation of the B-rep) with the merged faces: triangles tagged by face, + /// the edges inside a merged face dropped and the pieces of an edge it leaves joined. + pub(crate) fn virtual_mesh(&self, mut mesh: SurfaceMesh) -> SurfaceMesh { + if self.face_map.is_empty() { + return mesh; + } + let map = |f: u32| self.face_map.get(f as usize).copied().unwrap_or(f); + for f in &mut mesh.tri_face { + *f = map(*f); + } + let mut edges = Vec::with_capacity(mesh.edges.len()); + let mut edge_faces = Vec::with_capacity(mesh.edges.len()); + for (points, faces) in mesh.edges.into_iter().zip(mesh.edge_faces) { + let two = faces.windows(2).any(|w| w[0] != w[1]); + let mut mapped: Vec = faces.iter().map(|&f| map(f)).collect(); + mapped.dedup(); + // Between two B-rep faces of one merged face: inside it. + if two && mapped.windows(2).all(|w| w[0] == w[1]) { + continue; + } + edges.push(points); + edge_faces.push(mapped); + } + join_pieces(&mut edges, &mut edge_faces, (2.0 * mesh.weld_distance).max(1e-9)); + mesh.edges = edges; + mesh.edge_faces = edge_faces; + mesh + } +} + +/// The B-rep faces `f` is made of, by name: itself, or a merged face's. +fn members_of(f: &Face) -> Vec { + if f.members.is_empty() { vec![f.name.clone()] } else { f.members.clone() } +} + +fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { + (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::().sqrt() <= tol +} + +/// Join edges between the same faces end to end where they meet and no other edge ends: the +/// pieces of one edge a merge left (a fillet's pieces each had their own edge along the face +/// beside them). +fn join_pieces(edges: &mut Vec>, faces: &mut Vec>, tol: f64) { + let key = |f: &Vec| { + let mut k = f.clone(); + k.sort_unstable(); + k.dedup(); + k + }; + // Every edge end, to count the edges meeting at a point. + let ends = + |edges: &[Vec<[f64; 3]>], p: [f64; 3]| edges.iter().flat_map(|e| [e.first(), e.last()]).flatten().filter(|q| close(**q, p, tol)).count(); + let mut joined = 0; + 'again: while joined < MAX_JOINED { + for i in 0..edges.len() { + for j in (i + 1)..edges.len() { + let (Some(a), Some(b)) = (edges.get(i), edges.get(j)) else { continue }; + if faces.get(i).map(key) != faces.get(j).map(key) || a.len() < 2 || b.len() < 2 { + continue; + } + let (a0, a1, b0, b1) = (a[0], a[a.len() - 1], b[0], b[b.len() - 1]); + // Closed already, or would close into a loop at both ends: leave it. + if close(a0, a1, tol) || close(b0, b1, tol) { + continue; + } + let at = if close(a1, b0, tol) { + Some((a1, false, false)) + } else if close(a1, b1, tol) { + Some((a1, false, true)) + } else if close(a0, b1, tol) { + Some((a0, true, true)) + } else if close(a0, b0, tol) { + Some((a0, true, false)) + } else { + None + }; + let Some((p, flip_a, flip_b)) = at else { continue }; + if ends(edges, p) != 2 { + continue; + } + let mut b = edges.remove(j); + faces.remove(j); + let Some(a) = edges.get_mut(i) else { return }; + if flip_a { + a.reverse(); + } + if flip_b { + b.reverse(); + } + a.extend(b.into_iter().skip(1)); + joined += 1; + continue 'again; + } + } + break; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sample::{self, Plate}; + use crate::{FaceSurface, open_design}; + + fn plate() -> Design { + open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap() + } + + /// The plate's hole is two half-cylinders. + fn halves(d: &Design) -> Vec { + let body = &d.bodies[0]; + let s = body.face_surfaces().unwrap(); + body.faces().iter().zip(s).filter(|(_, s)| matches!(s, Some(FaceSurface::Cylinder { .. }))).map(|(f, _)| f.name.clone()).collect() + } + + /// Merging the hole's halves makes one cylindrical face: one tag on its triangles, its seams + /// gone from the edges, the rim arcs joined into one circle each, and the edge and face names + /// follow; the B-rep keeps its faces. + #[test] + fn merged_halves_are_one_face_without_seams() { + let d = plate(); + let hole = halves(&d); + assert_eq!(hole.len(), 2, "{hole:?}"); + let before = d.bodies[0].tessellate(0.01).unwrap(); + let body = d.bodies[0].name.clone(); + let d = d.merged(&[Merge { name: "Hole".into(), body, faces: hole.clone() }]).unwrap(); + assert!(d.errors.is_empty(), "{:?}", d.errors); + let body = &d.bodies[0]; + assert_eq!(body.faces().len(), 7); + let f = body.face_index("merge:Hole").unwrap(); + assert!(body.face_index(&hole[0]).is_none()); + assert!(matches!(body.face_surfaces().unwrap()[f as usize], Some(FaceSurface::Cylinder { .. }))); + assert_eq!(body.merged_faces(), vec![(f, 2)]); + let mesh = body.tessellate(0.01).unwrap(); + assert_eq!(mesh.triangles, before.triangles); + let tags: BTreeSet = mesh.tri_face.iter().copied().collect(); + assert_eq!(tags.len(), 7); + // 12 box edges and the two rims, each now one circle. + let edges = body.edges().unwrap(); + assert_eq!(edges.len(), 14, "{:?}", edges.iter().map(|e| &e.name).collect::>()); + assert!(edges.iter().all(|e| e.faces.iter().all(|&g| (g as usize) < 7))); + let rims: Vec<_> = edges.iter().filter(|e| e.faces.contains(&f)).collect(); + assert_eq!(rims.len(), 2); + for r in rims { + assert!(r.name.contains("merge:Hole"), "{}", r.name); + assert!(close(r.points[0], *r.points.last().unwrap(), 1e-6), "a rim is a closed circle"); + } + assert_eq!(body.measure().unwrap().faces, 7); + } + + /// A merge of faces that do not touch, of one face, of a missing face or body is an error + /// listed with the design, which keeps its faces. + #[test] + fn bad_merges_are_listed_and_change_nothing() { + let d = plate(); + let body = d.bodies[0].name.clone(); + let names: Vec = d.bodies[0].faces().iter().map(|f| f.name.clone()).collect(); + let s = d.bodies[0].face_surfaces().unwrap(); + let planes: Vec<&String> = names + .iter() + .zip(&s) + .filter(|(_, s)| matches!(s, Some(FaceSurface::Plane { normal, .. }) if normal[2].abs() > 0.5)) + .map(|(n, _)| n) + .collect(); + assert_eq!(planes.len(), 2, "top and bottom"); + let m = |name: &str, body: &str, faces: Vec| Merge { name: name.into(), body: body.into(), faces }; + let merged = d + .clone() + .merged(&[ + m("Apart", &body, planes.iter().map(|s| s.to_string()).collect()), + m("One", &body, vec![names[0].clone()]), + m("Missing", &body, vec![names[0].clone(), "nope".into()]), + m("Nobody", "Nobody", vec![names[0].clone(), names[1].clone()]), + ]) + .unwrap(); + let failed: Vec<&str> = merged.errors.iter().map(|e| e.feature.as_str()).collect(); + assert_eq!(failed, ["merge Apart", "merge One", "merge Missing", "merge Nobody"]); + assert!(merged.errors[0].message.contains("does not touch"), "{}", merged.errors[0].message); + assert_eq!(merged.bodies[0].faces().len(), 8); + // A second merge into a name already there is refused too. + let twice = d.merged(&[m("H", &body, halves(&plate())), m("H", &body, vec![names[0].clone(), names[1].clone()])]).unwrap(); + assert_eq!(twice.errors.len(), 1); + } + + /// Merges stack: a merged face merges again with its neighbour, and the surfaces differ then. + #[test] + fn a_merged_face_merges_again() { + let d = plate(); + let body = d.bodies[0].name.clone(); + let hole = halves(&d); + let d = d.merged(&[Merge { name: "Hole".into(), body: body.clone(), faces: hole }]).unwrap(); + let b = &d.bodies[0]; + let f = b.face_index("merge:Hole").unwrap(); + let mesh = b.tessellate(0.01).unwrap(); + let next = mesh.edge_faces.iter().find(|fs| fs.contains(&f)).and_then(|fs| fs.iter().find(|&&g| g != f)).copied().unwrap(); + let next = b.faces()[next as usize].name.clone(); + let d = d.merged(&[Merge { name: "More".into(), body, faces: vec!["merge:Hole".into(), next] }]).unwrap(); + assert!(d.errors.is_empty(), "{:?}", d.errors); + let b = &d.bodies[0]; + let f = b.face_index("merge:More").unwrap(); + assert_eq!(b.faces().len(), 6); + assert_eq!(b.merged_faces(), vec![(f, 3)]); + assert_eq!(b.face_surfaces().unwrap()[f as usize], None); + } +}