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 <[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-14 20:46:05 -04:00
1 parent ffb71de6e1
commit 94c98e0c0b
5 files changed
+139 -19

No files matched your search

+4 -2
View File
@@ -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] {
+31
View File
@@ -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<SurfaceTri>,
/// 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<SurfaceCurve>,
/// 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<_>>(), 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);
+1 -1
View File
@@ -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,
+12 -2
View File
@@ -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)
}
+91 -14
View File
@@ -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 (`<face>|<face>`, 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<Vec<Item>, 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<String>,
pub edges: Vec<String>,
}
impl BodyNames {
@@ -274,6 +277,11 @@ impl BodyNames {
fn face(&self, face: u32) -> Option<GeometryRef> {
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> {
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<usize> {
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<usize> {
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<usize> {
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<GeometryRef> {
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<Vec<bool>> = 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]