//! Select Other: everything under the cursor, for picking what lies under something else (two //! lines drawn on top of each other, coincident points, an edge in front of a face, overlapping //! bodies). //! //! - Holding the left button still (or a finger, on touch screens) for [`HOLD_SECS`], or the //! viewport menu's "Select Other…", opens a list at the cursor of every pickable thing there, //! in the selection filter's and the open dialog's terms: sketch points and curves first, then //! vertices, edges, faces, bodies, profiles, planes and axes, each kind nearest first. On a //! dense mesh (a modelled thread, an imported mesh) thousands of facets can share one cursor, //! so at most [`MAX_PER_KIND`] rows are kept per kind, nearest first, and the list reports how //! many things there were in all. //! - Hovering a row lights its entity up in the viewport; clicking it selects it (Shift/Ctrl add, //! as clicks do) or, with a dialog open, gives it to the dialog's active selection input. //! Esc or a click elsewhere closes the list. //! - Clicking again on the same spot with nothing changed selects the next thing in that list //! (click cycling). The second click of a double-click keeps its own actions. //! //! The control channel's `ui.selectOther` opens, describes and picks from the list. use egui::{Align2, FontId, Pos2, Rect, Sense, pos2, vec2}; use serde_json::{Value, json}; use solidcraft_engine::Sel; use solidcraft_engine::sketch::CurveKind; use std::hash::{Hash, Hasher}; use crate::SolveApp; use crate::theme::Tokens; use crate::viewport::{Hit, Proj}; /// How long the left button stays down, without moving, before the list opens. pub const HOLD_SECS: f64 = 0.5; /// How far (screen points) a repeat click may be from the last one and still cycle. const CYCLE_SLOP: f32 = 4.0; /// The most rows kept per kind of thing (vertices, edges, faces, …). The list exists to tell a /// handful of overlapping things apart; nearest first, farther ones of the same kind are dropped /// so it stays readable and bounded. pub(crate) const MAX_PER_KIND: usize = 20; /// One row of the list. #[derive(Clone, Debug)] pub struct Row { pub hit: Hit, pub sel: Sel, pub label: String, pub icon: &'static str, } /// The open list. #[derive(Clone, Debug)] pub struct Popup { pub at: Pos2, pub rows: Vec, /// How many things were under the cursor in all (the rows are capped per kind). pub total: usize, /// The row under the pointer (lit in the viewport). pub hovered: Option, /// The model and dialog it was made for: it closes when they change. revision: u64, dialog: bool, /// The browser's body highlight is ours to clear. lit_body: bool, } /// The last plain click, for cycling. #[derive(Clone, Debug)] struct Cycle { at: Pos2, chosen: Sel, selection: Vec, key: u64, } #[derive(Default)] pub struct State { pub popup: Option, /// A left press that may become a hold: where and when. hold: Option<(Pos2, f64)>, /// The current press belongs to Select Other (it opened or closed the list): the viewport /// doesn't also click, drag or box-select with it. held: bool, cycle: Option, } // --------------------------------------------------------------------------------------------- // What is under the cursor // --------------------------------------------------------------------------------------------- /// Every pickable thing under `pos`, ordered by kind (the sketch being edited first), then depth. pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { rows_capped(app, proj, pos).0 } /// [`rows`], with how many things were under the cursor in all. fn rows_capped(app: &SolveApp, proj: &Proj, pos: Pos2) -> (Vec, usize) { let hits = crate::viewport::pick_all(app, proj, pos); let mut found = crate::viewport::candidates(app, &hits); let dialog = app.dialog.as_ref().is_some_and(|d| d.wants_picks()); // A body behind each face (body picking already turns faces into bodies). if !dialog && !app.ui.pick_bodies { for h in &hits { if let Hit::Face { body, .. } = h { found.push((h.clone(), Sel::Body { name: body.clone() })); } } } // A face place a sketch point snapped (a Single hole's) is its own row: beside the face as // picked, and beside other points on that face. let snapped = |h: &Hit| matches!(h, Hit::SketchPoint { .. }); let mut out: Vec<(Hit, Sel)> = Vec::new(); for (h, s) in found { if !out.iter().any(|(hx, x)| if snapped(hx) || snapped(&h) { *x == s } else { crate::selection::same_item(x, &s) }) { out.push((h, s)); } } let sketching = app.session.active_sketch.is_some(); out.sort_by_key(|(h, s)| rank(&shown_as(h, s), sketching)); let total = out.len(); // Nearest first within a kind, so capping drops the farther ones. let mut per_kind: std::collections::HashMap = std::collections::HashMap::new(); let rows: Vec = out .into_iter() .filter(|(h, s)| { let n = per_kind.entry(rank(&shown_as(h, s), sketching).1).or_default(); if *n >= MAX_PER_KIND { return false; } *n += 1; true }) .map(|(hit, sel)| { let shown = shown_as(&hit, &sel); let label = label(app, &shown); Row { icon: icon(app, &shown), hit, sel, label } }) .collect(); (rows, total) } /// What a row shows itself as: a face place a sketch point snapped is that point (ranked, capped /// and named with the points). fn shown_as(hit: &Hit, sel: &Sel) -> Sel { match (hit, sel) { (Hit::SketchPoint { sketch, id, .. }, Sel::Face { .. }) => Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() }, _ => sel.clone(), } } /// Sort key: in a sketch its own points and curves come first; then by kind. fn rank(s: &Sel, sketching: bool) -> (u8, u8) { let kind = match s { Sel::SketchPoint { .. } | Sel::ExternalSketchPoint { .. } => 0, Sel::SketchCurve { .. } | Sel::ExternalSketchCurve { .. } => 1, Sel::SketchConstraint { .. } => 2, Sel::Vertex { .. } => 3, Sel::Edge { .. } => 4, Sel::Face { .. } | Sel::Feature { .. } => 5, Sel::Body { .. } | Sel::Occurrence { .. } => 6, Sel::Profile { .. } => 7, Sel::Plane { .. } => 8, Sel::Axis { .. } => 9, }; let own = matches!(s, Sel::SketchPoint { .. } | Sel::SketchCurve { .. }); (if sketching && !own { 1 } else { 0 }, kind) } /// The number at the end of an id (`l3` → 3). fn id_number(id: &str) -> Option { let digits: String = id.chars().rev().take_while(char::is_ascii_digit).collect::>().into_iter().rev().collect(); digits.parse().ok() } fn curve_kind_name(k: &CurveKind) -> &'static str { match k { CurveKind::Line { .. } => "Line", CurveKind::Circle { .. } => "Circle", CurveKind::Arc { .. } => "Arc", CurveKind::Ellipse { .. } => "Ellipse", CurveKind::Spline { .. } => "Spline", CurveKind::Conic { .. } => "Conic", } } fn sketch_name(app: &SolveApp, sketch: u64) -> String { app.session.doc.feature(sketch).map(|f| f.name.clone()).unwrap_or_else(|| format!("Sketch {sketch}")) } /// "Line 3" (its kind and number), with what kind of geometry it is when that matters. fn curve_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String { if let Some((i, c)) = sk.curves.iter().enumerate().find(|(_, c)| c.id == id) { let kind = curve_kind_name(&c.kind); let n = id_number(id).unwrap_or_else(|| sk.curves.iter().take(i + 1).filter(|x| curve_kind_name(&x.kind) == kind).count() as u64); let mut s = format!("{kind} {n}"); if c.link.is_some() { s.push_str(" (projected)"); } else if c.construction { s.push_str(" (construction)"); } return s; } if sk.wires.iter().any(|w| w.id == id) { return format!("3D Curve {}", id_number(id).unwrap_or(0)); } id.to_string() } /// "Point (arc 2 centre)": the role it plays in the first curve that uses it. fn point_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String { let Some(pi) = sk.points.iter().position(|p| p.id == id) else { return id.to_string() }; let role = sk.curves.iter().find_map(|c| { let name = format!("{} {}", curve_kind_name(&c.kind).to_lowercase(), id_number(&c.id).unwrap_or(0)); let (start, end) = if c.reversed { ("end", "start") } else { ("start", "end") }; let role = match &c.kind { CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } | CurveKind::Conic { a, b, .. } if *a == pi => Some(start.to_string()), CurveKind::Line { b, .. } | CurveKind::Arc { b, .. } | CurveKind::Conic { b, .. } if *b == pi => Some(end.to_string()), CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } | CurveKind::Ellipse { c, .. } if *c == pi => Some("centre".to_string()), CurveKind::Ellipse { m, .. } if *m == pi => Some("major axis end".to_string()), CurveKind::Conic { apex, .. } if *apex == pi => Some("apex".to_string()), CurveKind::Spline { pts, .. } => pts.iter().position(|p| *p == pi).map(|k| format!("point {}", k + 1)), _ => None, }?; Some(format!("{name} {role}")) }); let fixed = if sk.point_locked(pi) { ", fixed" } else { "" }; match role { Some(r) => format!("Point ({r}{fixed})"), None if fixed.is_empty() => format!("Point {}", id_number(id).unwrap_or(pi as u64 + 1)), None => format!("Point {} (fixed)", id_number(id).unwrap_or(pi as u64 + 1)), } } /// A row's name: "Line 3", "Point (arc 2 centre)", "Edge 4 · Body1", "XY Plane"… pub fn label(app: &SolveApp, sel: &Sel) -> String { let st = app.session.world_state(); let in_sketch = |sketch: u64, f: &dyn Fn(&solidcraft_engine::sketch::Sketch) -> String| st.sketch(sketch).map(|ss| f(&ss.sketch)).unwrap_or_default(); match sel { Sel::SketchPoint { id } => app.session.active_sketch.map(|s| in_sketch(s, &|sk| point_label(sk, id))).unwrap_or_else(|| id.clone()), Sel::SketchCurve { id } => app.session.active_sketch.map(|s| in_sketch(s, &|sk| curve_label(sk, id))).unwrap_or_else(|| id.clone()), Sel::ExternalSketchPoint { sketch, id } => format!("{} · {}", in_sketch(*sketch, &|sk| point_label(sk, id)), sketch_name(app, *sketch)), Sel::ExternalSketchCurve { sketch, id } => format!("{} · {}", in_sketch(*sketch, &|sk| curve_label(sk, id)), sketch_name(app, *sketch)), Sel::SketchConstraint { id } => format!("Constraint {id}"), Sel::Vertex { body, .. } => format!("Vertex · {body}"), Sel::Edge { body, index, .. } => format!("Edge {} · {body}", index + 1), Sel::Face { body, index, .. } => format!("Face {} · {body}", index + 1), Sel::Body { name } => format!("Body · {name}"), Sel::Occurrence { path } => format!("Occurrence {path:?}"), Sel::Feature { id } => app.session.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| format!("Feature {id}")), Sel::Profile { sketch, index } => format!("Profile {} · {}", index + 1, sketch_name(app, *sketch)), Sel::Plane { name } if matches!(name.as_str(), "XY" | "XZ" | "YZ") => format!("{name} Plane"), Sel::Axis { name } if matches!(name.as_str(), "X" | "Y" | "Z") => format!("{name} Axis"), Sel::Plane { name } | Sel::Axis { name } => name.clone(), } } fn icon(app: &SolveApp, sel: &Sel) -> &'static str { let st = app.session.world_state(); let curve = |sketch: Option, id: &str| { let kind = sketch.and_then(|s| st.sketch(s)).and_then(|ss| ss.sketch.curves.iter().find(|c| c.id == id).map(|c| c.kind.clone())); match kind { Some(CurveKind::Circle { .. }) => "circle", Some(CurveKind::Arc { .. }) => "arc_tangent", Some(CurveKind::Ellipse { .. }) => "ellipse", Some(CurveKind::Spline { .. }) => "spline", Some(CurveKind::Conic { .. }) => "conic", _ => "line", } }; match sel { Sel::SketchPoint { .. } | Sel::ExternalSketchPoint { .. } | Sel::Vertex { .. } => "point", Sel::SketchCurve { id } => curve(app.session.active_sketch, id), Sel::ExternalSketchCurve { sketch, id } => curve(Some(*sketch), id), Sel::SketchConstraint { .. } => "dimension", Sel::Edge { .. } => "fillet", Sel::Face { .. } | Sel::Feature { .. } => "presspull", Sel::Body { .. } => "body", Sel::Occurrence { .. } => "component", Sel::Profile { .. } => "sketch", Sel::Plane { .. } => "plane", Sel::Axis { .. } => "axis", } } // --------------------------------------------------------------------------------------------- // Opening, picking, closing // --------------------------------------------------------------------------------------------- fn projection_now(app: &SolveApp) -> Option { app.viewport.rect.map(|r| crate::viewport::projection(app, r)) } /// Open the list at a screen point; false (with a status message) when nothing is there. pub fn open(app: &mut SolveApp, at: Pos2) -> bool { let Some(proj) = projection_now(app) else { return false }; open_with(app, &proj, at) } fn open_with(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool { let (rows, total) = rows_capped(app, proj, at); if rows.is_empty() { app.status = Some(("Nothing to select here".into(), crate::now_ms(), false)); return false; } close(app); app.viewport.select_other.popup = Some(Popup { at, rows, total, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false }); true } pub fn close(app: &mut SolveApp) { if let Some(p) = app.viewport.select_other.popup.take() && p.lit_body { app.viewport.hover_bodies.clear(); } } pub fn is_open(app: &SolveApp) -> bool { app.viewport.select_other.popup.is_some() } /// Pick row `i` of the open list (`add`: Shift/Ctrl, add to the selection) and close it. pub fn choose(app: &mut SolveApp, i: usize, add: bool) -> Result { let Some(p) = app.viewport.select_other.popup.as_ref() else { return Err("Select Other is not open".into()) }; let at = p.at; let row = p.rows.get(i).cloned().ok_or_else(|| format!("no row {i} (the list has {})", p.rows.len()))?; close(app); apply(app, &row, add); remember(app, at, if add { None } else { Some(row.sel.clone()) }); Ok(row) } /// Select a row's entity: into the dialog's active input, or as a click does. fn apply(app: &mut SolveApp, row: &Row, add: bool) { if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) { crate::viewport::dialog_pick(app, &row.hit, row.sel.clone()); } else { crate::viewport::select(app, Some(row.sel.clone()), add); } } /// The open list as JSON (for `ui.selectOther`). pub fn describe(app: &SolveApp) -> Value { match &app.viewport.select_other.popup { None => json!({ "open": false }), Some(p) => json!({ "open": true, "at": [p.at.x, p.at.y], "hovered": p.hovered, "total": p.total, "rows": p.rows.iter().map(|r| json!({ "label": r.label, "icon": r.icon, "sel": r.sel })).collect::>(), }), } } /// The control channel's `ui.selectOther`: `{x, y}` opens the list there, `{pick: i, add?}` /// picks a row, `{hover: i}` lights one up, `{close: true}` closes it; it answers with the list. pub fn control(app: &mut SolveApp, p: &Value) -> Result { if p.get("close").and_then(Value::as_bool) == Some(true) { close(app); return Ok(describe(app)); } if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { if !(x.is_finite() && y.is_finite()) { return Err("x and y must be numbers".into()); } if app.viewport.rect.is_none() { return Err("no viewport".into()); } open(app, pos2(x as f32, y as f32)); return Ok(describe(app)); } if let Some(i) = p.get("hover").and_then(Value::as_u64) { let Some(pop) = app.viewport.select_other.popup.as_mut() else { return Err("Select Other is not open".into()) }; pop.hovered = Some(i as usize).filter(|i| *i < pop.rows.len()); hover_override(app); return Ok(describe(app)); } if let Some(i) = p.get("pick").and_then(Value::as_u64) { let row = choose(app, i as usize, p.get("add").and_then(Value::as_bool).unwrap_or(false))?; return Ok(json!({ "picked": row.label, "sel": row.sel, "selection": app.session.selection })); } Ok(describe(app)) } // --------------------------------------------------------------------------------------------- // Press and hold, and click cycling (called by the viewport) // --------------------------------------------------------------------------------------------- /// Follow a left press held still in the viewport; open the list when it has been held long /// enough. True while the press belongs to Select Other (the viewport must not also click, /// drag or box-select with it). #[allow(clippy::too_many_arguments)] pub fn track_hold( app: &mut SolveApp, ctx: &egui::Context, proj: &Proj, inside: bool, pressed: bool, down: bool, hover: Option, navigating: bool, ) -> bool { let consumed = app.viewport.select_other.held; let now = ctx.input(|i| i.time); let free = app.tool.is_none() && app.viewport.context_menu.is_none() && !is_open(app) && app.dialog.as_ref().is_none_or(|d| d.wants_picks()) && app.viewport.point_drag.is_none(); if pressed && inside && free && !navigating { app.viewport.select_other.hold = hover.map(|p| (p, now)); } let mut opened = false; if let Some((p0, t0)) = app.viewport.select_other.hold { let slop = ctx.options(|o| o.input_options.max_click_dist); if !down || navigating || !free || hover.is_some_and(|p| p.distance(p0) > slop) { app.viewport.select_other.hold = None; } else if now - t0 >= HOLD_SECS { app.viewport.select_other.hold = None; opened = open_with(app, proj, p0); app.viewport.select_other.held = true; } else { ctx.request_repaint_after(std::time::Duration::from_millis(30)); } } if !down { app.viewport.select_other.held = false; } consumed || opened } /// While the list is open, the hovered row is what the viewport lights up. pub fn hover_override(app: &mut SolveApp) { let Some(p) = app.viewport.select_other.popup.as_mut() else { return }; let row = p.hovered.and_then(|i| p.rows.get(i)); let body = match row.map(|r| &r.sel) { Some(Sel::Body { name }) => Some(name.clone()), _ => None, }; app.viewport.hover = if body.is_some() { None } else { row.map(|r| r.hit.clone()) }; match body { Some(b) => { app.viewport.hover_bodies = vec![b]; p.lit_body = true; } None if p.lit_body => { app.viewport.hover_bodies.clear(); p.lit_body = false; } None => {} } } /// What a repeat click must find unchanged: the model, the view, the sketch and the filter. fn cycle_key(app: &SolveApp) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); app.session.revision.hash(&mut h); for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] { v.to_bits().hash(&mut h); } app.session.active_sketch.hash(&mut h); app.ui.pick_off.hash(&mut h); app.ui.pick_bodies.hash(&mut h); app.dialog.is_some().hash(&mut h); h.finish() } fn remember(app: &mut SolveApp, at: Pos2, chosen: Option) { app.viewport.select_other.cycle = chosen.map(|chosen| Cycle { at, chosen, selection: app.session.selection.clone(), key: cycle_key(app) }); } /// A plain click selected `sel` at `at`: a repeat click there may cycle from it. pub fn note_click(app: &mut SolveApp, at: Pos2, sel: Option) { remember(app, at, sel); } /// A plain click at `at`: when it repeats the last one (same spot, nothing changed since), select /// the next thing under the cursor and return true. pub fn cycle_click(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool { let Some(c) = app.viewport.select_other.cycle.clone() else { return false }; if c.at.distance(at) > CYCLE_SLOP || c.key != cycle_key(app) || c.selection != app.session.selection { return false; } let rows = rows(app, proj, at); if rows.len() < 2 { return false; } let next = rows.iter().position(|r| crate::selection::same_item(&r.sel, &c.chosen)).map_or(0, |k| (k + 1) % rows.len()); let Some(row) = rows.get(next).cloned() else { return false }; apply(app, &row, false); app.status = Some((format!("{} ({} of {})", row.label, next + 1, rows.len()), crate::now_ms(), false)); remember(app, c.at, Some(row.sel)); true } // --------------------------------------------------------------------------------------------- // Drawing // --------------------------------------------------------------------------------------------- /// Draw the open list; handle its hover, clicks, and a press elsewhere (which closes it). pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let Some(p) = app.viewport.select_other.popup.clone() else { return }; if p.revision != app.session.revision || p.dialog != app.dialog.is_some() || app.tool.is_some() { close(app); return; } let t = Tokens::get(); let mut hovered = None; let mut picked = None; let w = crate::context_menu::LIST_W - 10.0; // Title, rows up to the scroll height, margins and border. let size = vec2(w + 10.0, 30.0 + (p.rows.len() as f32 * 24.0).min(360.0)); let resp = crate::frame::area(egui::Id::new("sc_select_other")) .fixed_pos(crate::context_menu::place(p.at, vec2(12.0, 12.0), size, ctx.content_rect())) .constrain(true) .order(egui::Order::Foreground) .show(ctx, |ui| { crate::context_menu::menu_frame(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover()); let title = crate::i18n::tr_label("Select Other").to_uppercase(); let title = if p.total > p.rows.len() { format!("{title} · {} / {}", p.rows.len(), p.total) } else { title }; ui.painter().text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, title, FontId::proportional(10.5), t.text_dim); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { for (i, row) in p.rows.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); crate::scenario::publish_handle(&format!("select_other:{i}"), r.center()); if resp.hovered() { hovered = Some(i); ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), 4.0, t.hover); } crate::icons::paint( ui.painter(), Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(16.0, 16.0)), row.icon, t.icon, t.icon_fill, t.accent, ); ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &row.label, FontId::proportional(13.0), t.text); if resp.clicked() { picked = Some(i); } } }); }); }); if let Some(pop) = app.viewport.select_other.popup.as_mut() && pop.hovered != hovered { pop.hovered = hovered; ctx.request_repaint(); } let (pressed, pointer, mods) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.interact_pos(), i.modifiers)); if let Some(i) = picked { let _ = choose(app, i, mods.shift || mods.ctrl || mods.command); } else if pressed && !pointer.is_some_and(|q| resp.response.rect.contains(q)) { // A press elsewhere only closes the list. close(app); app.viewport.select_other.held = true; } } #[cfg(test)] #[path = "select_other_tests.rs"] mod tests;