feat(ui): Select Other — pick what lies under something else (#503)

Hold the left button still (long-press on touch) or right-click › Select Other… to list everything under the cursor: sketch points and curves ("Line 3", "Point (arc 2 centre)"), vertices, edges, faces, bodies, profiles, planes and axes, filtered like a click and by the open dialog's active input. Hovering a row lights it up; clicking selects it (Shift/Ctrl add) or feeds the dialog. A repeat click on the same spot with nothing changed selects the next thing there; a double-click keeps its own actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-12 22:56:57 -04:00
1 parent 1fd4a2f782
commit 4aaa5457b3
13 files changed
+952 -76

No files matched your search

+12
View File
@@ -164,6 +164,8 @@ pub struct MenuState {
pub confirm: Option<crate::delete::Pending>,
/// 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<Item> {
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])
+1
View File
@@ -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))
+7
View File
@@ -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| {
+1
View File
@@ -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;
+11
View File
@@ -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}))
+526
View File
@@ -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<Row>,
/// The row under the pointer (lit in the viewport).
pub hovered: Option<usize>,
/// 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<Sel>,
key: u64,
}
#[derive(Default)]
pub struct State {
pub popup: Option<Popup>,
/// 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<Cycle>,
}
// ---------------------------------------------------------------------------------------------
// 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<Row> {
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<u64> {
let digits: String = id.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().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<u64>, 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<Proj> {
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<Row, String> {
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::<Vec<_>>(),
}),
}
}
/// 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<Value, String> {
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<Pos2>,
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<Sel>) {
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<Sel>) {
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;
+172
View File
@@ -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<String> {
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<String> {
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:?}");
}
+3 -1
View File
@@ -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);
+184 -74
View File
@@ -82,6 +82,8 @@ pub struct ViewportState {
pub build_ms: f64,
/// The right-click menu, open at this screen position.
pub context_menu: Option<Pos2>,
/// 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<Vec2>)>,
@@ -720,7 +722,68 @@ pub fn pick_3d(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
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<Hit> {
pick_hits(app, proj, pos, false, true)
}
fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec<Hit> {
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<T> {
all: bool,
v: Vec<(T, Hit)>,
}
impl<T: PartialOrd + Copy> Near<T> {
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<Item = Hit> {
self.sorted().into_iter().map(|x| x.1)
}
}
fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool) -> Vec<Hit> {
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<u64> = 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<Pos2> = 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<Pos2> = 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<Pos2> = 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<Sel>, add: bool) {
pub(crate) fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
match sel {
Some(x) => {
let items = grouped_selection(&app.session, &x);
@@ -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}}
]
+9
View File
@@ -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.
+8 -1
View File
@@ -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;
+1
View File
@@ -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 |