mesh: projection and relaxation keep to a merged face's B-rep pieces

A merged face on several surfaces (a sliver absorbed into its neighbour)
had no exact surface, so the projection target fell back to its
triangles: flat curvature probes gave fewer divisions round the boss and
the enclosure base's symmetric mesh dropped from 0.70 to 0.51 once its
absorbs reached the cells. The target now projects a point of such a
face onto the exact surface of the B-rep piece it is on, trimmed to that
piece (Tessellation::with_pieces, Shape::tessellate_pieces); node
relaxation slides on it the same way.

Test from commands: the enclosure base's four slivers absorbed, meshed
whole and by symmetry, the absorbs kept in every cell.

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 15:25:37 -04:00
1 parent 09385f069f
commit a2e747b9ba
7 files changed
+186 -61

No files matched your search

+14 -2
View File
@@ -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<fieldcraft_mesh::seeds::EdgeSeed>,
/// 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<u32>,
}
fn analytic(s: FaceSurface) -> Surface {
@@ -73,7 +76,8 @@ pub(super) fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input
}
let surfaces = shape.face_surfaces()?.into_iter().map(|f| f.map(analytic)).collect();
let face_names = shape.faces().iter().map(|f| f.name.clone()).collect();
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag, seeds: Vec::new() })
let (mesh, pieces) = shape.tessellate_pieces(tol)?;
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(mesh), surfaces, face_names, diagonal: diag, seeds: Vec::new(), pieces })
}
/// The imported surface `surface` names, with the body name its mesh is tagged with.
@@ -109,7 +113,15 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
}
// 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()))?;
+23 -14
View File
@@ -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<Option<AnalyticSurface>> {
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 {
+36
View File
@@ -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::<Vec<_>>());
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
+1 -1
View File
@@ -286,7 +286,7 @@ pub(crate) fn open_body(s: &Session, name: &str, tolerance: Option<f64>, 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 })
}
+2 -2
View File
@@ -194,7 +194,7 @@ fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry>
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<u32>) -> Result<DesignGeometry>
&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))
}
};