diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 9a32b387..472f7012 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -164,6 +164,8 @@ pub struct MenuState { pub confirm: Option, /// The Properties window: title and measurements. pub props: Option<(String, Value)>, + /// The viewport menu opened over something: it offers Select Other. + pub over_geometry: bool, } #[derive(Clone, Debug, PartialEq)] @@ -346,6 +348,11 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(act("ui.repeat", &format!("Repeat {label}"), "redo")); v.push(Item::sep()); } + // Everything under the cursor, for what lies beneath something else. + if app.menu.over_geometry { + v.push(act("ui.selectOther", "Select Other…", "").key("Hold click")); + v.push(Item::sep()); + } if app.session.active_sketch.is_some() { v.extend(sketch_entity_items(app)); v.push(Item::sep()); @@ -726,6 +733,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { app.start(&id); } } + "ui.selectOther" => { + crate::select_other::open(app, at); + } "ui.delete" if item.params.is_some() => crate::delete::request(app, p), "ui.delete" => delete(app), "ui.finishSketch" => app.finish_sketch(), @@ -1161,6 +1171,8 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) { /// Open the viewport menu at a screen position. A right-click on something not selected /// selects it first, so the menu is about what is under the cursor. pub fn open(app: &mut SolveApp, at: Pos2) { + app.menu.over_geometry = app.viewport.hover.is_some(); + crate::select_other::close(app); if app.session.active_sketch.is_none() && let Some(h) = app.viewport.hover.clone() && let Some((_, sel)) = crate::viewport::candidate(app, &[h]) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9d1c2eb3..7bed43c6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -501,6 +501,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}), ), }, + "ui.selectOther" => wrap(crate::select_other::control(app, p)), "ui.menuPick" => { let Some(key) = s("item") else { return err("missing `item` (id or label)") }; wrap(crate::context_menu::pick(app, key)) diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 78b7aba2..030c80f2 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -165,6 +165,13 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { } }); } + ui.label( + RichText::new( + "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.", + ) + .size(11.0) + .color(t.text_dim), + ); ui.separator(); let q = app.keymap.filter.to_lowercase(); egui::ScrollArea::both().max_height(330.0).auto_shrink([false, true]).show(ui, |ui| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 92196b39..aec926fd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -79,6 +79,7 @@ mod revolve_edit_tests; #[cfg(test)] mod revolve_manipulator_tests; pub mod scenario; +pub mod select_other; pub mod selection; #[cfg(test)] mod shell_face_sketch_ui_tests; diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 9e496b7a..e54e82bf 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -7,6 +7,7 @@ //! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its //! text (with `near`: the one in that label's row) //! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`, +//! `{"hold": AT, "frames"?}` (press and hold still: Select Other), //! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}` //! or `{"sketch": [x,y]}` (the sketch being edited) //! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n, "max_frames"?: n}`: a request sent the way an @@ -378,6 +379,16 @@ impl Harness { let [x, y] = found.ok_or_else(|| format!("no widget `{text}` on screen"))?; self.call("ui.move", json!({"x": x, "y": y})); self.call("ui.click", json!({"x": x, "y": y})) + } else if let Some(at) = s.get("hold") { + // Press and hold still (Select Other): `frames` with the button down (default 40, + // about 0.67 s), then let go. + let [x, y] = self.at(at)?; + self.call("ui.move", json!({"x": x, "y": y})); + let frames = s.get("frames").and_then(Value::as_u64).unwrap_or(40); + self.call( + "ui.drag", + json!({"x0": x, "y0": y, "x1": x, "y1": y, "steps": 1, "hold": frames, "shift": flag("shift"), "ctrl": flag("ctrl")}), + ) } else if let Some(at) = s.get("move") { let [x, y] = self.at(at)?; self.call("ui.move", json!({"x": x, "y": y})) diff --git a/crates/ui-egui/src/select_other.rs b/crates/ui-egui/src/select_other.rs new file mode 100644 index 00000000..0e14c78e --- /dev/null +++ b/crates/ui-egui/src/select_other.rs @@ -0,0 +1,526 @@ +//! 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. +//! - 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; + +/// 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, + /// 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 { + 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() })); + } + } + } + let mut out: Vec<(Hit, Sel)> = Vec::new(); + for (h, s) in found { + if !out.iter().any(|(_, x)| crate::selection::same_item(x, &s)) { + out.push((h, s)); + } + } + let sketching = app.session.active_sketch.is_some(); + out.sort_by_key(|(_, s)| rank(s, sketching)); + out.into_iter() + .map(|(hit, sel)| { + let label = label(app, &sel); + Row { icon: icon(app, &sel), hit, sel, label } + }) + .collect() +} + +/// 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 = rows(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, 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, + "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; + let resp = crate::frame::area(egui::Id::new("sc_select_other")) + .fixed_pos(p.at + vec2(12.0, 12.0)) + .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()); + ui.painter().text( + pos2(r.left() + 8.0, r.center().y), + Align2::LEFT_CENTER, + crate::i18n::tr_label("Select Other").to_uppercase(), + 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; diff --git a/crates/ui-egui/src/select_other_tests.rs b/crates/ui-egui/src/select_other_tests.rs new file mode 100644 index 00000000..b4da736a --- /dev/null +++ b/crates/ui-egui/src/select_other_tests.rs @@ -0,0 +1,172 @@ +//! Select Other: press and hold, the viewport menu's entry, click cycling and dialog inputs. + +use serde_json::{Value, json}; +use solidcraft_engine::Sel; + +use crate::scenario::Harness; + +/// A sketch with two lines drawn on top of each other (l1, then l2), off the origin axes. +fn two_lines() -> (Harness, Value) { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + for _ in 0..2 { + h.app.run("sketch.line", json!({"points":[[0,10],[20,10]],"infer":false,"connect":false})).unwrap(); + } + h.app.cam.distance = 60.0; + h.settle(); + let at = h.call("ui.at", json!({"sketch":[10,10]}))["result"].clone(); + (h, at) +} + +fn labels(h: &Harness) -> Vec { + crate::select_other::describe(&h.app)["rows"].as_array().into_iter().flatten().filter_map(|r| r["label"].as_str().map(String::from)).collect() +} + +fn curves(h: &Harness) -> Vec { + h.app.session.selection.iter().filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }).collect() +} + +#[test] +fn holding_still_lists_both_coincident_lines_and_picks_the_one_underneath() { + let (mut h, at) = two_lines(); + let before = h.app.session.doc.to_json(); + h.step(&json!({"hold": at})).unwrap(); + assert!(crate::select_other::is_open(&h.app), "a held press opens Select Other"); + let rows = labels(&h); + assert!(rows.len() >= 2, "{rows:?}"); + assert_eq!(&rows[..2], ["Line 1", "Line 2"], "the sketch's own curves come first"); + // Letting go of the hold neither selected nor dragged anything. + assert!(h.app.session.selection.is_empty(), "{:?}", h.app.session.selection); + assert_eq!(h.app.session.doc.to_json(), before); + // Hovering a row lights it up in the viewport. + h.step(&json!({"move": {"handle": "select_other:1"}})).unwrap(); + assert!(matches!(&h.app.viewport.hover, Some(crate::viewport::Hit::SketchCurve { id, .. }) if id == "l2"), "{:?}", h.app.viewport.hover); + h.step(&json!({"click": {"handle": "select_other:1"}})).unwrap(); + assert!(!crate::select_other::is_open(&h.app)); + assert_eq!(curves(&h), ["l2"]); + // Shift-picking from the list adds, like a Shift-click. + h.step(&json!({"hold": at})).unwrap(); + h.step(&json!({"click": {"handle": "select_other:0"}, "shift": true})).unwrap(); + let mut got = curves(&h); + got.sort(); + assert_eq!(got, ["l1", "l2"]); +} + +#[test] +fn esc_and_a_click_elsewhere_close_the_list_without_selecting() { + let (mut h, at) = two_lines(); + h.step(&json!({"hold": at})).unwrap(); + assert!(crate::select_other::is_open(&h.app)); + h.step(&json!({"key": "Escape"})).unwrap(); + assert!(!crate::select_other::is_open(&h.app)); + assert!(h.app.session.active_sketch.is_some(), "Esc only closed the list"); + h.step(&json!({"hold": at})).unwrap(); + h.step(&json!({"click": [at[0].as_f64().unwrap() + 200.0, at[1].as_f64().unwrap() + 150.0]})).unwrap(); + assert!(!crate::select_other::is_open(&h.app)); + assert!(h.app.session.selection.is_empty(), "{:?}", h.app.session.selection); +} + +#[test] +fn clicking_again_on_the_same_spot_selects_the_next_thing_under_it() { + let (mut h, at) = two_lines(); + h.step(&json!({"click": at})).unwrap(); + assert_eq!(curves(&h), ["l1"]); + // A deliberate second click (not part of a double-click). + h.frames(30); + h.step(&json!({"click": at})).unwrap(); + assert_eq!(curves(&h), ["l2"]); + h.frames(30); + h.step(&json!({"click": at})).unwrap(); + assert_eq!(curves(&h), ["l1"], "it wraps around"); + // A click somewhere else starts over. + let x = at[0].as_f64().unwrap(); + let y = at[1].as_f64().unwrap(); + h.frames(30); + h.step(&json!({"click": [x + 40.0, y]})).unwrap(); + h.frames(30); + h.step(&json!({"click": at})).unwrap(); + assert_eq!(curves(&h), ["l1"]); +} + +#[test] +fn a_double_click_does_not_cycle() { + let (mut h, at) = two_lines(); + h.step(&json!({"click": at, "double": true})).unwrap(); + // The double-click's chain starts from the line under the cursor. + assert!(curves(&h).contains(&"l1".to_string()), "{:?}", h.app.session.selection); +} + +#[test] +fn the_viewport_menu_offers_select_other() { + let (mut h, at) = two_lines(); + h.step(&json!({"click": at, "button": "right"})).unwrap(); + let menu = h.call("ui.menu", json!({}))["result"].clone(); + let items: Vec<&str> = menu["items"].as_array().into_iter().flatten().filter_map(|i| i["id"].as_str()).collect(); + assert!(items.contains(&"ui.selectOther"), "{items:?}"); + let r = h.call("ui.menuPick", json!({"item": "ui.selectOther"})); + assert_eq!(r["ok"], true, "{r}"); + assert!(labels(&h).starts_with(&["Line 1".to_string(), "Line 2".to_string()]), "{:?}", labels(&h)); + // The control channel picks rows too. + let r = h.call("ui.selectOther", json!({"pick": 1})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(curves(&h), ["l2"]); + // Over empty space there is nothing to offer. + h.step(&json!({"click": [40, 400], "button": "right"})).unwrap(); + let menu = h.call("ui.menu", json!({}))["result"].clone(); + assert!(!menu["items"].as_array().into_iter().flatten().any(|i| i["id"] == "ui.selectOther"), "{menu}"); +} + +#[test] +fn overlapping_bodies_list_both_and_the_hidden_one_can_be_picked() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Inner"})).unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[5,5,5],"body_name":"Outer"})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.step(&json!({"hold": {"world": [7, 7, 15]}})).unwrap(); + let rows = labels(&h); + assert!(rows.iter().any(|r| r == "Body · Outer") && rows.iter().any(|r| r == "Body · Inner"), "{rows:?}"); + let faces = rows.iter().filter(|r| r.starts_with("Face ")).count(); + assert_eq!(faces, 2, "the nearest face of each body: {rows:?}"); + let i = rows.iter().position(|r| r == "Body · Inner").unwrap(); + h.call("ui.selectOther", json!({"pick": i})); + assert_eq!(h.app.session.selection, [Sel::Body { name: "Inner".into() }]); +} + +#[test] +fn a_dialog_selection_input_takes_its_pick_from_the_list() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap(); + h.call("ui.view", json!({"view":"home"})); + h.call("ui.view", json!({"view":"fit"})); + h.step(&json!({"start": "solid.fillet"})).unwrap(); + h.frames(2); + h.step(&json!({"hold": {"world": [5, 0, 10]}})).unwrap(); + let rows = crate::select_other::describe(&h.app)["rows"].clone(); + let sels: Vec<&Value> = rows.as_array().into_iter().flatten().map(|r| &r["sel"]).collect(); + assert!(!sels.is_empty(), "{rows}"); + // The edge in front, then the face behind it; no bodies, which the fillet doesn't take. + assert_eq!(sels[0]["type"], "edge", "{rows}"); + assert!(sels.iter().all(|s| s["type"] == "edge" || s["type"] == "face"), "only what the fillet's input accepts: {rows}"); + h.call("ui.selectOther", json!({"pick": 0})); + let d = h.app.dialog.as_ref().expect("the fillet dialog stays open"); + assert!(d.inputs.iter().any(|i| i.items.iter().any(|x| matches!(x, Sel::Edge { .. }))), "{:?}", d.inputs); + assert!(h.app.session.selection.is_empty(), "the pick went to the dialog, not the selection"); +} + +#[test] +fn rows_name_points_by_their_curves() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[10,10],"radius":3})).unwrap(); + h.app.cam.distance = 60.0; + h.settle(); + let at = h.call("ui.at", json!({"sketch":[10,10]}))["result"].clone(); + h.call("ui.selectOther", json!({"x": at[0], "y": at[1]})); + let rows = labels(&h); + assert!(rows.iter().any(|r| r.starts_with("Point (") && r.contains("centre")), "{rows:?}"); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 768ef994..39763cbd 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1045,7 +1045,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { match k { Key::Escape => { app.esc_handled = true; - if app.tool.is_some() { + if crate::select_other::is_open(app) { + crate::select_other::close(app); + } else if app.tool.is_some() { crate::tools::finish(app); } else if app.dialog.is_some() { crate::dialogs::cancel(app); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index f35736bd..a864060e 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -82,6 +82,8 @@ pub struct ViewportState { pub build_ms: f64, /// The right-click menu, open at this screen position. pub context_menu: Option, + /// Select Other: its list, a press being held, and click cycling. + pub select_other: crate::select_other::State, /// A sketch point or curve being dragged (its id, the undo depth when the drag began: the /// whole drag is one undo step, and for a curve where it was last under the cursor). pub point_drag: Option<(String, usize, Option)>, @@ -720,7 +722,68 @@ pub fn pick_3d(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { pick_with_sketch_points(app, proj, pos, true) } +/// Everything under `pos` (Select Other): like [`pick`], but every sketch point, sketch curve, +/// vertex, edge, axis and profile near the cursor, and the nearest face of every body along the +/// ray, instead of the nearest of each kind. Each kind is nearest first. +pub fn pick_all(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { + pick_hits(app, proj, pos, false, true) +} + fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec { + pick_hits(app, proj, pos, other_sketch_points, false) +} + +/// The same thing picked twice (one row per target in [`pick_all`]). +pub fn same_target(a: &Hit, b: &Hit) -> bool { + match (a, b) { + (Hit::SketchPoint { sketch: s, id: i, .. }, Hit::SketchPoint { sketch: t, id: j, .. }) + | (Hit::SketchCurve { sketch: s, id: i, .. }, Hit::SketchCurve { sketch: t, id: j, .. }) => s == t && i == j, + (Hit::Vertex { body: a, point: p }, Hit::Vertex { body: b, point: q }) => a == b && p.dist(*q) < 1e-9, + (Hit::Edge { body: a, index: i, .. }, Hit::Edge { body: b, index: j, .. }) + | (Hit::Face { body: a, index: i, .. }, Hit::Face { body: b, index: j, .. }) => a == b && i == j, + (Hit::Profile { sketch: s, index: i }, Hit::Profile { sketch: t, index: j }) => s == t && i == j, + (Hit::Plane { name: a, .. }, Hit::Plane { name: b, .. }) | (Hit::Axis { name: a }, Hit::Axis { name: b }) => a == b, + _ => false, + } +} + +/// The candidates of one kind: the nearest only, or with `all` every target (nearest first). +struct Near { + all: bool, + v: Vec<(T, Hit)>, +} + +impl Near { + fn new(all: bool) -> Self { + Near { all, v: Vec::new() } + } + /// Whether a candidate at `d` would be kept. + fn wants(&self, d: T) -> bool { + self.all || self.v.first().is_none_or(|(b, _)| d < *b) + } + fn offer(&mut self, d: T, h: Hit) { + if !self.all { + if self.wants(d) { + self.v = vec![(d, h)]; + } + return; + } + match self.v.iter_mut().find(|(_, x)| same_target(x, &h)) { + Some(e) if d < e.0 => *e = (d, h), + Some(_) => {} + None => self.v.push((d, h)), + } + } + fn sorted(mut self) -> Vec<(T, Hit)> { + self.v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + self.v + } + fn hits(self) -> impl Iterator { + self.sorted().into_iter().map(|x| x.1) + } +} + +fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool) -> Vec { let s = &app.session; let st = s.world_state(); let bounds = body_bounds(app, &st); @@ -733,23 +796,21 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_ let external_points = wants_points || other_sketch_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project")); let point_sketches: Vec = if external_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() }; - let mut best: Option<(f32, Hit)> = None; + let mut best = Near::new(all); for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) { let sid = ss.feature; for p in &ss.sketch.points { if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) { let dd = sp.distance(pos) - if s.active_sketch == Some(sid) { 2.0 } else { 0.0 }; - if dd < 8.0 && best.as_ref().is_none_or(|(b, _)| dd < *b) { - best = Some((dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos })); + if dd < 8.0 && best.wants(dd) { + best.offer(dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos }); } } } } - if let Some((_, h)) = best { - hits.push(h); - } + hits.extend(best.hits()); // Sketch curves (the active sketch wins ties). - let mut bestc: Option<(f32, Hit)> = None; + let mut bestc = Near::new(all); for sid in visible_sketches(app) { let Some(ss) = st.sketch(sid) else { continue }; let bonus = if s.active_sketch == Some(sid) { 2.0 } else { 0.0 }; @@ -762,8 +823,8 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_ let pts: Vec = part.iter().filter_map(|q| proj.to_screen(*q)).collect(); for w in pts.windows(2) { let dd = seg_dist(pos, w[0], w[1]) - bonus; - if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) { - bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight })); + if dd < 6.0 && bestc.wants(dd) { + bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight }); } } } @@ -773,17 +834,16 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_ let pts: Vec = w.pts.iter().filter_map(|q| proj.to_screen(*q)).collect(); for seg in pts.windows(2) { let dd = seg_dist(pos, seg[0], seg[1]) - bonus; - if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) { - bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false })); + if dd < 6.0 && bestc.wants(dd) { + bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false }); } } } } - if let Some((_, h)) = bestc { - hits.push(h); - } - // Nearest face along the ray (hides what is behind it). + hits.extend(bestc.hits()); + // Nearest face along the ray (hides what is behind it); with `all`, every body's nearest. let mut bestf: Option<(f64, Hit)> = None; + let mut faces = Near::new(all); for b in &st.bodies { if !app.session.body_visible(&b.name) { continue; @@ -793,23 +853,27 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_ let pad = bb.diagonal() * 1e-6 + 1e-9; match ray_box(o, d, &bb, pad) { None => continue, - Some(t0) if bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue, + Some(t0) if !all && bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue, _ => {} } } let m = b.mesh(); - if let Some((t, ti)) = m.raycast(o, d) - && bestf.as_ref().is_none_or(|(bt, _)| t < *bt) - { + if let Some((t, ti)) = m.raycast(o, d) { let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize; - bestf = Some((t, Hit::Face { body: b.name.clone(), index: fi, point: o + d * t })); + let hit = Hit::Face { body: b.name.clone(), index: fi, point: o + d * t }; + if bestf.as_ref().is_none_or(|(bt, _)| t < *bt) { + bestf = Some((t, hit.clone())); + } + if all { + faces.offer(t, hit); + } } } let slack = scene_radius(app) * 0.02; let visible = |p: Vec3| bestf.as_ref().is_none_or(|(t, _)| (p - o).dot(d) <= *t + slack); // Vertices (edge end points) and edges. - let mut bestv: Option<(f32, Hit)> = None; - let mut beste: Option<(f32, Hit)> = None; + let mut bestv = Near::new(all); + let mut beste = Near::new(all); for b in &st.bodies { if !app.session.body_visible(&b.name) { continue; @@ -828,35 +892,35 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_ for v in [e.first(), e.last()].into_iter().flatten() { if let Some(sp) = proj.to_screen(*v) { let dd = sp.distance(pos); - if dd < 7.0 && visible(*v) && bestv.as_ref().is_none_or(|(bd, _)| dd < *bd) { - bestv = Some((dd, Hit::Vertex { body: b.name.clone(), point: *v })); + if dd < 7.0 && visible(*v) && bestv.wants(dd) { + bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v }); } } } let pts: Vec = e.iter().filter_map(|q| proj.to_screen(*q)).collect(); for (k, w) in pts.windows(2).enumerate() { let dd = seg_dist(pos, w[0], w[1]); - if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) { + if dd < 6.0 && beste.wants(dd) { let mid = polyline_mid(e); // Visible where the cursor is (not just at the middle). let at = e.get(k).copied().unwrap_or(mid); if visible(at) || visible(mid) { - beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid })); + beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid }); } } } } } - hits.extend(bestv.map(|x| x.1)); - hits.extend(beste.map(|x| x.1)); + hits.extend(bestv.hits()); + hits.extend(beste.hits()); // Origin axes. let size = origin_size(app.cam.half_height()); - let mut besta: Option<(f32, Hit)> = None; + let mut besta = Near::new(all); for (name, dir) in origin_axes(app) { if let (Some(a), Some(b)) = (proj.to_screen(Vec3::ZERO), proj.to_screen(dir * (size * AXIS_LEN))) { let dd = seg_dist(pos, a, b); - if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) { - besta = Some((dd, Hit::Axis { name: name.into() })); + if dd < 5.0 && besta.wants(dd) { + besta.offer(dd, Hit::Axis { name: name.into() }); } } } @@ -864,15 +928,16 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_ for (name, a, b) in construction_axis_segments(app) { if let (Some(a), Some(b)) = (proj.to_screen(a), proj.to_screen(b)) { let dd = seg_dist(pos, a, b); - if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) { - besta = Some((dd, Hit::Axis { name })); + if dd < 5.0 && besta.wants(dd) { + besta.offer(dd, Hit::Axis { name }); } } } - hits.extend(besta.map(|x| x.1)); + hits.extend(besta.hits()); // Surfaces by depth: profiles (slightly preferred over a face they lie on), faces, planes. let mut surf: Vec<(f64, Hit)> = Vec::new(); let mut bestp: Option<(f64, f64, Hit)> = None; + let mut profiles_all: Vec<(f64, f64, Hit)> = Vec::new(); for sid in visible_sketches(app) { let Some(ss) = st.sketch(sid) else { continue }; let n = ss.plane.normal(); @@ -888,15 +953,24 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_ // Show Profile off: the sketch being edited offers no profiles. let profiles = if ss.sketch.view.hide_profile && s.active_sketch == Some(sid) { &[][..] } else { &ss.profiles[..] }; for (pi, p) in profiles.iter().enumerate() { - if p.region.contains(lp) && bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) { + if !p.region.contains(lp) { + continue; + } + if all { + profiles_all.push((t, p.area, Hit::Profile { sketch: sid, index: pi })); + } else if bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) { bestp = Some((t, p.area, Hit::Profile { sketch: sid, index: pi })); } } } - if let Some((t, _, h)) = bestp { + // Smallest profile first. + profiles_all.sort_by(|a, b| a.1.total_cmp(&b.1)); + for (t, _, h) in bestp.into_iter().chain(profiles_all) { surf.push((t - slack, h)); } - if let Some(f) = bestf { + if all { + surf.extend(faces.sorted()); + } else if let Some(f) = bestf { surf.push(f); } // Planes give way to a face or profile at the same depth (a floor on the XZ plane). @@ -1103,27 +1177,57 @@ pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> { if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) { return hits.iter().find_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s))); } + hits.iter().filter(|h| pick_allowed(app, h)).find_map(|h| hit_candidate(app, h)) +} + +/// Every hit a click could pick, in order, each with its selection (Select Other). +pub fn candidates(app: &SolveApp, hits: &[Hit]) -> Vec<(Hit, Sel)> { + if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) { + return hits.iter().filter_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s))).collect(); + } + hits.iter().filter(|h| pick_allowed(app, h)).filter_map(|h| hit_candidate(app, h)).collect() +} + +/// What the selection filter lets a click pick with no dialog. +fn pick_allowed(app: &SolveApp, h: &Hit) -> bool { let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); - hits.iter() - .filter(|h| match h { - Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"), - Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"), - Hit::Profile { .. } => !off("sketch"), - Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies, - Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies, - Hit::Face { .. } => !off("faces") || app.ui.pick_bodies, - Hit::Plane { .. } | Hit::Axis { .. } => true, - }) - .find_map(|h| match h { - Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => { - Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() })) - } - Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => { - Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() })) - } - Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })), - _ => hit_sel(h).map(|s| (h.clone(), s)), - }) + match h { + Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"), + Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"), + Hit::Profile { .. } => !off("sketch"), + Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies, + Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies, + Hit::Face { .. } => !off("faces") || app.ui.pick_bodies, + Hit::Plane { .. } | Hit::Axis { .. } => true, + } +} + +/// The selection a click on `h` makes with no dialog (sketch entities only belong to the active sketch). +fn hit_candidate(app: &SolveApp, h: &Hit) -> Option<(Hit, Sel)> { + match h { + Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => { + Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() })) + } + Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => { + Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() })) + } + Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })), + _ => hit_sel(h).map(|s| (h.clone(), s)), + } +} + +/// A pick for the open dialog's active input (a click, or a Select Other row). +pub(crate) fn dialog_pick(app: &mut SolveApp, hit: &Hit, sel: Sel) { + let Some(mut d) = app.dialog.take() else { return }; + d.pick(&app.session, sel); + if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind { + *sketch = match hit { + Hit::SketchCurve { sketch, .. } => Some(*sketch), + _ => None, + }; + d.extra.remove("path"); + } + app.dialog = Some(d); } /// The edge a selection refers to now: by index if it still passes through the stored point, @@ -1855,6 +1959,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default(); let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) }; app.viewport.hover = cand.as_ref().map(|c| c.0.clone()); + // Select Other: a press held still opens the list of everything under it; while the list is + // open its row under the pointer is what lights up. + let held = + crate::select_other::track_hold(app, ui.ctx(), &proj, inside, primary_pressed, primary_down, hover, primary_nav.is_some() || mode.is_some()); + crate::select_other::hover_override(app); let painter = ui.painter_at(rect); // Background gradient. let (top, bot) = (t.viewport_top, t.viewport_bottom); @@ -1930,22 +2039,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if app.tool.is_some() { crate::tools::on_hover(app, &proj, p); } - if resp.clicked() { + if resp.clicked() && !held { if app.tool.is_some() { crate::tools::on_click(app, &proj, p); } else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) { - if let Some((hit, Some(sel))) = cand - && let Some(mut d) = app.dialog.take() - { - d.pick(&app.session, sel); - if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind { - *sketch = match hit { - Hit::SketchCurve { sketch, .. } => Some(sketch), - _ => None, - }; - d.extra.remove("path"); - } - app.dialog = Some(d); + if let Some((hit, Some(sel))) = cand { + dialog_pick(app, &hit, sel); } } else { // Dimensions and constraint glyphs sit on top of the geometry. @@ -1961,15 +2060,23 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if !crate::sketch3d::click(app, &proj, p) { match on_dim.or(on_glyph) { Some(id) => select(app, Some(Sel::SketchConstraint { id }), add), - None => select(app, cand.and_then(|c| c.1), add), + // Clicking again on the same spot selects the next thing under it (not + // the second click of a double-click, which has its own actions). + None if !add && !resp.double_clicked() && crate::select_other::cycle_click(app, &proj, p) => {} + None => { + let sel = cand.and_then(|c| c.1); + select(app, sel.clone(), add); + crate::select_other::note_click(app, p, if add { None } else { sel }); + } } } } } // Double-click a dimension: edit its value in place. - let dim_edit = resp.double_clicked() && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p); + let dim_edit = resp.double_clicked() && !held && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p); // Double-click a sketch curve: select the chain joined to it end to end. if resp.double_clicked() + && !held && !dim_edit && app.tool.is_none() && app.dialog.is_none() @@ -1983,6 +2090,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } // Double-click an edge: select the edges that continue it smoothly (the loop). if resp.double_clicked() + && !held && !dim_edit && app.tool.is_none() && app.dialog.is_none() @@ -2006,7 +2114,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } } // Dragging a sketch point of the active sketch moves it; the solver keeps the constraints. - if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() { + if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() && !held { let on_plane = |app: &SolveApp, p: Pos2| { let ss = app.session.active_sketch.and_then(|sid| app.session.world_state().sketch(sid).cloned())?; let (o, d) = proj.ray(p); @@ -2118,6 +2226,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } // Box selection: a primary drag on the model when no navigation mode is on. if app.tool.is_none() + && !held && primary_nav.is_none() && mode.is_none() && app.viewport.point_drag.is_none() @@ -2173,6 +2282,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::canvas::show(app, ui, &painter, &proj); crate::sketch_tools::show(app, ui, &painter, &proj); crate::sketch_dims::show(app, ui, &proj); + crate::select_other::show(app, ui.ctx()); axis_view(app, ui, rect); nav_bar(app, ui, rect); // Status chip. @@ -2192,7 +2302,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { /// A click without a command: select the candidate (replacing the selection, or toggling it /// in with Shift/Ctrl); a click on nothing clears. -fn select(app: &mut SolveApp, sel: Option, add: bool) { +pub(crate) fn select(app: &mut SolveApp, sel: Option, add: bool) { match sel { Some(x) => { let items = grouped_selection(&app.session, &x); diff --git a/crates/ui-egui/tests/scenarios/select_other_coincident_lines.json b/crates/ui-egui/tests/scenarios/select_other_coincident_lines.json new file mode 100644 index 00000000..e5db6939 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/select_other_coincident_lines.json @@ -0,0 +1,17 @@ +[ + {"note": "Two lines drawn on top of each other: holding the button still lists both, and the one underneath can be picked."}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"call": "ui.set", "params": {"showOrigin": false}}, + {"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[0, 10], [20, 10]], "infer": false, "connect": false}}}, + {"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[0, 10], [20, 10]], "infer": false, "connect": false}}}, + {"hold": {"sketch": [10, 10]}}, + {"expect": {"selection": 0, "sketching": true}}, + {"move": {"handle": "select_other:1"}}, + {"click": {"handle": "select_other:1"}}, + {"expect": {"selection": 1}}, + {"call": "ui.selectOther", "params": {"pick": 0}, "fail": true}, + {"note": "Click cycling: a second deliberate click on the same spot selects the next line."}, + {"click": {"sketch": [10, 10]}}, + {"expect": {"selection": 1}} +] diff --git a/docs/book/src/guide/shortcuts.md b/docs/book/src/guide/shortcuts.md index 52b29d3d..3fb55126 100644 --- a/docs/book/src/guide/shortcuts.md +++ b/docs/book/src/guide/shortcuts.md @@ -26,4 +26,13 @@ Keys handled by the window itself: | F6 | fit the view to the model | | Esc | cancel the running command, or clear the selection | +Mouse and touch: + +| Action | Result | +|---|---| +| Click | select (Shift or Ctrl adds) | +| Click again on the same spot | select the next thing under the cursor (click cycling) | +| Hold the left button still (long-press on touch), or right-click › Select Other… | list everything under the cursor to pick from | +| Double-click a sketch curve or edge | select its chain or tangent loop | + Help › Keyboard Shortcuts in the app lists the current bindings, including any you changed. diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index f09cfa53..a70a31e9 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -14,7 +14,14 @@ origin, bodies, sketches, construction planes, canvases, joints and components. The eye shows or hides an item, a component's radio button makes it the active component, and right-clicking any row gives that item's menu. Rows drag into groups and other components. -- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The +- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. To + reach something under something else (two lines drawn on top of each other, coincident points, + an edge in front of a face, overlapping bodies), hold the left button still for half a second + (long-press on a touch screen) or right-click › **Select Other…**: a list of everything under + the cursor opens there, the sketch being edited first. Hovering a row lights it up; clicking it + selects it (Shift or Ctrl adds), and in a command's dialog it goes to the active selection box. + Esc or a click elsewhere closes the list. Clicking again on the same spot, with nothing changed + in between, selects the next thing in that list. The wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse < preset in Preferences switches to SolidWorks, Inventor, Alias or Tinkercad style). On a trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c9a4cd99..3354d63c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,6 +56,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) | | `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled) | | `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does | +| `ui.selectOther` | `x?`, `y?`, `pick?`, `add?`, `hover?`, `close?` | Select Other: with a point, list everything pickable there (as holding the left button does); `pick`: select row `pick` (`add`: as Shift-click; with a dialog open, into its active input); `hover`: light a row up; without: the open list (`open`, `at`, `rows`: label, icon, sel) | | `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened | | `ui.selection` | | the selection, the open dialog's inputs, the hovered item, and the active sketch's drawn dimensions (`dimensions`: id and text centre, `dimension_selected`, `dimension_editing`) | | `ui.worldToScreen` | `points`: `[[x, y, z]…]` world points | their screen points |