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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 11:09:11 -04:00
1 parent f38a3be917
commit b126c2293b
5 files changed
+463 -20

No files matched your search

+12 -4
View File
@@ -27,6 +27,9 @@ pub struct Design {
pub cuts: Vec<crate::partition::CutReport>,
/// The bodies as opened, before any partition: what [`Design::partitioned`] starts from.
pub(crate) source: std::sync::Arc<Vec<Shape>>,
/// 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<Vec<Shape>>,
/// The timeline the design was rebuilt from ([`Design::features`]).
pub(crate) history: Option<std::sync::Arc<crate::features::History>>,
}
@@ -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<Design> {
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<Design> {
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(),
+5 -4
View File
@@ -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<BodyFeatures> {
// 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<u64, Vec<String>> = BTreeMap::new();
for f in shape.faces() {
let ids: BTreeSet<u64> = feature_ids(&f.name).collect();
let ids: BTreeSet<u64> =
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());
}
+23 -7
View File
@@ -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<Face>,
/// The virtual face (index into `faces`) of each B-rep face; empty: they are the same.
face_map: Vec<u32>,
}
/// 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<String>,
}
/// 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<Shape> {
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<Vec<Option<FaceSurface>>> {
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<Vec<Option<FaceSurface>>> {
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<SurfaceMesh> {
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,
})
}))
}
}
+6 -5
View File
@@ -101,7 +101,7 @@ impl Design {
}
let planes: Vec<(Plane, [f64; 3])> = cuts.iter().map(plane_of).collect::<Result<_>>()?;
let mut bodies: Vec<Shape> = self.source.as_ref().clone();
let mut errors: Vec<FeatureError> = self.errors.iter().filter(|e| !e.feature.starts_with("partition ")).cloned().collect();
let mut errors: Vec<FeatureError> = 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<Split> {
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<FaceSurface>, b: Option<FaceSurface>, tol: f64) -> bool {
pub(crate) fn same_surface(a: Option<FaceSurface>, b: Option<FaceSurface>, 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,
+417
View File
@@ -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 <name>`), as a feature that failed to rebuild; the others still apply.
//!
//! The merged face is named `merge:<name>`. A face name in a merge selects the face of that name,
//! or the pieces a partition cut it into (`<name>#0`, `<name>#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:<name>`).
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<String>,
}
/// 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 <name>`) 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<Design> {
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
/// (`<name>#k`).
pub fn faces_named(&self, name: &str) -> Vec<u32> {
let exact: Vec<u32> =
(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<Shape> {
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<Face> = Vec::with_capacity(self.faces.len());
let mut renumber = Vec::with_capacity(self.faces.len());
let mut merged: Option<u32> = 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<u32>) -> Result<()> {
let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
let mut reached = BTreeSet::new();
let mut todo: Vec<u32> = 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<Vec<Option<FaceSurface>>> {
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<Option<FaceSurface>>) -> Result<Vec<Option<FaceSurface>>> {
if self.face_map.is_empty() {
return Ok(brep);
}
let tol = (self.size()? * 1e-6).max(1e-9);
let mut out: Vec<Option<Option<FaceSurface>>> = 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<u32> = 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<String> {
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::<f64>().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<Vec<[f64; 3]>>, faces: &mut Vec<Vec<u32>>, tol: f64) {
let key = |f: &Vec<u32>| {
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<String> {
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<u32> = 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::<Vec<_>>());
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<String> = 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<String>| 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);
}
}