forked from 1b-agent/fieldcraft-upstream
engine: draw geometry.check findings as viewport markers
geometry.check keeps its findings on the session (not an edit: not journalled, saved or undone) while the checked design stays open; the picture and the app's viewport draw the faces of small, sliver and near faces in the marker colour, short edges as thick lines and a dot at each finding over everything. `mark: false` or a clean check clears them. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
f8b92fcd89
commit
e79ba866a1
6 files changed
+193
-3
No files matched your search
@@ -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()];
|
||||
|
||||
@@ -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<Value> {
|
||||
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<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.
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<usage::Origin>,
|
||||
/// 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<Arc<view::GeometryMarks>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
|
||||
@@ -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<fieldcraft_geometry::Design>,
|
||||
pub marks: Vec<GeometryMark>,
|
||||
}
|
||||
|
||||
/// 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<Section>, 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);
|
||||
|
||||
@@ -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<String>,
|
||||
/// The faces of `faces` by face index (each on its own body), for drawing them.
|
||||
pub face_indices: Vec<u32>,
|
||||
/// 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<String>,
|
||||
/// The measure: length (mm), area (mm²), width (mm) or distance (mm).
|
||||
@@ -130,6 +134,8 @@ pub fn check(bodies: &[&Shape], options: &CheckOptions) -> Result<Vec<Finding>>
|
||||
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<Vec<Finding>>
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user