From 94c98e0c0b6f6b7090d31d8b0f10096365ce2d28 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:35:12 -0400 Subject: [PATCH] UI: pick a design's edges by their persistent names The viewport keeps each drawn body's B-rep edges; with Select: Edges a click picks the nearest visible one (before and after meshing), hover and the selection draw it wide, and multi-select works as for faces. Part of #230. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/mesh.rs | 6 +- crates/render/src/display.rs | 31 ++++++++++ crates/render/src/lib.rs | 2 +- crates/ui-egui/src/lib.rs | 14 ++++- crates/ui-egui/src/selection.rs | 105 +++++++++++++++++++++++++++----- 5 files changed, 139 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index e32a27c..fe90728 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -755,7 +755,8 @@ mod tests { // A tagged tetrahedron, and the same with a face missing. let p = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; let t = vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]]; - let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() }; + let tet = + SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() }; let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() }; for mesh in [tet, open] { s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 })); @@ -956,7 +957,8 @@ mod tests { let p = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; let t = vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]]; - let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() }; + let tet = + SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() }; let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() }; let mut s = Session::default(); for mesh in [tet, open] { diff --git a/crates/render/src/display.rs b/crates/render/src/display.rs index 2f406ae..a1c0a66 100644 --- a/crates/render/src/display.rs +++ b/crates/render/src/display.rs @@ -75,6 +75,17 @@ pub struct SurfaceTri { pub face: u32, } +/// A segment of a geometry surface's B-rep edge (a named design edge), for picking and marking. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SurfaceCurve { + pub a: Vec3, + pub b: Vec3, + /// The surface (body) it belongs to, as [`SurfaceTri::body`]. + pub body: u32, + /// The edge's index in that body's tessellation. + pub edge: u32, +} + /// Display data for a model and its geometry. #[derive(Clone, Debug, Default, PartialEq)] pub struct DisplayMesh { @@ -90,6 +101,8 @@ pub struct DisplayMesh { pub surface_tris: Vec, /// Geometry edges: where two faces meet, and open boundaries. pub surface_edges: Vec<[Vec3; 2]>, + /// The B-rep edges of the geometry surfaces that have them, segment by segment. + pub curves: Vec, /// Geometry surfaces (bodies) added. pub bodies: u32, pub bounds: Aabb3, @@ -267,6 +280,21 @@ impl DisplayMesh { self.bodies = self.bodies.saturating_add(1); } + /// Add the B-rep edges (polylines, by edge index) of the surface [`DisplayMesh::add_surface`] + /// adds next, so they are picked as that body's edges; non-finite points are skipped. + pub fn add_curves(&mut self, edges: &[Vec<[f64; 3]>]) { + let body = self.bodies; + for (e, points) in edges.iter().enumerate() { + let Ok(edge) = u32::try_from(e) else { break }; + for w in points.windows(2) { + let (a, b) = (Vec3::from(w[0]), Vec3::from(w[1])); + if a.is_finite() && b.is_finite() { + self.curves.push(SurfaceCurve { a, b, body, edge }); + } + } + } + } + /// The colour of a geometry triangle: `single` when there is one body, else body `k`'s /// colour from `palette` (cycling), so the bodies of a design, such as the cells of a /// partitioned part, tell apart. @@ -415,7 +443,10 @@ mod tests { // triangle, which are skipped. let p = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]; let mut d = DisplayMesh::default(); + // Its B-rep edges: the diagonal (two segments) and a side; a non-finite point is skipped. + d.add_curves(&[vec![p[0], [0.5, 0.5, 0.0], p[2]], vec![p[0], p[1]], vec![[f64::NAN; 3], p[1]]]); d.add_surface(&p, &[[0, 1, 2], [0, 2, 3], [0, 0, 1], [0, 1, 9]], &[0, 1, 1, 1]); + assert_eq!(d.curves.iter().map(|c| (c.body, c.edge)).collect::>(), vec![(0, 0), (0, 0), (0, 1)]); assert_eq!(d.surface_tris.len(), 2); // Four open sides and the diagonal between the two faces. assert_eq!(d.surface_edges.len(), 5); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3d900ef..6488b75 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,7 +16,7 @@ mod raster; mod vec; pub use camera::{Camera, Mat4, Orientation, StandardView}; -pub use display::{DisplayEdge, DisplayLine, DisplayMesh, DisplayNode, DisplayTri, SurfaceTri}; +pub use display::{DisplayEdge, DisplayLine, DisplayMesh, DisplayNode, DisplayTri, SurfaceCurve, SurfaceTri}; pub use raster::{ Canvas, LABEL_OFFSET, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneLabel, SceneLine, ScenePoint, SceneTri, fit_size, png, render, render_layers, render_layers_png, render_png, shade, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4893d9d..8b4d3f5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -743,7 +743,11 @@ impl ModelApp { .filter_map(|body| { let tolerance = body.measure().map(|m| display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })); let mesh = tolerance.and_then(|t| body.tessellate(t)).map_err(|e| failed.push(format!("{}: {e}", body.name))).ok()?; - let names = selection::BodyNames { name: body.name.clone(), faces: body.faces().iter().map(|f| f.name.clone()).collect() }; + let names = selection::BodyNames { + name: body.name.clone(), + faces: body.faces().iter().map(|f| f.name.clone()).collect(), + edges: body.edge_names(&mesh), + }; Some((names, mesh)) }) .collect(); @@ -754,13 +758,18 @@ impl ModelApp { } self.pick.names.clear(); for (names, mesh) in &self.bodies.1 { + d.add_curves(&mesh.edges); d.add_surface(&mesh.positions, &mesh.triangles, &mesh.tri_face); self.pick.names.push(names.clone()); } // An imported surface's faces are named by their tags. for s in &self.session.surfaces { d.add_surface(&s.mesh.positions, &s.mesh.triangles, &s.mesh.tri_face); - self.pick.names.push(selection::BodyNames { name: s.name.clone(), faces: (0..s.faces).map(|f| f.to_string()).collect() }); + self.pick.names.push(selection::BodyNames { + name: s.name.clone(), + faces: (0..s.faces).map(|f| f.to_string()).collect(), + edges: Vec::new(), + }); } let was_empty = self.display.1.bounds.is_empty(); self.display = (Some(self.revision), d); @@ -876,6 +885,7 @@ impl ModelApp { index: &index, elements: show_mesh, geometry: show_geometry && !mesh_faces, + curves: show_geometry, }; f(&picker) } diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs index 63d143a..354cbec 100644 --- a/crates/ui-egui/src/selection.rs +++ b/crates/ui-egui/src/selection.rs @@ -14,9 +14,10 @@ //! face. Every choice is the nearest, ties going to the lower index, so a pick is deterministic. //! //! Geometry is named through the mesh's geometry tags ([`fieldcraft_model::Classification`]) once -//! the model is meshed, and through the drawn bodies' face names before. Edges and vertices have -//! names only on a mesh that carries them (block and planar meshes); a design's edges have no -//! persistent names yet. +//! the model is meshed, and through the drawn bodies' face names before. A design's edges are +//! picked on its drawn B-rep edges by their persistent names (`|`, before and after +//! meshing); other edges and vertices have names only on a mesh that carries them (block and +//! planar meshes). use std::collections::{BTreeMap, BTreeSet}; @@ -262,11 +263,13 @@ pub fn items_from_params(p: &Value) -> Result, String> { Ok(items) } -/// A drawn geometry surface's names: its body and, by face tag, its faces. +/// A drawn geometry surface's names: its body and, by face tag, its faces; by edge index, its +/// B-rep edges ([`DisplayMesh::curves`]). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct BodyNames { pub name: String, pub faces: Vec, + pub edges: Vec, } impl BodyNames { @@ -274,6 +277,11 @@ impl BodyNames { fn face(&self, face: u32) -> Option { GeometryRef::face(self.name.clone(), self.faces.get(face as usize)?.clone()).ok() } + + /// The reference to B-rep edge `edge` of this body, when it has a name. + fn edge(&self, edge: u32) -> Option { + GeometryRef::new(self.name.clone(), Entity::Edge(self.edges.get(edge as usize)?.clone())).ok() + } } /// Where the mesh's drawn primitives lie on the geometry: built once per model revision from the @@ -337,6 +345,16 @@ pub struct Picker<'a> { pub elements: bool, /// Geometry surfaces are drawn (and picked). pub geometry: bool, + /// The geometry's B-rep edges are drawn (and picked), over the mesh too. + pub curves: bool, +} + +/// The candidate nearest on screen within `radius` whose point passes `visible`: candidates are +/// (index, screen distance, the point to check); the first of the nearest few that passes wins. +fn nearest_passing(mut near: Vec<(usize, f64, Vec3)>, radius: f64, visible: impl Fn(Vec3) -> bool) -> Option { + near.retain(|c| c.1 <= radius); + near.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0))); + near.into_iter().take(VISIBILITY_CHECKS).find(|c| visible(c.2)).map(|c| c.0) } /// The first drawn face along a ray. @@ -385,18 +403,42 @@ impl Picker<'_> { /// Whether `p` is in front: the ray through its pixel reaches it before any drawn face. fn visible(&self, proj: &Projector, p: Vec3) -> bool { + self.visible_within(proj, p, self.slack()) + } + + /// Whether `p` is in front, counting it on the first face along its ray within `slack`. + fn visible_within(&self, proj: &Projector, p: Vec3, slack: f64) -> bool { let Some([x, y, _]) = proj.screen(p) else { return false }; let (o, d) = proj.ray(x, y); let along = (p - o).dot(d); - self.surface(o, d).is_none_or(|s| along <= s.t() + self.slack()) + self.surface(o, d).is_none_or(|s| along <= s.t() + slack) } - /// The candidate nearest on screen to `at` within `radius` that is visible: candidates are - /// (index, screen distance, the point to check); the first visible of the nearest few wins. - fn nearest_visible(&self, proj: &Projector, mut near: Vec<(usize, f64, Vec3)>, radius: f64) -> Option { - near.retain(|c| c.1 <= radius); - near.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0))); - near.into_iter().take(VISIBILITY_CHECKS).find(|c| self.visible(proj, c.2)).map(|c| c.0) + /// The candidate nearest on screen to `at` within `radius` that is visible ([`nearest_passing`]). + fn nearest_visible(&self, proj: &Projector, near: Vec<(usize, f64, Vec3)>, radius: f64) -> Option { + nearest_passing(near, radius, |p| self.visible(proj, p)) + } + + /// Whether a point of a B-rep edge is in front. The edge's polyline and the drawn faces are + /// two approximations of the same curve, which can lie apart by the tessellation's chord + /// error: a point still counts as on the first face within a hundredth of the model's size. + fn curve_visible(&self, proj: &Projector, p: Vec3) -> bool { + self.visible_within(proj, p, self.slack() * 100.0) + } + + /// The design edge (a [`DisplayMesh::curves`] segment's) nearest `at` on screen, visible. + fn pick_curve(&self, proj: &Projector, at: [f64; 2]) -> Option { + if !self.curves { + return None; + } + let near = self.display.curves.iter().enumerate().filter_map(|(i, c)| { + let (sa, sb) = (proj.screen(c.a)?, proj.screen(c.b)?); + let (distance, k) = segment_distance(at, [sa[0], sa[1]], [sb[0], sb[1]]); + (distance <= EDGE_RADIUS).then(|| (i, distance, c.a + (c.b - c.a) * k)) + }); + let i = nearest_passing(near.collect(), EDGE_RADIUS, |p| self.curve_visible(proj, p))?; + let c = self.display.curves.get(i)?; + self.names.get(c.body as usize)?.edge(c.edge) } /// The geometry face of an element face triangle or a geometry triangle. @@ -467,6 +509,11 @@ impl Picker<'_> { self.entity(*tag).cloned().map(Item::Geometry) } Filter::Edges => { + // A design's B-rep edges first: they are what an edge is named by before meshing, + // and what seeds and sets refer to after. + if let Some(g) = self.pick_curve(proj, at) { + return Some(Item::Geometry(g)); + } // Element edges and line elements on geometry edges. let mut tagged: Vec<(Vec3, Vec3, u32)> = self .display @@ -611,6 +658,23 @@ impl Picker<'_> { } } outline(s, first, color); + if m.edges && self.curves { + // Which edges of each drawn body are marked, worked out once per edge. + let marked: Vec> = self + .names + .iter() + .map(|body| { + (0..body.edges.len()) + .map(|e| body.edge(u32::try_from(e).unwrap_or(u32::MAX)).is_some_and(|g| m.refs.iter().any(|r| r.selects(&g)))) + .collect() + }) + .collect(); + for c in &self.display.curves { + if marked.get(c.body as usize).and_then(|b| b.get(c.edge as usize)).copied().unwrap_or(false) { + s.lines.push(SceneLine { a: c.a, b: c.b, color, width: EDGE_WIDTH }); + } + } + } if m.edges { for e in &self.display.edges { if on(self.index.edge_tag.get(&edge_key(e.nodes[0], e.nodes[1])).copied()) { @@ -845,7 +909,7 @@ mod tests { Scene3 { model, display, index } } fn picker(&self) -> Picker<'_> { - Picker { display: &self.display, names: &[], model: &self.model, index: &self.index, elements: true, geometry: false } + Picker { display: &self.display, names: &[], model: &self.model, index: &self.index, elements: true, geometry: false, curves: false } } } @@ -937,11 +1001,13 @@ mod tests { fn drawn_geometry_names_its_faces() { let mut display = DisplayMesh::default(); let square = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]; + // Its one B-rep edge: the diagonal between the two faces. + display.add_curves(&[vec![square[0], square[2]]]); display.add_surface(&square, &[[0, 1, 2], [0, 2, 3]], &[0, 1]); - let names = [BodyNames { name: "Plate".into(), faces: vec!["F1:a".into(), "F1:b".into()] }]; + let names = [BodyNames { name: "Plate".into(), faces: vec!["F1:a".into(), "F1:b".into()], edges: vec!["F1:a|F1:b".into()] }]; let model = Model::default(); let index = Index::new(&model, &display); - let p = Picker { display: &display, names: &names, model: &model, index: &index, elements: false, geometry: true }; + let p = Picker { display: &display, names: &names, model: &model, index: &index, elements: false, geometry: true, curves: true }; let mut cam = Camera::default(); cam.set_view(StandardView::Top); cam.fit(&display.bounds); @@ -957,6 +1023,17 @@ mod tests { let o = p.overlay(&sel, None, Rgb(9, 9, 9), Rgb(0, 0, 0)); assert_eq!(o.tris.len(), 1); assert_eq!(o.tris[0].color, Rgb(9, 9, 9)); + // The design edge is picked by its persistent name, and drawn wide when selected. + let edge = GeometryRef::new("Plate", Entity::Edge("F1:a|F1:b".into())).unwrap(); + let on = pixel(&proj, Vec3::new(0.5, 0.5, 0.0)); + assert_eq!(p.pick(&proj, on, Filter::Edges), Some(Item::Geometry(edge.clone()))); + assert_eq!(p.pick(&proj, at, Filter::Edges), None); + sel.apply([Item::Geometry(edge)], Mode::Replace); + let o = p.overlay(&sel, None, Rgb(9, 9, 9), Rgb(0, 0, 0)); + assert_eq!((o.tris.len(), o.lines.len()), (0, 1)); + // Without the curves drawn, the edge is not picked. + let hidden = Picker { curves: false, ..p }; + assert_eq!(hidden.pick(&proj, on, Filter::Edges), None); } #[test]