diff --git a/crates/engine/src/cmd/hex.rs b/crates/engine/src/cmd/hex.rs index 3f2695e..ae3e9b1 100644 --- a/crates/engine/src/cmd/hex.rs +++ b/crates/engine/src/cmd/hex.rs @@ -42,6 +42,9 @@ pub(super) struct Input<'a> { /// The design's edge seeds ([`super::seeds::edge_seeds`]); the mesher applies those on its /// edges. pub(super) seeds: Vec, + /// Per triangle of `mesh`, the B-rep face it lies on: its piece of a merged face (empty for + /// a surface that is not a body's). + pub(super) pieces: Vec, } fn analytic(s: FaceSurface) -> Surface { @@ -73,7 +76,8 @@ pub(super) fn shape_input(shape: &Shape, tolerance: Option) -> Result(s: &'a Session, p: &Value) -> Result>> { } // Faces named by index, as mesh.tet.generate names them. let face_names = (0..t.faces).map(|f| f.to_string()).collect(); - Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi), seeds: Vec::new() }]) + Ok(vec![Input { + name, + mesh: Cow::Borrowed(&t.mesh), + surfaces: Vec::new(), + face_names, + diagonal: diagonal(lo, hi), + seeds: Vec::new(), + pieces: Vec::new(), + }]) } (None, None) => { let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first, or give `surface`".into()))?; diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index 091b8e9..5b29fa0 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -216,21 +216,30 @@ pub(super) fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOption /// The exact surfaces of a body's faces, for the quality stage's projections (none when the /// kernel cannot give them: the stage then keeps to the tessellation). pub fn analytic_surfaces(shape: &fieldcraft_geometry::Shape) -> Vec> { + shape.face_surfaces().unwrap_or_default().into_iter().map(|f| f.map(analytic_surface)).collect() +} + +fn analytic_surface(f: fieldcraft_geometry::FaceSurface) -> AnalyticSurface { use fieldcraft_geometry::FaceSurface as F; - shape - .face_surfaces() - .unwrap_or_default() - .into_iter() - .map(|f| { - f.map(|f| match f { - F::Plane { normal, point } => AnalyticSurface::Plane { normal, point }, - F::Cylinder { origin, axis, radius } => AnalyticSurface::Cylinder { origin, axis, radius }, - F::Cone { apex, axis, half_angle } => AnalyticSurface::Cone { apex, axis, half_angle }, - F::Sphere { center, radius } => AnalyticSurface::Sphere { center, radius }, - F::Torus { center, axis, major, minor } => AnalyticSurface::Torus { center, axis, major, minor }, - }) - }) - .collect() + match f { + F::Plane { normal, point } => AnalyticSurface::Plane { normal, point }, + F::Cylinder { origin, axis, radius } => AnalyticSurface::Cylinder { origin, axis, radius }, + F::Cone { apex, axis, half_angle } => AnalyticSurface::Cone { apex, axis, half_angle }, + F::Sphere { center, radius } => AnalyticSurface::Sphere { center, radius }, + F::Torus { center, axis, major, minor } => AnalyticSurface::Torus { center, axis, major, minor }, + } +} + +/// The B-rep pieces of `shape`'s merged faces (virtual topology) for a projection target built +/// on its tessellation whose triangles lie on the B-rep faces `tri_piece` +/// ([`fieldcraft_geometry::Shape::tessellate_pieces`]): a merged face on no one surface then +/// projects onto each piece's own; none when no face is merged. +pub(crate) fn pieces(shape: &fieldcraft_geometry::Shape, tri_piece: &[u32]) -> fieldcraft_mesh::tet::geometry::Pieces { + if !shape.has_merged_faces() { + return fieldcraft_mesh::tet::geometry::Pieces::default(); + } + let analytic = shape.brep_face_surfaces().unwrap_or_default().into_iter().map(|f| f.map(analytic_surface)).collect(); + fieldcraft_mesh::tet::geometry::Pieces { tri_piece: tri_piece.to_vec(), analytic } } pub(super) fn quality_json(q: &TetQuality) -> Value { diff --git a/crates/engine/src/cmd/plan.rs b/crates/engine/src/cmd/plan.rs index 56ff615..a7eb6f4 100644 --- a/crates/engine/src/cmd/plan.rs +++ b/crates/engine/src/cmd/plan.rs @@ -2405,6 +2405,42 @@ mod acceptance { accept(&mut s, &r, 0.5, 10.0); } + /// The enclosure base's bosses cut a 0.42 mm strip out of the middle of the inner fillet + /// (#511, #513): geometry.check finds the slivers and geometry.face.absorb absorbs them. + /// The absorbed faces stay absorbed in the cells the plan's symmetry cuts the body into, and + /// the projection keeps to the exact surfaces of a merged face's pieces, so the mesh is no + /// worse for them, whole or by symmetry. + #[test] + fn absorbed_slivers_stay_absorbed_in_the_plan_s_cells() { + let mut commands = vec![ + json!({"command": "solid.box", "params": {"length": 56, "width": 42, "height": 16, "body_name": "Base"}}), + json!({"command": "solid.fillet", "params": {"edges": [[0, 0, 8], [56, 0, 8], [56, 42, 8], [0, 42, 8]], "radius": 5}}), + json!({"command": "solid.shell", "params": {"faces": [[28, 21, 16]], "thickness": 3, "body": "Base"}}), + ]; + for (x, y) in [(-1.0, -1.0), (57.0, -1.0), (-1.0, 43.0), (57.0, 43.0)] { + commands.push(json!({"command": "solid.cylinder", "params": {"diameter": 13, "height": 16, "base": [x, y, 0], "operation": "join", "targets": ["Base"]}})); + } + let (_d, mut s) = open(json!(commands)); + let check = s.execute("geometry.check", &json!({"body": "Base", "size": 1.5, "mark": true})).unwrap(); + assert_eq!(check["counts"]["sliver_face"], 4, "{check}"); + let absorbed = s.execute("geometry.face.absorb", &json!({"from_check": true})).unwrap(); + assert_eq!(absorbed["absorbed"].as_array().map(Vec::len), Some(4), "{absorbed}"); + let plan = s.execute("mesh.plan", &json!({"body": "Base", "size": 1.5, "layers": 2})).unwrap(); + assert_eq!(plan["mesher"], "project", "{plan}"); + let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"], "symmetry": false})).unwrap(); + accept(&mut s, &r, 0.6, 10.0); + let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap(); + assert_eq!(r["symmetry"].as_array().map(Vec::len), Some(2), "{r}"); + let design = s.design.as_deref().unwrap(); + assert!(design.errors.is_empty(), "{:?}", design.errors); + // Each quarter holds one corner, its sliver absorbed. + let cells: Vec<_> = design.bodies.iter().filter(|b| b.name.starts_with("Base/")).collect(); + assert_eq!(cells.len(), 4); + assert!(cells.iter().all(|c| c.merged_faces().len() == 1), "{:?}", cells.iter().map(|c| c.merged_faces()).collect::>()); + assert_eq!(design.source_body("Base").map(|b| b.merged_faces().len()), Some(4)); + accept(&mut s, &r, 0.65, 10.0); + } + /// The crank arm sample, whole (#455, #190): its sides are at an angle to the axes and its /// root arcs meet the bosses at a kink, so it takes the ring-and-pillow layout: each boss an /// annulus from its bore or pedal hole to its circle through every level, the arm a carved diff --git a/crates/engine/src/cmd/project.rs b/crates/engine/src/cmd/project.rs index 082da40..9440edc 100644 --- a/crates/engine/src/cmd/project.rs +++ b/crates/engine/src/cmd/project.rs @@ -286,7 +286,7 @@ pub(crate) fn open_body(s: &Session, name: &str, tolerance: Option, raw: &V let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &super::mesh::surface_options(raw, &input.mesh)?) .map_err(|e| EngineError::Params(format!("body `{name}`: {e}")))?; - let target = Tessellation::new(&surface, super::mesh::analytic_surfaces(shape)); + let target = Tessellation::new(&surface, super::mesh::analytic_surfaces(shape)).with_pieces(&surface, super::mesh::pieces(shape, &input.pieces)); let m = shape.measure()?; Ok(Body { input, surface, target, lo: m.bbox_min, hi: m.bbox_max }) } diff --git a/crates/engine/src/cmd/relax.rs b/crates/engine/src/cmd/relax.rs index 314ccd1..06dac66 100644 --- a/crates/engine/src/cmd/relax.rs +++ b/crates/engine/src/cmd/relax.rs @@ -194,7 +194,7 @@ fn design_geometry(s: &Session, nodes: &BTreeSet) -> Result None => None, Some(shape) => { let tol = (shape.size()? * 1e-3).max(1e-6); - let mesh = shape.tessellate(tol)?; + let (mesh, pieces) = shape.tessellate_pieces(tol)?; let surface = BoundarySurface::new( &mesh.positions, &mesh.triangles, @@ -202,7 +202,7 @@ fn design_geometry(s: &Session, nodes: &BTreeSet) -> Result &SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() }, )?; let analytic = super::mesh::analytic_surfaces(shape); - g.bodies.push(Tessellation::new(&surface, analytic.clone())); + g.bodies.push(Tessellation::new(&surface, analytic.clone()).with_pieces(&surface, super::mesh::pieces(shape, &pieces))); Some((g.bodies.len() - 1, analytic)) } }; diff --git a/crates/geometry/src/lib.rs b/crates/geometry/src/lib.rs index 1523a4a..43964c3 100644 --- a/crates/geometry/src/lib.rs +++ b/crates/geometry/src/lib.rs @@ -206,8 +206,8 @@ impl Shape { self.virtual_surfaces(brep) } - /// [`Shape::face_surfaces`] by B-rep face index. - pub(crate) fn brep_face_surfaces(&self) -> Result>> { + /// [`Shape::face_surfaces`] by B-rep face index (the pieces of [`Shape::tessellate_pieces`]). + pub fn brep_face_surfaces(&self) -> Result>> { guard("face surfaces", || { Ok((0..self.body.face_count()) .map(|f| { @@ -257,18 +257,37 @@ impl Shape { /// 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 { + Ok(self.virtual_mesh(self.tessellate_brep(tolerance)?)) + } + + /// [`Shape::tessellate`], and per triangle the B-rep face it lies on: its piece of a merged + /// face, whose surface is in [`Shape::brep_face_surfaces`] (the same as its tag when no face + /// is merged). + pub fn tessellate_pieces(&self, tolerance: f64) -> Result<(SurfaceMesh, Vec)> { + let brep = self.tessellate_brep(tolerance)?; + let pieces = brep.tri_face.clone(); + Ok((self.virtual_mesh(brep), pieces)) + } + + /// The B-rep's own tessellation, tagged by B-rep face. + fn tessellate_brep(&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(self.virtual_mesh(SurfaceMesh { + Ok(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, - })) + }) + } + + /// Whether some face is made of several B-rep faces ([`virtual_faces`]). + pub fn has_merged_faces(&self) -> bool { + !self.face_map.is_empty() } } diff --git a/crates/mesh/src/tet/geometry.rs b/crates/mesh/src/tet/geometry.rs index c12e86e..122a2bb 100644 --- a/crates/mesh/src/tet/geometry.rs +++ b/crates/mesh/src/tet/geometry.rs @@ -429,6 +429,20 @@ pub struct Tessellation { /// A tree over each face pair's segments. pair_trees: std::collections::BTreeMap<[u32; 2], Tree>, analytic: Vec>, + /// Faces made of several B-rep faces (virtual topology): each triangle's B-rep face (its + /// piece) and each piece's exact surface, for a face with no one surface of its own. Empty + /// when no face is merged. + tri_piece: Vec, + piece_analytic: Vec>, +} + +/// The B-rep faces of merged faces: per triangle of the surface a [`Tessellation`] is built on +/// (as given to [`BoundarySurface::new`]) the B-rep face it lies on, and each B-rep face's exact +/// surface when known. +#[derive(Debug, Clone, Default)] +pub struct Pieces { + pub tri_piece: Vec, + pub analytic: Vec>, } impl Tessellation { @@ -480,7 +494,31 @@ impl Tessellation { by_pair.entry(*pair).or_default().push(i as u32); } let pair_trees = by_pair.into_iter().map(|(pair, items)| (pair, Tree::build_over(&seg_boxes, items))).collect(); - Tessellation { points, triangles, tri_face, tri_tree, face_trees, segments, seg_tree, pair_trees, analytic } + Tessellation { + points, + triangles, + tri_face, + tri_tree, + face_trees, + segments, + seg_tree, + pair_trees, + analytic, + tri_piece: Vec::new(), + piece_analytic: Vec::new(), + } + } + + /// The same target with the B-rep pieces of its merged faces (`surface` the boundary + /// surface it was built on): a point on a merged face that lies on no one surface goes onto + /// the exact surface of the piece it is on, trimmed to that piece, instead of its triangles. + pub fn with_pieces(mut self, surface: &BoundarySurface, pieces: Pieces) -> Tessellation { + if pieces.tri_piece.is_empty() { + return self; + } + self.tri_piece = surface.source_triangle().iter().map(|&t| pieces.tri_piece.get(t as usize).copied().unwrap_or(u32::MAX)).collect(); + self.piece_analytic = pieces.analytic; + self } fn pt(&self, v: u32) -> P { @@ -492,19 +530,42 @@ impl Tessellation { self.analytic.get(face as usize).and_then(|a| a.as_ref()) } - /// The closest point on the face's triangles, and the longest edge of the triangle it is - /// on (the local scale of the tessellation). - fn on_tessellation(&self, face: u32, p: P) -> Option<(P, f64)> { + /// The exact surface of a point of face `face` on its triangle `t`: the face's own, else + /// that of the B-rep piece of a merged face the triangle lies on. + fn exact(&self, face: u32, t: u32) -> Option<&AnalyticSurface> { + self.analytic(face).or_else(|| { + let piece = *self.tri_piece.get(t as usize)?; + self.piece_analytic.get(piece as usize).and_then(|a| a.as_ref()) + }) + } + + /// Whether triangle `u` lies on the same piece of face `face` as triangle `t` (always, for + /// a face with a surface of its own or without pieces). + fn same_piece(&self, face: u32, t: u32, u: u32) -> bool { + self.analytic(face).is_some() || self.tri_piece.get(t as usize) == self.tri_piece.get(u as usize) + } + + /// The nearest triangle of face `face` to `p` and the closest point on it. + fn nearest_triangle(&self, face: u32, p: P) -> Option<(P, u32)> { let accept = |t: u32| self.tri_face.get(t as usize) == Some(&face); let dist = |t: u32| { let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]); - let (a, b, c) = (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2])); - let q = closest_on_triangle(p, a, b, c); + let q = closest_on_triangle(p, self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2])); let d = sub(q, p); - let longest = measure::dist(a, b).max(measure::dist(b, c)).max(measure::dist(c, a)); - (dot(d, d), [q[0], q[1], q[2], longest]) + // The triangle index travels through the f64 exactly (counts are capped far below + // 2^53). + (dot(d, d), [q[0], q[1], q[2], f64::from(t)]) }; - self.face_tree(face).nearest4(p, &accept, &dist).map(|r| ([r[0], r[1], r[2]], r[3])) + self.face_tree(face).nearest4(p, &accept, &dist).map(|r| ([r[0], r[1], r[2]], r[3] as u32)) + } + + /// The closest point on the face's triangles, the longest edge of the triangle it is on + /// (the local scale of the tessellation) and that triangle. + fn on_tessellation(&self, face: u32, p: P) -> Option<(P, f64, u32)> { + let (q, t) = self.nearest_triangle(face, p)?; + let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]); + let (a, b, c) = (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2])); + Some((q, measure::dist(a, b).max(measure::dist(b, c)).max(measure::dist(c, a)), t)) } /// The B-rep face whose triangles lie nearest to `p`. @@ -552,24 +613,17 @@ impl Tessellation { /// closest point of the face's triangles. Unlike [`BoundaryGeometry::project_face`] it never /// returns a point of the surface's extension beyond the face's boundary, whatever the /// distance from `p` — nor of the same surface's other faces: the pieces of one fillet split - /// by a cut share their cylinder, and a point on one piece is off the others. + /// by a cut share their cylinder, and a point on one piece is off the others. On a merged + /// face lying on no one surface, the surface is that of the piece its nearest triangle is + /// on, trimmed to that piece. pub fn project_trimmed(&self, face: u32, p: P) -> Option

{ - let accept = |t: u32| self.tri_face.get(t as usize) == Some(&face); let corners = |t: u32| { let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]); (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2])) }; - let near = { - let dist = |t: u32| { - let (a, b, c) = corners(t); - let q = closest_on_triangle(p, a, b, c); - let d = sub(q, p); - (dot(d, d), [q[0], q[1], q[2], 0.0]) - }; - let r = self.face_tree(face).nearest4(p, &accept, &dist)?; - [r[0], r[1], r[2]] - }; - let Some(surface) = self.analytic(face) else { return Some(near) }; + let (near, nearest) = self.nearest_triangle(face, p)?; + let accept = |t: u32| self.tri_face.get(t as usize) == Some(&face) && self.same_piece(face, nearest, t); + let Some(surface) = self.exact(face, nearest) else { return Some(near) }; let Some(q) = surface.project(p) else { return Some(near) }; // The nearest triangle to the surface's point, with its slack: the largest gap between // the triangle and the surface (at its edges' midpoints and centre: a chord's deepest @@ -589,19 +643,14 @@ impl Tessellation { } /// The outward unit normal of B-rep face `face` near `p`: the exact surface's (oriented like - /// the nearest triangle) when known, else the nearest triangle's. + /// the nearest triangle; on a merged face, its piece's) when known, else the nearest + /// triangle's. pub fn face_normal(&self, face: u32, p: P) -> Option

{ - let accept = |t: u32| self.tri_face.get(t as usize) == Some(&face); - let dist = |t: u32| { - let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]); - let (a, b, c) = (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2])); - let d = sub(closest_on_triangle(p, a, b, c), p); - let n = unit(cross(sub(b, a), sub(c, a))).unwrap_or([0.0; 3]); - (dot(d, d), [n[0], n[1], n[2], 0.0]) - }; - let r = self.face_tree(face).nearest4(p, &accept, &dist)?; - let flat = unit([r[0], r[1], r[2]])?; - match self.analytic(face).and_then(|s| s.normal_at(p)) { + let (_, t) = self.nearest_triangle(face, p)?; + let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]); + let (a, b, c) = (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2])); + let flat = unit(cross(sub(b, a), sub(c, a)))?; + match self.exact(face, t).and_then(|s| s.normal_at(p)) { Some(n) if dot(n, flat) < 0.0 => Some(scale(n, -1.0)), Some(n) => Some(n), None => Some(flat), @@ -611,8 +660,8 @@ impl Tessellation { impl BoundaryGeometry for Tessellation { fn project_face(&self, face: u32, p: P) -> Option

{ - let (near, scale_len) = self.on_tessellation(face, p)?; - match self.analytic(face) { + let (near, scale_len, t) = self.on_tessellation(face, p)?; + match self.exact(face, t) { // The exact surface's closest point, kept when it is near the tessellation's: the // tessellation deviates from its surface by far less than half its triangles' size, // so a point further away is a projection onto another part of the surface (the far @@ -664,8 +713,8 @@ impl Tessellation { /// tessellation's chordal tolerance, as [`BoundaryGeometry::project_face`]). pub fn slide_face(&self, face: u32, p: P, d: P) -> Option

{ let target = add(p, d); - let (near, scale_len) = self.on_tessellation(face, target)?; - if let Some(q) = self.analytic(face).and_then(|s| s.slide(p, d)) { + let (near, scale_len, t) = self.on_tessellation(face, target)?; + if let Some(q) = self.exact(face, t).and_then(|s| s.slide(p, d)) { let tol = (0.5 * scale_len).max(2.0 * measure::dist(target, near)); if measure::dist(q, near) <= tol { return Some(q);