engine: geometry.check reports what is small against the element size

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 23:17:39 -04:00
1 parent 5cee94f947
commit 44de124d9d
10 files changed
+210 -2

No files matched your search

+1
View File
@@ -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 |
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>geometry.check</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M9 3 L16 6.5 L16 13 L9 16.5 L2 13 L2 6.5 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 6.5 L9 10 L16 6.5 M9 10 L9 16.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 6.5 L2 9.5" fill="none" stroke="#f0b44a" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.5 17 C13.5 15.067 15.067 13.5 17 13.5 C18.933 13.5 20.5 15.067 20.5 17 C20.5 18.933 18.933 20.5 17 20.5 C15.067 20.5 13.5 18.933 13.5 17 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.5 19.5 L22 22" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.
+1
View File
@@ -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}]}
+7
View File
@@ -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",
+11
View File
@@ -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()];
+175
View File
@@ -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<Value> {
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<String, Value> =
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<String> = 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<Value> = 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<Value> {
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<String> = 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<f64> = 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<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("3D/3dmodel.model", zip::write::SimpleFileOptions::default()).unwrap();
+1 -1
View File
@@ -156,7 +156,7 @@ pub(super) fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result<SurfaceOp
}
/// An optional number in `range`.
fn number_in(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
pub(super) fn number_in(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
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() })) }
}
+1
View File
@@ -51,6 +51,7 @@ pub static ALL: &[CommandSpec] = &[
geometry::BODIES,
geometry::FEATURES,
geometry::CENTRE,
geometry::CHECK,
geometry::TESSELLATE,
geometry::IMPORT_MESH,
datum::DATUM_PLANE,
+2 -1
View File
@@ -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`) |