From 44de124d9d78616cbbff9dea4b7bd15619beee3c Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:13:57 -0400 Subject: [PATCH] engine: geometry.check reports what is small against the element size Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- ATTRIBUTION.md | 1 + assets/icons/geometry.check.svg | 11 ++ assets/icons/geometry.check.vectorcraft | Bin 0 -> 1948 bytes assets/icons/src/geometry.check.json | 1 + crates/engine/src/catalogue.rs | 7 + crates/engine/src/cmd/forms.rs | 11 ++ crates/engine/src/cmd/geometry.rs | 175 ++++++++++++++++++++++++ crates/engine/src/cmd/mesh.rs | 2 +- crates/engine/src/cmd/mod.rs | 1 + docs/features.md | 3 +- 10 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 assets/icons/geometry.check.svg create mode 100644 assets/icons/geometry.check.vectorcraft create mode 100644 assets/icons/src/geometry.check.json 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 0000000000000000000000000000000000000000..2bef93a585b985a8b67a55cd4897dc4c0b469ae0 GIT binary patch literal 1948 zcmV;N2V?jjiwFP!00000|K(b3Z`(Ey{wsz)8;0Ir0_;QA6k=!(7kXe={;8rh1)K<>p{p_{4edmi6)PGtMY5Wz zg@W0V<@1Ubok$-DDN9N!Qp_m!$Dk95NJ@6DS)PNyk}#Mt8d<)ztP5l5~r}!EMneRo(X0sW0vg)SdH`G#E8slH0q@`)>Ct;+7WeN%i zbMDx@SpwM;sGN}!4THA@_WlQFMfbY7+#Il968Lf~f1tE(r}!x80s@y@DHA%q023Yu z6ZaB_hwokt1@R>67kIl(@Dp#hiND{%*L{HT%yM4SxzsFx6+EI!16!3Ayr9m1RUQ5w z`+{rtsD5_1l{0mGT)Vx`x4Jv{d`CCo_o16C^gsiV(oG^JW~g-IXRepU-i~VgiT`bN zgYVfRRD#oFw}QHQNu*0;1@b_IuST@!mz^`{a3hA)D}`=1TXjU=hdKf`OWhy~ls;ll zObS~OTfCv4tA>w(n%KhSr*5B7`jQ4q)^SPB#g zP*tlD9F9=c?+G~c?1v^#^PH76n%kXohDe;H`YjpMUtp9H#I6|r{WVI50$2U>6!((B z?PQ4mm6JhC?3bJi88VgadM6Z#|JnH`(IL7beAx9++C||5`Y2Eh^k3hR2EtnjNJWPi zzXIczk7@##%lVY#+X%Ge&CXh;dilBp1zRZzEeN$Eey8GhV=DE>oQP1}#xz`I6Rxrb zbE%){+;*w-#bT-B7*86YuMl`*Aq`VwARl3NE$HY3JYj?oLLF2`i0?!=woqan)Cnk& zj`k&#oe<9m%B$5>>};%%q+#F%T1T;rV01i#HGUKs1Nj}iE5EZw0$ZF&JFhGde>$}C zlr0T}GJ0E4*s@uM;XQLn6J*L7&j+@$HI^t8TG7lTJ!A1h8G{-FIfW75my@Q8e#MMF z68;(?d9#8PMm|5$qeWn$K#<-a`H~KEoBfTF{6q*4wCvgj?9Fj{o4b-%f40OnPtZ-1 zV;WHo<$-H7QP-GMbTCcsK1wVnIkS2!<^mvCxIDaVTq0K-5;+Trq#O6+P8Bgn#w* zd`$vjuLBnS))s*$mRnCJk-SV8rj7^oyfH@du9&k0x5XkeSdlhVIb(uEYbH?OjM02y zJg1E%8jZs1xl%D}6LUg7wN;s0JM~_I&l7IdOFJZjAM=U(3xR}iW9vj>aN8g-*^3ba zL>QTy>StbCXS`&j5kF=fGE_6y3@dB{<^7ImjZ7^#)rLv5=ugZ`GG! znmL$rQrN~mNzZxo_xYhe&_-W#NL|=&AL5>R!d|L6kb#i77yI1SwjO;iMh>L^(1xPp zR%0YzNnW(pt4q||$@u+=J9wk{rY_;2s}3bbyq^T1qlNN^C799v~&MmJ$t+5?V_M2S^F5r33?{_|{VV0a83`DgPAu zT=sTzY_V(}o3*mm`h3Fc7E#ZWIifCL6ft%*VACmpoREy)Vi60Tn+`rTQW%~im)M68 z%FN1AC{fqmON)523$<1AfzS z`A>}NXG(6@qjw@f_Ja2pjq|(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`) |