diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index d5822d7..00fd2a7 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1718,6 +1718,7 @@ pub const GEOMETRY_CHECK: &[Field] = &[ 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"), + flag("mark", "Mark findings").help("Mark what is found in the viewport until the design changes.").def("true"), ]; 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()]; diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index 0ab06d1..e1f92f3 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -56,7 +56,7 @@ pub const CENTRE: CommandSpec = CommandSpec { 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)"#, + 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). `mark` (default true): the viewport and pictures mark the findings in the marker colour (their faces, short edges as lines, and a dot at each `at`) until the design changes or a check with `mark: false` or nothing found"#, form: Some(super::forms::GEOMETRY_CHECK), kind: CommandKind::Model, requires: &[Requirement::Design], @@ -425,7 +425,30 @@ fn check(s: &mut Session, p: &Value) -> Result { 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 mark = match p.get("mark") { + None => true, + Some(v) => v.as_bool().ok_or_else(|| EngineError::Params("`mark` must be true or false".into()))?, + }; let findings = check::check(&shapes, &options)?; + s.geometry_marks = s.design.as_ref().filter(|_| mark && !findings.is_empty()).map(|design| { + let marks = findings + .iter() + .map(|f| { + // A near pair's second face lies on the other body. + let on = |k: usize| if k == 1 { f.other_body.clone().unwrap_or_else(|| f.body.clone()) } else { f.body.clone() }; + // A short edge is drawn itself: its faces can be large. + let faces = if f.kind == Kind::ShortEdge { + Vec::new() + } else if f.kind == Kind::NearFaces { + f.face_indices.iter().enumerate().map(|(k, &i)| (on(k), i)).collect() + } else { + f.face_indices.iter().map(|&i| (f.body.clone(), i)).collect() + }; + crate::view::GeometryMark { faces, path: f.path.clone(), at: f.at } + }) + .collect(); + Arc::new(crate::view::GeometryMarks { design: Arc::downgrade(design), marks }) + }); 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. @@ -556,6 +579,46 @@ mod tests { assert!(kinds(&fine).is_empty(), "{fine}"); } + /// The findings are drawn in the picture: the sliver riser's faces in the marker colour, the + /// short edges as lines and a dot at each finding over everything; a clean check, `mark: + /// false` or another design takes them away. + #[test] + fn check_marks_its_findings_in_the_picture() { + 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 p = crate::view::Palette::LIGHT; + let marked = |s: &Session| { + let (scene, _, _) = crate::view::scene(s, &p); + let faces = scene.tris.iter().filter(|t| t.color == p.marked).count(); + let lines: Vec<_> = scene.on_top.iter().filter(|l| l.color == p.marked).collect(); + let dots = lines.iter().filter(|l| l.a == l.b).count(); + (faces, lines.len() - dots, dots) + }; + assert_eq!(marked(&s), (0, 0, 0), "nothing checked yet"); + let r = s.execute("geometry.check", &json!({"size": 5})).unwrap(); + let (faces, edges, dots) = marked(&s); + assert!(faces > 0, "the riser's faces are marked"); + assert!(edges >= 2, "the short edges are drawn: {edges}"); + assert_eq!(dots, r["findings"].as_array().unwrap().len(), "a dot at each finding"); + // Every marked triangle lies on the riser, x = 20, between z = 10 and 10.4. + let (scene, _, _) = crate::view::scene(&s, &p); + for t in scene.tris.iter().filter(|t| t.color == p.marked) { + assert!(t.p.iter().all(|v| (v.x - 20.0).abs() < 1e-6 && v.z > 10.0 - 1e-6 && v.z < 10.4 + 1e-6), "{t:?}"); + } + s.execute("geometry.check", &json!({"size": 5, "mark": false})).unwrap(); + assert_eq!(marked(&s), (0, 0, 0), "mark: false clears"); + s.execute("geometry.check", &json!({"size": 5})).unwrap(); + s.execute("geometry.check", &json!({"size": 1})).unwrap(); + assert_eq!(marked(&s), (0, 0, 0), "a clean check clears"); + s.execute("geometry.check", &json!({"size": 5})).unwrap(); + assert!(s.execute("geometry.check", &json!({"size": 5, "mark": "yes"})).is_err()); + assert!(marked(&s).2 > 0, "a failed check keeps the marks"); + s.design = s.design.as_deref().map(|d| Arc::new(d.clone())); + assert_eq!(marked(&s), (0, 0, 0), "stale once another design is open"); + } + /// 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() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 738f650..ece772a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -295,6 +295,9 @@ pub struct Session { /// sets it on its own model (and the control channel and the command search while they /// run); `None` (scratch sessions, the CLI, tests) counts nothing. pub origin: Option, + /// What the last `geometry.check` found, drawn as markers while the design it checked is + /// open ([`view::geometry_marks`]). Not an edit: neither journalled, saved nor undone. + pub geometry_marks: Option>, } impl Session { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 6c7034d..0ecdd60 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -110,6 +110,65 @@ fn display_tolerance(b: &Aabb3) -> f64 { if d.is_finite() { (d * 1e-3).clamp(1e-3, 1.0) } else { 0.1 } } +/// The findings of a `geometry.check`, to draw over the design it checked. +#[derive(Clone, Debug, Default)] +pub struct GeometryMarks { + /// The design checked: the marks are stale once another is open. + pub design: std::sync::Weak, + pub marks: Vec, +} + +/// One finding to draw: its faces, its edge and the point to look at. +#[derive(Clone, Debug, PartialEq)] +pub struct GeometryMark { + /// The faces by body name and face index. + pub faces: Vec<(String, u32)>, + /// A short edge's polyline (empty for the other kinds). + pub path: Vec<[f64; 3]>, + pub at: [f64; 3], +} + +/// The findings of the last `geometry.check`, while the design it checked is still open. +pub fn geometry_marks(s: &Session) -> Option<&GeometryMarks> { + let marks = s.geometry_marks.as_deref()?; + let design = s.design.as_ref()?; + std::sync::Weak::ptr_eq(&marks.design, &std::sync::Arc::downgrade(design)).then_some(marks) +} + +/// Marker line width, and the marker dot's diameter, in output pixels. +const MARK_WIDTH: f64 = 4.0; +const MARK_DOT: f64 = 12.0; + +/// Draw `marks` over `scene` in `color`: the faces they name recoloured when `faces` (the +/// scene's triangles start with `display`'s surface triangles in order, as they do while no +/// element faces are drawn; `bodies` names the drawn surfaces in order), short edges as thick +/// lines and a dot at each finding's point, both over everything so a feature too small to see +/// at the zoom, or inside the mesh, still shows where it is. +pub fn draw_geometry_marks(scene: &mut Scene, display: &DisplayMesh, bodies: &[&str], marks: &GeometryMarks, faces: bool, color: Rgb) { + if faces { + let marked: std::collections::BTreeSet<(u32, u32)> = marks + .marks + .iter() + .flat_map(|m| &m.faces) + .filter_map(|(body, f)| bodies.iter().position(|b| b == body).and_then(|b| u32::try_from(b).ok()).map(|b| (b, *f))) + .collect(); + for (t, d) in scene.tris.iter_mut().zip(&display.surface_tris) { + if marked.contains(&(d.body, d.face)) { + t.color = color; + } + } + } + for m in &marks.marks { + for w in m.path.windows(2) { + if let [a, b] = w { + scene.on_top.push(SceneLine { a: Vec3::from(*a), b: Vec3::from(*b), color, width: MARK_WIDTH }); + } + } + let at = Vec3::from(m.at); + scene.on_top.push(SceneLine { a: at, b: at, color, width: MARK_DOT }); + } +} + /// What a picture shows besides the image. #[derive(Clone, Debug, Default, PartialEq)] pub struct Shown { @@ -273,6 +332,16 @@ fn picture(s: &Session, p: &Palette, section: Option
, toward: Vec3) -> let Some((view, result)) = plot else { let mut scene = model_scene(&base, p); mark(&mut scene, &base, &marked(s), p.marked); + if let Some(marks) = geometry_marks(s) { + let bodies: Vec<&str> = s + .design + .iter() + .flat_map(|d| &d.bodies) + .filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name)) + .map(|b| b.name.as_str()) + .collect(); + draw_geometry_marks(&mut scene, &base, &bodies, marks, base.tris.is_empty(), p.marked); + } let bounds = base.bounds; let shows = |n: &str| s.shows(fieldcraft_io::ItemKind::Datum, n); let datums = fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &shows, None, &p.glyphs, &mut scene); diff --git a/crates/geometry/src/check.rs b/crates/geometry/src/check.rs index 5f330b8..9bf695b 100644 --- a/crates/geometry/src/check.rs +++ b/crates/geometry/src/check.rs @@ -78,6 +78,10 @@ pub struct Finding { /// The face (small and sliver faces), the faces of the edge (short edges), or the two faces /// (near faces: the first on `body`, the second on `other_body`). pub faces: Vec, + /// The faces of `faces` by face index (each on its own body), for drawing them. + pub face_indices: Vec, + /// The edge's polyline, for a short edge (empty otherwise), for drawing it. + pub path: Vec<[f64; 3]>, /// The body of the second face, for near faces. pub other_body: Option, /// The measure: length (mm), area (mm²), width (mm) or distance (mm). @@ -130,6 +134,8 @@ pub fn check(bodies: &[&Shape], options: &CheckOptions) -> Result> body: shape.name.clone(), edge: Some(e.name.clone()), faces: distinct.iter().map(|&f| shape.face_label(f)).collect(), + face_indices: distinct.iter().copied().collect(), + path: e.points.clone(), other_body: None, value: length, limit, @@ -174,7 +180,18 @@ pub fn check(bodies: &[&Shape], options: &CheckOptions) -> Result> } fn face_finding(kind: Kind, shape: &Shape, f: u32, value: f64, limit: f64, at: [f64; 3]) -> Finding { - Finding { kind, body: shape.name.clone(), edge: None, faces: vec![shape.face_label(f)], other_body: None, value, limit, at } + Finding { + kind, + body: shape.name.clone(), + edge: None, + faces: vec![shape.face_label(f)], + face_indices: vec![f], + path: Vec::new(), + other_body: None, + value, + limit, + at, + } } #[derive(Default)] @@ -279,6 +296,8 @@ fn near_faces(bodies: &[&Shape], tris: &[Tri], adjacent: &BTreeSet<(usize, u32, body: sa.name.clone(), edge: None, faces: vec![sa.face_label(fa), sb.face_label(fb)], + face_indices: vec![fa, fb], + path: Vec::new(), other_body: Some(sb.name.clone()), value: d, limit: gap, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 28586a2..7103301 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1041,7 +1041,8 @@ impl ModelApp { pub fn scene_key(&self) -> u64 { let mut h = std::hash::DefaultHasher::new(); let plot = self.plot.1.as_ref().map(|p| Arc::as_ptr(p) as usize); - (self.revision, self.ui.style, self.ui.show(), self.ui.dark, plot, self.selected_datum()).hash(&mut h); + let marks = fieldcraft_engine::view::geometry_marks(&self.session).map(|m| m as *const _ as usize); + (self.revision, self.ui.style, self.ui.show(), self.ui.dark, plot, self.selected_datum(), marks).hash(&mut h); format!("{:?}", self.datum_preview()).hash(&mut h); h.finish() } @@ -1091,6 +1092,13 @@ impl ModelApp { let marked = fieldcraft_engine::view::marked(&self.session); fieldcraft_engine::view::mark(&mut s, &self.display.1, &marked, theme::rgb(t.error)); } + // What Check Geometry found, in the error colour: its faces where geometry faces are + // drawn, short edges and a dot at each finding over everything. + if let Some(marks) = fieldcraft_engine::view::geometry_marks(&self.session).filter(|_| show.geometry && self.plot.1.is_none()) { + let faces = style != scene::Style::Wireframe && !(show.mesh && !self.display.1.tris.is_empty()); + let bodies: Vec<&str> = self.pick.names.iter().map(|n| n.name.as_str()).collect(); + fieldcraft_engine::view::draw_geometry_marks(&mut s, &self.display.1, &bodies, marks, faces, theme::rgb(t.error)); + } let mut datums = Aabb3::EMPTY; if show.geometry && self.plot.1.is_none() { datums = scene::datums(&mut s, &self.session, &self.display.1.bounds, self.selected_datum().as_deref(), &t); @@ -2454,6 +2462,33 @@ mod tests { assert_eq!(red(&mut app), 0); } + /// Check Geometry's findings draw in the error colour (faces, and a dot at each over + /// everything) until a check with `mark: false`. + #[test] + fn geometry_check_findings_draw_in_the_error_colour() { + let dir = std::env::temp_dir().join(format!("fieldcraft-ui-check-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let design = dir.join("plate.solidcraft"); + let bytes = fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap(); + std::fs::write(&design, bytes).unwrap(); + let mut app = ModelApp::new(Session::default()); + app.run("geometry.open", json!({"path": design.to_string_lossy()})).unwrap(); + let error = theme::rgb(theme::Tokens::get(app.ui.dark).error); + let red = |app: &mut ModelApp| { + let s = app.scene().1; + (s.tris.iter().filter(|t| t.color == error).count(), s.on_top.iter().filter(|l| l.color == error && l.a == l.b).count()) + }; + assert_eq!(red(&mut app), (0, 0)); + // Against a 1 m element every face of the plate is small. + let r = app.run("geometry.check", json!({"size": 1000})).unwrap(); + let (faces, dots) = red(&mut app); + assert!(faces > 0, "{r}"); + assert_eq!(dots, r["findings"].as_array().unwrap().len()); + app.run("geometry.check", json!({"size": 1000, "mark": false})).unwrap(); + assert_eq!(red(&mut app), (0, 0)); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn starting_a_command_opens_its_form_and_running_it_closes_the_form() { let mut app = ModelApp::new(Session::default());