kernel: measure through SMLib's exact mass properties, checked against the mesh

With SOLIDCRAFT_KERNEL=smlib, kernel::measure and measure_mass take volume, area and centroid
from SMLib's Brep::mass_properties (integrated on the exact surfaces, accuracy 1e-8). The mesh
measure is still made, as an independent check. Where SMLib disagrees with it beyond the mesh's
own accuracy (1e-3; tangency-labs/solidcore#384), or can't take the body, the mesh measure
stands and the measurement says why. BodyMeasure and MassMeasure carry `measured_by` (smlib |
mesh) and `notes`, and inspect.measure shows both. The box and topology counts stay the mesh's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-10-21 11:37:49 -04:00
parent fe70fd9287
commit c65fc91a95
4 changed files with 143 additions and 5 deletions
+2
View File
@@ -66,6 +66,8 @@ pub fn measure_json(b: &solidcraft_doc::ModelBody) -> Value {
"vertices": m.merged.vertices,
"face_types": m.merged.face_types,
"kernel": {"faces": m.faces, "edges": m.edges, "vertices": m.vertices, "shells": m.shells},
"measured_by": m.measured_by,
"notes": m.notes,
}),
Err(e) => json!({"name": b.name, "error": e.to_string()}),
}
+1
View File
@@ -92,6 +92,7 @@ mod loft_region_tests;
mod loopblend;
mod matchsurf;
mod measure;
mod measure_backend;
mod mesh_convert;
mod meshbody;
#[cfg(test)]
+26 -5
View File
@@ -20,6 +20,13 @@ pub struct BodyMeasure {
/// Counts after merging split pieces of the same analytic surface/curve (comparable with
/// systems that use periodic faces and edges).
pub merged: TopoCounts,
/// What volume, area and centre come from: `smlib` (exact, integrated on the surfaces, with
/// the kernel setting SMLib) or `mesh` (a fine tessellation).
pub measured_by: &'static str,
/// Why a measurement is not what the kernel setting asks for (SMLib refused the body, or
/// disagreed with the mesh).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
/// Per-face sums: area, 6 × signed volume and 24 × first moments (divergence theorem, origin 0).
@@ -57,12 +64,17 @@ fn min_edge_radius(m: &Mesh) -> Option<f64> {
/// Volume, area, centre and box of a body, as [`measure`] works them out, without the topology
/// counts (and the tessellation they take).
#[derive(Clone, Copy, Debug, Serialize)]
#[derive(Clone, Debug, Serialize)]
pub struct MassMeasure {
pub volume: f64,
pub area: f64,
pub centroid: Vec3,
pub bbox: Aabb3,
/// As [`BodyMeasure::measured_by`].
pub measured_by: &'static str,
/// As [`BodyMeasure::notes`].
#[serde(skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
/// Measure a body from a fine tessellation (chord tolerance 5e-5 of the body size, finer for
@@ -86,9 +98,12 @@ pub fn measure(b: &Body) -> Result<BodyMeasure> {
vertices: pos.len(),
shells: 1,
merged,
measured_by: "mesh",
notes: Vec::new(),
});
}
let (m, topo) = mass(b, true)?;
let (mut m, topo) = mass(b, true)?;
let chosen = crate::measure_backend::choose(b, &mut m);
let topo = match topo {
Some(t) => t,
None => b.tessellate((b.size() * 1e-3).max(1e-3))?,
@@ -103,6 +118,8 @@ pub fn measure(b: &Body) -> Result<BodyMeasure> {
vertices: b.vertex_count(),
shells: b.shell_count(),
merged: merged_topology(b, &topo)?,
measured_by: chosen.by,
notes: chosen.notes,
})
}
@@ -111,9 +128,13 @@ pub fn measure(b: &Body) -> Result<BodyMeasure> {
pub fn measure_mass(b: &Body) -> Result<MassMeasure> {
if b.triangle_mesh().is_some() {
let m = measure(b)?;
return Ok(MassMeasure { volume: m.volume, area: m.area, centroid: m.centroid, bbox: m.bbox });
return Ok(MassMeasure { volume: m.volume, area: m.area, centroid: m.centroid, bbox: m.bbox, measured_by: m.measured_by, notes: m.notes });
}
Ok(mass(b, false)?.0)
let mut m = mass(b, false)?.0;
let chosen = crate::measure_backend::choose(b, &mut m);
m.measured_by = chosen.by;
m.notes = chosen.notes;
Ok(m)
}
/// The mass properties of a B-rep body from three tessellations: a coarse one (which sets how
@@ -170,7 +191,7 @@ fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
}
let volume = v6 / 6.0;
let centroid = if v6.abs() > 1e-12 { mom / (4.0 * v6) } else { fine.bounds().center() };
Ok((MassMeasure { volume, area, centroid, bbox: coarse.bounds().union(&fine.bounds()) }, topo))
Ok((MassMeasure { volume, area, centroid, bbox: coarse.bounds().union(&fine.bounds()), measured_by: "mesh", notes: Vec::new() }, topo))
}
#[cfg(test)]
+114
View File
@@ -0,0 +1,114 @@
//! Mass properties through SMLib, when [`crate::backend`] is SMLib: the body goes to SMLib as its
//! schema 4 record and SMLib integrates its volume, area and centroid on the exact surfaces
//! (`solidcraft_smlib::Brep::mass_properties`). The mesh measure is always made too, as an
//! independent check: where SMLib disagrees with it beyond the mesh's own accuracy
//! (tangency-labs/solidcore#384), or can't take the body, the mesh measure stands and the
//! measurement says why in its notes.
use solidcraft_geom::Vec3;
use crate::body::Body;
use crate::measure::MassMeasure;
/// The mesh measure's own accuracy (relative, for volume and area; of the body's size, for the
/// centroid): SMLib must agree with it this closely for its numbers to be used.
const AGREE: f64 = 1e-3;
/// Exact volume, area and centroid.
pub(crate) struct Exact {
pub volume: f64,
pub area: f64,
pub centroid: Vec3,
}
/// What the measurement comes from, and what it says about it.
pub(crate) struct Choice {
pub by: &'static str,
pub notes: Vec<String>,
}
/// SMLib's mass properties of `b`, or why there are none.
#[cfg(feature = "smlib")]
fn exact(b: &Body) -> Result<Exact, String> {
let sb = crate::native::smlib_body(b).map_err(|e| e.to_string())?;
let m = sb.mass_properties(solidcraft_smlib::MEASURE_ACCURACY).map_err(|e| format!("SMLib mass properties: {e}"))?;
let [x, y, z] = m.centroid;
Ok(Exact { volume: m.volume, area: m.area, centroid: Vec3::new(x, y, z) })
}
#[cfg(not(feature = "smlib"))]
fn exact(_: &Body) -> Result<Exact, String> {
Err("this build has no SMLib".into())
}
/// Where the kernel setting is SMLib, replaces `mesh`'s volume, area and centroid with SMLib's
/// exact ones when they agree with it; otherwise keeps the mesh measure and notes why. The box
/// stays the mesh's.
pub(crate) fn choose(b: &Body, mesh: &mut MassMeasure) -> Choice {
if !matches!(crate::backend(), Ok(crate::Backend::Smlib)) {
return Choice { by: "mesh", notes: Vec::new() };
}
let e = match crate::guard("SMLib mass properties", || Ok(exact(b))) {
Ok(Ok(e)) => e,
Ok(Err(why)) | Err(crate::KernelError::Internal(why)) => {
return Choice { by: "mesh", notes: vec![format!("measured from the mesh: SMLib could not measure this body ({why})")] };
}
Err(e) => return Choice { by: "mesh", notes: vec![format!("measured from the mesh: SMLib could not measure this body ({e})")] },
};
match disagreement(&e, mesh, b.size()) {
None => {
mesh.volume = e.volume;
mesh.area = e.area;
mesh.centroid = e.centroid;
Choice { by: "smlib", notes: Vec::new() }
}
Some(what) => Choice {
by: "mesh",
notes: vec![format!("measured from the mesh: SMLib's exact mass properties disagree with it ({what}; tangency-labs/solidcore#384)")],
},
}
}
/// Where SMLib's numbers and the mesh's differ by more than the mesh's accuracy, which and by how
/// much.
fn disagreement(e: &Exact, m: &MassMeasure, size: f64) -> Option<String> {
let rel = |a: f64, b: f64| (a - b).abs() / a.abs().max(b.abs()).max(1e-12);
let finite = e.volume.is_finite() && e.area.is_finite() && [e.centroid.x, e.centroid.y, e.centroid.z].iter().all(|x| x.is_finite());
if !finite {
return Some("SMLib's numbers are not finite".into());
}
let mut out = Vec::new();
if rel(e.volume, m.volume) > AGREE {
out.push(format!("volume {:.6} against {:.6}", e.volume, m.volume));
}
if rel(e.area, m.area) > AGREE {
out.push(format!("area {:.6} against {:.6}", e.area, m.area));
}
let d = e.centroid.dist(m.centroid);
if d > AGREE * size.max(1e-9) {
out.push(format!("centroid {d:.6} mm apart"));
}
(!out.is_empty()).then(|| out.join(", "))
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_geom::Aabb3;
fn mesh(volume: f64, area: f64, centroid: Vec3) -> MassMeasure {
MassMeasure { volume, area, centroid, bbox: Aabb3::EMPTY, measured_by: "mesh", notes: Vec::new() }
}
#[test]
fn agreement_is_within_the_mesh_accuracy() {
let e = Exact { volume: 1000.0, area: 600.0, centroid: Vec3::new(5.0, 5.0, 5.0) };
assert!(disagreement(&e, &mesh(1000.4, 600.2, Vec3::new(5.0, 5.0, 5.001)), 17.3).is_none());
let off = disagreement(&e, &mesh(1015.0, 600.0, Vec3::new(5.0, 5.0, 5.0)), 17.3).unwrap();
assert!(off.contains("volume 1000.000000 against 1015.000000"), "{off}");
let moved = disagreement(&e, &mesh(1000.0, 600.0, Vec3::new(5.0, 5.0, 6.0)), 17.3).unwrap();
assert!(moved.contains("centroid"), "{moved}");
let nan = Exact { volume: f64::NAN, area: 600.0, centroid: Vec3::ZERO };
assert!(disagreement(&nan, &mesh(1000.0, 600.0, Vec3::ZERO), 17.3).is_some());
}
}