Boundary surface preparation (weld, manifold/orientation, exact self-intersection, feature edges) and mesh.surface.check (#15)

* mesh: boundary surface preparation (weld, manifold and orientation checks, exact self-intersection, feature edges)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: mesh.surface.check on an open design's body or an imported surface

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: struct-init the test session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* surface: weld at an absolute distance too; the engine welds CAD tessellations at twice SolidCraft's linear tolerance

Faces of a SolidCraft tessellation carry their own copies of shared boundary points, which agree
only to within the kernel's tolerance (up to ~2e-6 mm on the test corpus).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: surface preparation in the literature survey, architecture and roadmap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* surface: simplify after review

- The weld distance travels with the mesh (SurfaceMesh::weld_distance, set by Shape::tessellate),
  so every consumer welds a tessellation correctly without opting in.
- Exact tests are infallible on predicates::orient2d_projected (shared with generic_orient2d), and
  compute each vertex's plane side once per pair with an early reject.
- One Box3 for the weld, the BVH and shells; a Half struct for edges; the weld's two lookups share
  one loop, with chained grid cells; cached normals for feature edges; shared parameter helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* corpus: boundary surface preparation as a corpus stage

20 of 21 parts are valid boundaries with their expected genus; cross-cylinders' tessellation has
cracks and flipped triangles where its two intersection curves meet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 16:01:42 -04:00
1 parent 94dc50bb0c
commit 02cc9dab57
24 files changed
+1987 -60

No files matched your search

+8 -3
View File
@@ -1,7 +1,7 @@
use std::sync::Arc;
use modelcraft_geometry::tagged_mesh;
use modelcraft_geometry::{Shape, open_design_file};
use modelcraft_geometry::{Shape, SurfaceMesh, open_design_file};
use serde_json::{Value, json};
use crate::{CommandSpec, EngineError, Result, Session};
@@ -72,10 +72,15 @@ fn body<'a>(s: &'a Session, p: &Value) -> Result<&'a Shape> {
s.design.as_deref().and_then(|d| d.body(name)).ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))
}
fn tessellate(s: &mut Session, p: &Value) -> Result<Value> {
/// The body named by `body`, tessellated within `tolerance`.
pub(crate) fn body_mesh<'a>(s: &'a Session, p: &Value) -> Result<(&'a Shape, SurfaceMesh)> {
let shape = body(s, p)?;
let tolerance = p.get("tolerance").and_then(Value::as_f64).ok_or_else(|| EngineError::Params("`tolerance` must be a number (mm)".into()))?;
let mesh = shape.tessellate(tolerance)?;
Ok((shape, shape.tessellate(tolerance)?))
}
fn tessellate(s: &mut Session, p: &Value) -> Result<Value> {
let (shape, mesh) = body_mesh(s, p)?;
let mut per_face = vec![0usize; shape.faces().len()];
for &f in &mesh.tri_face {
if let Some(n) = per_face.get_mut(f as usize) {
+146 -1
View File
@@ -1,9 +1,13 @@
use std::borrow::Cow;
use modelcraft_geometry::SurfaceMesh;
use modelcraft_mesh::delaunay::Triangulation;
use modelcraft_mesh::quality::{self, QualityReport};
use modelcraft_mesh::surface::{BoundarySurface, Defect, FeatureKind, SurfaceOptions};
use modelcraft_model::{ElementKind, MAX_ELEMENTS};
use serde_json::{Value, json};
use super::{label, labels, points, xyz};
use super::{label, labels, optional_number, points, xyz};
use crate::{CommandSpec, EngineError, Result, Session};
pub const NODE_ADD: CommandSpec = CommandSpec {
@@ -57,6 +61,13 @@ fn delaunay_points(_: &mut Session, p: &Value) -> Result<Value> {
}))
}
pub const SURFACE_CHECK: CommandSpec = CommandSpec {
id: "mesh.surface.check",
label: "Check Boundary Surface",
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, optional "weld": 1e-9 (0 or 1e-15..=1e-3 of the bounding-box diagonal), "weld_distance": mm (default: the surface's own, twice SolidCraft's linear tolerance for a tessellated body, 0 for an imported mesh), "feature_angle": 45 (degrees, (0, 180]) — welds the tessellated body or imported surface and checks it can bound a volume mesh: closed, manifold, outward, no self-intersections; reports shells, genus, volume and feature edges, or the located defects"#,
run: surface_check,
};
fn node_add(s: &mut Session, p: &Value) -> Result<Value> {
let l = label(p, "label")?;
s.model.set_node(l, xyz(p, "xyz")?)?;
@@ -136,6 +147,63 @@ fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
/// The surface named by `body` (tessellated at `tolerance`) or `surface` (an imported index).
fn surface_input<'a>(s: &'a Session, p: &Value) -> Result<Cow<'a, SurfaceMesh>> {
match (p.get("body"), p.get("surface")) {
(Some(_), None) => Ok(Cow::Owned(super::geometry::body_mesh(s, p)?.1)),
(None, Some(i)) => {
let n = s.surfaces.len();
i.as_u64()
.and_then(|i| usize::try_from(i).ok())
.and_then(|i| s.surfaces.get(i))
.map(|t| Cow::Borrowed(&t.mesh))
.ok_or_else(|| EngineError::Params(format!("`surface` must be the index of an imported surface (there are {n})")))
}
_ => Err(EngineError::Params("give either `body` (with `tolerance`) or `surface`".into())),
}
}
fn defect_json(d: &Defect) -> Value {
json!({"kind": d.kind(), "message": d.to_string(), "at": d.at()})
}
fn surface_check(s: &mut Session, p: &Value) -> Result<Value> {
let mesh = surface_input(s, p)?;
let defaults = SurfaceOptions::default();
let options = SurfaceOptions {
weld_tolerance: optional_number(p, "weld", defaults.weld_tolerance)?,
weld_distance: optional_number(p, "weld_distance", mesh.weld_distance)?,
feature_angle: optional_number(p, "feature_angle", defaults.feature_angle)?,
};
let surface = match BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options) {
Ok(surface) => surface,
Err(modelcraft_mesh::Error::Surface(d)) => {
return Ok(json!({"valid": false, "defects": {"count": d.total, "listed": d.listed.iter().map(defect_json).collect::<Vec<_>>()}}));
}
Err(e) => return Err(e.into()),
};
let mut faces: Vec<u32> = surface.tri_face().to_vec();
faces.sort_unstable();
faces.dedup();
let count = |k: FeatureKind| surface.feature_edges().iter().filter(|e| e.kind == k).count();
let shells: Vec<Value> = surface
.shells()
.iter()
.map(|sh| json!({"triangles": sh.triangles, "vertices": sh.vertices, "genus": sh.genus, "volume": sh.volume, "void": sh.void}))
.collect();
Ok(json!({
"valid": true,
"vertices": surface.points().len(),
"triangles": surface.triangles().len(),
"input": {"vertices": mesh.positions.len(), "triangles": mesh.triangles.len()},
"faces": faces.len(),
"volume": surface.volume(),
"shells": shells,
"feature_edges": {"face_boundary": count(FeatureKind::FaceBoundary), "sharp": count(FeatureKind::Sharp)},
"feature_corners": surface.feature_corners().len(),
}))
}
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -202,4 +270,81 @@ mod tests {
assert!(s.execute("mesh.quality", &json!({"bins": 1e9})).is_err());
assert!(s.execute("mesh.quality", &json!({"elements": "all"})).is_err());
}
#[test]
fn a_tessellated_block_is_a_valid_boundary() {
use modelcraft_mesh::surface::{BoundarySurface, FeatureKind, SurfaceOptions};
let mesh = modelcraft_geometry::block([0.0; 3], [2.0, 3.0, 4.0]).unwrap().tessellate(0.01).unwrap();
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
let s = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).unwrap();
assert!(s.points().len() < mesh.positions.len(), "the tessellation is a soup that welding joins");
assert_eq!((s.shells().len(), s.shells()[0].genus), (1, 0));
assert!((s.volume() - 24.0).abs() < 1e-9, "{}", s.volume());
assert!(s.feature_edges().iter().all(|e| e.kind == FeatureKind::FaceBoundary));
assert_eq!(s.feature_corners().len(), 8);
let length: f64 = s
.feature_edges()
.iter()
.map(|e| {
let [a, b] = e.vertices.map(|v| s.points()[v as usize]);
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
})
.sum();
assert!((length - 4.0 * (2.0 + 3.0 + 4.0)).abs() < 1e-9, "the twelve edges: {length}");
}
#[test]
fn surface_check_on_an_open_design_and_on_imported_surfaces() {
use std::sync::Arc;
use modelcraft_geometry::sample::{Plate, plate_design};
use modelcraft_geometry::tagged_mesh::TaggedSurface;
use modelcraft_geometry::{SurfaceMesh, open_design};
let plate = Plate::default();
let mut s = Session { design: Some(Arc::new(open_design(&plate_design(&plate).unwrap()).unwrap())), ..Session::default() };
let r = s.execute("mesh.surface.check", &json!({"body": "Body1", "tolerance": 0.05})).unwrap();
assert_eq!(r["valid"], true, "{r}");
assert_eq!(r["faces"], 8);
assert_eq!(r["shells"].as_array().unwrap().len(), 1);
assert_eq!(r["shells"][0]["genus"], 1, "the hole is a handle");
let exact = (plate.width * plate.depth - std::f64::consts::PI * (plate.hole_diameter / 2.0).powi(2)) * plate.thickness;
let volume = r["volume"].as_f64().unwrap();
assert!(volume >= exact && volume < exact * 1.01, "a polygonal hole inscribed in the circle: {volume} vs {exact}");
assert!(r["feature_edges"]["face_boundary"].as_u64().unwrap() > 12);
assert!(r["feature_corners"].as_u64().unwrap() >= 8);
assert!(s.journal.is_empty(), "a check changes nothing");
// A tagged tetrahedron, and the same with a face missing.
let p = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let t = vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]];
let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0 };
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0 };
for mesh in [tet, open] {
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
}
let r = s.execute("mesh.surface.check", &json!({"surface": 0, "feature_angle": 180})).unwrap();
assert_eq!((r["valid"].clone(), r["feature_edges"].clone()), (json!(true), json!({"face_boundary": 3, "sharp": 0})));
let r = s.execute("mesh.surface.check", &json!({"surface": 1})).unwrap();
assert_eq!(r["valid"], false);
assert_eq!(r["defects"]["count"], 3);
assert_eq!(r["defects"]["listed"][0]["kind"], "open_edge");
assert!(r["defects"]["listed"][0]["at"].is_array());
for bad in [
json!({"surface": 2}),
json!({"surface": -1}),
json!({"surface": 0, "body": "Body1"}),
json!({}),
json!({"surface": 0, "weld": 1.0}),
json!({"surface": 0, "weld": "fine"}),
json!({"surface": 0, "weld_distance": -1}),
json!({"surface": 0, "weld_distance": 1}),
json!({"surface": 0, "feature_angle": 0}),
json!({"body": "Body1"}),
json!({"body": "Nope", "tolerance": 0.05}),
] {
assert!(s.execute("mesh.surface.check", &bad).is_err(), "{bad}");
}
}
}
+9
View File
@@ -21,6 +21,7 @@ pub static ALL: &[CommandSpec] = &[
mesh::ELEMENT_REMOVE,
mesh::QUALITY,
mesh::DELAUNAY_POINTS,
mesh::SURFACE_CHECK,
];
/// A required label parameter (1..=u32::MAX; the model checks its own range).
@@ -63,3 +64,11 @@ pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result<Vec<u32>> {
.collect::<Option<Vec<u32>>>()
.ok_or_else(|| EngineError::Params(format!("`{key}` must be labels in 1..={MAX_LABEL}")))
}
/// An optional number parameter.
pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result<f64> {
match p.get(key) {
None => Ok(default),
Some(v) => v.as_f64().ok_or_else(|| EngineError::Params(format!("`{key}` must be a number"))),
}
}