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 @@ + + + geometry.check + + + + + + + + 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`) |