diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index f007f4b..47dbd4f 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -56,6 +56,7 @@ is committed and listed here with its exports.
| assets/icons/file.save_as.svg, assets/icons/file.save_as.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save_as.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/file.save.svg, assets/icons/file.save.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.bodies.svg, assets/icons/geometry.bodies.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.bodies.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+| assets/icons/geometry.check.svg, assets/icons/geometry.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.svg, assets/icons/geometry.datum.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.axis.svg, assets/icons/geometry.datum.axis.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.axis.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.point.svg, assets/icons/geometry.datum.point.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.point.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
diff --git a/assets/icons/geometry.check.svg b/assets/icons/geometry.check.svg
new file mode 100644
index 0000000..6220b52
--- /dev/null
+++ b/assets/icons/geometry.check.svg
@@ -0,0 +1,11 @@
+
+
diff --git a/assets/icons/geometry.check.vectorcraft b/assets/icons/geometry.check.vectorcraft
new file mode 100644
index 0000000..2bef93a
Binary files /dev/null and b/assets/icons/geometry.check.vectorcraft differ
diff --git a/assets/icons/src/geometry.check.json b/assets/icons/src/geometry.check.json
new file mode 100644
index 0000000..1dc73f1
--- /dev/null
+++ b/assets/icons/src/geometry.check.json
@@ -0,0 +1 @@
+{"shapes": [{"d": "M9 3 L16 6.5 L16 13 L9 16.5 L2 13 L2 6.5 Z", "fill": "soft", "stroke": "line"}, {"d": "M2 6.5 L9 10 L16 6.5 M9 10 L9 16.5", "stroke": "line"}, {"line": [2, 6.5, 2, 9.5], "stroke": "warm", "width": 2.25}, {"circle": [17, 17, 3.5], "stroke": "accent", "width": 1.75}, {"line": [19.5, 19.5, 22, 22], "stroke": "accent", "width": 2}]}
diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs
index 5c2d0fa..a8933c0 100644
--- a/crates/engine/src/catalogue.rs
+++ b/crates/engine/src/catalogue.rs
@@ -247,6 +247,13 @@ pub static CATALOGUE: &[Capability] = &[
&["geometry.centre", "geometry.features"],
),
ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"),
+ ready(
+ "geometry.check",
+ "Check Geometry",
+ Geometry,
+ "Inspect",
+ "Short edges, small and sliver faces and near faces against the element size, located",
+ ),
ready(
"mesh.surface.check",
"Check Surface",
diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs
index c035da9..f4050d9 100644
--- a/crates/engine/src/cmd/forms.rs
+++ b/crates/engine/src/cmd/forms.rs
@@ -1635,6 +1635,17 @@ pub const MESH_PLAN: &[Field] = &[
];
pub const MESH_PLAN_APPLY: &[Field] = &[text("plan", "Plan").help("The name of a saved mesh plan (mesh.plan).").req()];
pub const FEATURES: &[Field] = &[reference("body", "Body", C::Body).help("Only this body's features.").hint("every body")];
+pub const GEOMETRY_CHECK: &[Field] = &[
+ reference("body", "Body", C::Body).help("The body to check; every body when empty.").hint("every body"),
+ positive("size", "Element size", D::Millimetre)
+ .help("The element size you intend to mesh with; features are small against it.")
+ .hint("1/20 of the diagonal"),
+ range("short_edge", "Short edge", D::None, 0.0, 10.0).help("Edges shorter than this fraction of the size are reported.").def("0.3"),
+ range("small_face", "Small face", D::None, 0.0, 10.0).help("Faces with area below this fraction of size² are reported.").def("0.1"),
+ range("sliver", "Sliver width", D::None, 0.0, 10.0).help("Faces narrower than this fraction of the size are reported.").def("0.3"),
+ range("gap", "Gap", D::None, 0.0, 10.0).help("Parallel faces closer than this fraction of the size, but not touching, are reported.").def("0.3"),
+ range("parallel_angle", "Parallel within", D::Degree, 0.0, 60.0).help("Faces this close to parallel count for the gap.").def("10"),
+];
pub const TESSELLATE: &[Field] = &[reference("body", "Body", C::Body).help("The body to tessellate.").req(), TOLERANCE];
pub const IMPORT_MESH: &[Field] = &[path("path", "3MF file", false).help("A 3MF surface mesh with named faces.").req()];
pub const FILE_OPEN: &[Field] = &[path("path", "Model", false).help("The FieldCraft model file to open.").req()];
diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs
index 2cbbdfd..0ab06d1 100644
--- a/crates/engine/src/cmd/geometry.rs
+++ b/crates/engine/src/cmd/geometry.rs
@@ -53,6 +53,16 @@ pub const CENTRE: CommandSpec = CommandSpec {
run: centre,
};
+pub const CHECK: CommandSpec = CommandSpec {
+ id: "geometry.check",
+ label: "Check Geometry",
+ params: r#"{"body"?: "Body1" (default: every body of the open design), "size"?: mm (the target element size; default a twentieth of the smallest checked body's bounding-box diagonal), "short_edge": 0.3, "small_face": 0.1, "sliver": 0.3, "gap": 0.3 (fractions of the size, 0..=10), "parallel_angle": 10 (degrees, 0..=60)} — before meshing, finds what is small against the size (read-only): `short_edge` edges shorter than short_edge × size, `small_face` faces with area below small_face × size², `sliver_face` faces narrower (2 × area / perimeter) than sliver × size, and `near_faces` pairs of faces within parallel_angle of parallel that are closer than gap × size without touching (a gap between bodies or cells, an overlap, or a wall thinner than the gap; faces within twice the tessellation tolerance touch). Replies `size`, `counts` per kind, `summary`, and `findings` (by kind, worst first, at most 1000; `truncated` says when more were found), each with `kind`, `body`, `edge`, `faces` (persistent names; near faces: the second on `other_body`), `value` (mm, mm²), `limit` and `at` (a point on it)"#,
+ form: Some(super::forms::GEOMETRY_CHECK),
+ kind: CommandKind::Model,
+ requires: &[Requirement::Design],
+ run: check,
+};
+
pub const TESSELLATE: CommandSpec = CommandSpec {
id: "geometry.tessellate",
label: "Tessellate Body",
@@ -383,6 +393,80 @@ pub(crate) fn body_mesh<'a>(s: &'a Session, p: &Value) -> Result<(&'a Shape, Sur
Ok((shape, shape.tessellate(tolerance)?))
}
+/// Most findings `geometry.check` lists.
+const MAX_FINDINGS: usize = 1000;
+
+fn check(s: &mut Session, p: &Value) -> Result {
+ use fieldcraft_geometry::check::{self, CheckOptions, Kind};
+ let shapes: Vec<&Shape> = match p.get("body") {
+ Some(_) => vec![body(s, p)?],
+ None => s.design.as_deref().map(|d| d.bodies.iter().collect()).unwrap_or_default(),
+ };
+ if shapes.is_empty() {
+ return Err(EngineError::Params("the design has no bodies to check".into()));
+ }
+ let size = match p.get("size") {
+ Some(v) => v.as_f64().filter(|x| *x > 0.0 && x.is_finite()).ok_or_else(|| EngineError::param("size", ProblemKind::NotPositive))?,
+ None => {
+ let mut smallest = f64::INFINITY;
+ for b in &shapes {
+ smallest = smallest.min(b.size()?);
+ }
+ smallest / 20.0
+ }
+ };
+ let fraction = |key: &str, default: f64| super::mesh::number_in(p, key, default, 0.0..=10.0);
+ let defaults = CheckOptions::new(size);
+ let options = CheckOptions {
+ size,
+ short_edge: fraction("short_edge", defaults.short_edge)?,
+ small_face: fraction("small_face", defaults.small_face)?,
+ sliver: fraction("sliver", defaults.sliver)?,
+ gap: fraction("gap", defaults.gap)?,
+ parallel_angle: super::mesh::number_in(p, "parallel_angle", defaults.parallel_angle.to_degrees(), 0.0..=60.0)?.to_radians(),
+ };
+ let findings = check::check(&shapes, &options)?;
+ let counts: serde_json::Map =
+ Kind::ALL.iter().map(|k| (k.name().to_string(), json!(findings.iter().filter(|f| f.kind == *k).count()))).collect();
+ // The size to three decimals, without trailing zeros.
+ let shown = format!("{size:.3}").trim_end_matches('0').trim_end_matches('.').to_string();
+ let summary = if findings.is_empty() {
+ format!("nothing small against size {shown}")
+ } else {
+ let parts: Vec = Kind::ALL
+ .iter()
+ .filter_map(|k| {
+ let n = findings.iter().filter(|f| f.kind == *k).count();
+ (n > 0).then(|| format!("{n} {}", k.name().replace('_', " ")))
+ })
+ .collect();
+ format!("{} against size {shown}", parts.join(", "))
+ };
+ let listed: Vec = findings
+ .iter()
+ .take(MAX_FINDINGS)
+ .map(|f| {
+ json!({
+ "kind": f.kind.name(),
+ "body": f.body,
+ "edge": f.edge,
+ "faces": f.faces,
+ "other_body": f.other_body,
+ "value": f.value,
+ "limit": f.limit,
+ "at": f.at,
+ })
+ })
+ .collect();
+ Ok(json!({
+ "size": size,
+ "counts": counts,
+ "summary": summary,
+ "findings": listed,
+ "truncated": findings.len() > MAX_FINDINGS,
+ }))
+}
+
fn tessellate(s: &mut Session, p: &Value) -> Result {
let (shape, mesh) = body_mesh(s, p)?;
let mut per_face = vec![0usize; shape.faces().len()];
@@ -420,6 +504,97 @@ mod tests {
use super::*;
+ /// A session with the design the SolidCraft `commands` build open.
+ fn design(commands: Value) -> (crate::tests::TempDir, Session) {
+ let bytes = fieldcraft_geometry::script_design(&json!({"commands": commands}).to_string()).unwrap();
+ let (dir, path) = crate::tests::design_file(&bytes);
+ let mut s = Session::default();
+ s.execute("geometry.open", &json!({"path": path})).unwrap();
+ (dir, s)
+ }
+
+ fn kinds(r: &Value) -> Vec<&str> {
+ r["findings"].as_array().unwrap().iter().map(|f| f["kind"].as_str().unwrap()).collect()
+ }
+
+ /// A plain block is clean at a size it can be meshed with; a 0.4 mm step on its top is a
+ /// sliver riser with two short edges, located on the step; the check changes nothing.
+ #[test]
+ fn check_finds_a_small_step() {
+ let (_dir, mut s) = design(json!([{"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10, "body_name": "Base"}}]));
+ let clean = s.execute("geometry.check", &json!({"size": 5})).unwrap();
+ assert!(kinds(&clean).is_empty(), "{clean}");
+ assert_eq!(clean["summary"], "nothing small against size 5");
+ let (_dir, mut s) = design(json!([
+ {"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10, "body_name": "Base"}},
+ {"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10.4, "operation": "join", "targets": ["Base"]}},
+ ]));
+ let journal = s.journal.len();
+ let r = s.execute("geometry.check", &json!({"body": "Base", "size": 5})).unwrap();
+ assert_eq!(s.journal.len(), journal, "read-only");
+ assert_eq!(r["counts"]["short_edge"], 2, "{r}");
+ assert_eq!(r["counts"]["sliver_face"], 1, "{r}");
+ assert_eq!(r["counts"]["small_face"], 0, "{r}");
+ assert_eq!(r["counts"]["near_faces"], 0, "{r}");
+ let names: Vec = s.execute("geometry.bodies", &json!({})).unwrap()[0]["faces"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|f| f["name"].as_str().unwrap().to_string())
+ .collect();
+ for f in r["findings"].as_array().unwrap() {
+ let at: Vec = f["at"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
+ assert!((at[0] - 20.0).abs() < 1e-6 && at[2] >= 10.0 - 1e-6 && at[2] <= 10.4 + 1e-6, "on the riser: {f}");
+ assert!(f["faces"].as_array().unwrap().iter().all(|n| names.contains(&n.as_str().unwrap().to_string())), "{f}");
+ assert!(f["value"].as_f64().unwrap() < f["limit"].as_f64().unwrap());
+ }
+ let short = &r["findings"][0];
+ assert!((short["value"].as_f64().unwrap() - 0.4).abs() < 1e-6, "{short}");
+ assert!(short["edge"].is_string());
+ // The size is what makes the step small: at 1 mm it is a feature like any other.
+ let fine = s.execute("geometry.check", &json!({"size": 1})).unwrap();
+ assert!(kinds(&fine).is_empty(), "{fine}");
+ }
+
+ /// Two bodies 0.5 mm apart: their facing faces are near faces; once they touch they are not.
+ #[test]
+ fn check_finds_a_gap_between_bodies() {
+ let (_dir, mut s) = design(json!([
+ {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "A"}},
+ {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [0, 0, 10.5], "body_name": "B"}},
+ ]));
+ let r = s.execute("geometry.check", &json!({"size": 2.5})).unwrap();
+ assert_eq!(kinds(&r), ["near_faces"], "{r}");
+ let f = &r["findings"][0];
+ assert!((f["value"].as_f64().unwrap() - 0.5).abs() < 1e-6, "{f}");
+ let bodies = [f["body"].as_str().unwrap(), f["other_body"].as_str().unwrap()];
+ assert!(bodies.contains(&"A") && bodies.contains(&"B"), "{f}");
+ assert!(s.execute("geometry.check", &json!({"size": 2.5, "gap": 0.1})).unwrap()["findings"].as_array().unwrap().is_empty());
+ // Without a size the default is a twentieth of the smaller diagonal.
+ let d = s.execute("geometry.check", &json!({})).unwrap();
+ assert!((d["size"].as_f64().unwrap() - 300f64.sqrt() / 20.0).abs() < 1e-9, "{d}");
+ }
+
+ #[test]
+ fn check_rejects_hostile_parameters() {
+ let mut s = Session::default();
+ assert!(s.execute("geometry.check", &json!({})).is_err(), "needs a design");
+ let (_dir, mut s) = design(json!([{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "A"}}]));
+ for p in [
+ json!({"size": 0}),
+ json!({"size": -1}),
+ json!({"size": "big"}),
+ json!({"size": 1e308, "gap": 1e308}),
+ json!({"short_edge": -1}),
+ json!({"sliver": 1e9}),
+ json!({"parallel_angle": 90}),
+ json!({"body": "Nope"}),
+ json!({"body": 3}),
+ ] {
+ assert!(s.execute("geometry.check", &p).is_err(), "{p}");
+ }
+ }
+
fn package(model: &str) -> Vec {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("3D/3dmodel.model", zip::write::SimpleFileOptions::default()).unwrap();
diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs
index 9d5fe17..b02d1fe 100644
--- a/crates/engine/src/cmd/mesh.rs
+++ b/crates/engine/src/cmd/mesh.rs
@@ -156,7 +156,7 @@ pub(super) fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result) -> Result {
+pub(super) fn number_in(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive) -> Result {
let v = optional_number(p, key, default)?;
if range.contains(&v) { Ok(v) } else { Err(EngineError::param(key, ProblemKind::NotInRange { min: *range.start(), max: *range.end() })) }
}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index e4f91a3..e91601f 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -51,6 +51,7 @@ pub static ALL: &[CommandSpec] = &[
geometry::BODIES,
geometry::FEATURES,
geometry::CENTRE,
+ geometry::CHECK,
geometry::TESSELLATE,
geometry::IMPORT_MESH,
datum::DATUM_PLANE,
diff --git a/docs/features.md b/docs/features.md
index 303c045..6e01156 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
-75 ready, 39 coming soon.
+76 ready, 39 coming soon.
## Geometry
@@ -16,6 +16,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Import | Tagged Mesh — Read a face-tagged surface mesh (3MF) | ready | `geometry.import.mesh` |
| Inspect | Bodies — List the open design's bodies, volumes and named faces, edges and vertices | ready | `geometry.bodies`, `geometry.centre`, `geometry.features` |
| Inspect | Tessellate — Tessellate a body into a face-tagged boundary surface | ready | `geometry.tessellate` |
+| Inspect | Check Geometry — Short edges, small and sliver faces and near faces against the element size, located | ready | `geometry.check` |
| Inspect | Check Surface — Weld, closed, manifold, orientation and self-intersection checks; feature edges | ready | `mesh.surface.check` |
| Prepare | Datum Plane — A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis | ready | `geometry.datum`, (`geometry.datum.edit`), (`geometry.datum.delete`), (`geometry.datum.suppress`) |
| Prepare | Datum Axis — A datum axis: principal axis, two points or datum points, point and direction | ready | `geometry.datum.axis`, (`geometry.datum.axis.edit`) |