fix(ui): Select Other keeps its list readable on dense meshes (#636)

Select Other keeps at most 20 rows per kind, nearest first, so a dense spot (a modelled thread, an imported mesh) no longer lists thousands of rows. The title shows how many were under the cursor in all, and ui.selectOther reports it as total.
This commit is contained in:
Alan16888 authored and GitHub committed 2026-10-13 06:41:18 -04:00
1 parent 46e21127dd
commit 9f6fb735dd
3 files changed
+61 -13

No files matched your search

+36 -12
View File
@@ -5,7 +5,10 @@
//! - Holding the left button still (or a finger, on touch screens) for [`HOLD_SECS`], or the //! - 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, //! 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 //! 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. //! vertices, edges, faces, bodies, profiles, planes and axes, each kind nearest first. On a
//! dense mesh (a modelled thread, an imported mesh) thousands of facets can share one cursor,
//! so at most [`MAX_PER_KIND`] rows are kept per kind, nearest first, and the list reports how
//! many things there were in all.
//! - Hovering a row lights its entity up in the viewport; clicking it selects it (Shift/Ctrl add, //! - 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. //! 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. //! Esc or a click elsewhere closes the list.
@@ -28,6 +31,10 @@ use crate::viewport::{Hit, Proj};
pub const HOLD_SECS: f64 = 0.5; pub const HOLD_SECS: f64 = 0.5;
/// How far (screen points) a repeat click may be from the last one and still cycle. /// How far (screen points) a repeat click may be from the last one and still cycle.
const CYCLE_SLOP: f32 = 4.0; const CYCLE_SLOP: f32 = 4.0;
/// The most rows kept per kind of thing (vertices, edges, faces, …). The list exists to tell a
/// handful of overlapping things apart; nearest first, farther ones of the same kind are dropped
/// so it stays readable and bounded.
const MAX_PER_KIND: usize = 20;
/// One row of the list. /// One row of the list.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -43,6 +50,8 @@ pub struct Row {
pub struct Popup { pub struct Popup {
pub at: Pos2, pub at: Pos2,
pub rows: Vec<Row>, pub rows: Vec<Row>,
/// How many things were under the cursor in all (the rows are capped per kind).
pub total: usize,
/// The row under the pointer (lit in the viewport). /// The row under the pointer (lit in the viewport).
pub hovered: Option<usize>, pub hovered: Option<usize>,
/// The model and dialog it was made for: it closes when they change. /// The model and dialog it was made for: it closes when they change.
@@ -78,6 +87,11 @@ pub struct State {
/// Every pickable thing under `pos`, ordered by kind (the sketch being edited first), then depth. /// 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> { pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Row> {
rows_capped(app, proj, pos).0
}
/// [`rows`], with how many things were under the cursor in all.
fn rows_capped(app: &SolveApp, proj: &Proj, pos: Pos2) -> (Vec<Row>, usize) {
let hits = crate::viewport::pick_all(app, proj, pos); let hits = crate::viewport::pick_all(app, proj, pos);
let mut found = crate::viewport::candidates(app, &hits); let mut found = crate::viewport::candidates(app, &hits);
let dialog = app.dialog.as_ref().is_some_and(|d| d.wants_picks()); let dialog = app.dialog.as_ref().is_some_and(|d| d.wants_picks());
@@ -97,12 +111,25 @@ pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Row> {
} }
let sketching = app.session.active_sketch.is_some(); let sketching = app.session.active_sketch.is_some();
out.sort_by_key(|(_, s)| rank(s, sketching)); out.sort_by_key(|(_, s)| rank(s, sketching));
out.into_iter() let total = out.len();
// Nearest first within a kind, so capping drops the farther ones.
let mut per_kind: std::collections::HashMap<u8, usize> = std::collections::HashMap::new();
let rows: Vec<Row> = out
.into_iter()
.filter(|(_, s)| {
let n = per_kind.entry(rank(s, sketching).1).or_default();
if *n >= MAX_PER_KIND {
return false;
}
*n += 1;
true
})
.map(|(hit, sel)| { .map(|(hit, sel)| {
let label = label(app, &sel); let label = label(app, &sel);
Row { icon: icon(app, &sel), hit, sel, label } Row { icon: icon(app, &sel), hit, sel, label }
}) })
.collect() .collect();
(rows, total)
} }
/// Sort key: in a sketch its own points and curves come first; then by kind. /// Sort key: in a sketch its own points and curves come first; then by kind.
@@ -255,14 +282,14 @@ pub fn open(app: &mut SolveApp, at: Pos2) -> bool {
} }
fn open_with(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool { fn open_with(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
let rows = rows(app, proj, at); let (rows, total) = rows_capped(app, proj, at);
if rows.is_empty() { if rows.is_empty() {
app.status = Some(("Nothing to select here".into(), crate::now_ms(), false)); app.status = Some(("Nothing to select here".into(), crate::now_ms(), false));
return false; return false;
} }
close(app); close(app);
app.viewport.select_other.popup = app.viewport.select_other.popup =
Some(Popup { at, rows, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false }); Some(Popup { at, rows, total, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false });
true true
} }
@@ -306,6 +333,7 @@ pub fn describe(app: &SolveApp) -> Value {
"open": true, "open": true,
"at": [p.at.x, p.at.y], "at": [p.at.x, p.at.y],
"hovered": p.hovered, "hovered": p.hovered,
"total": p.total,
"rows": p.rows.iter().map(|r| json!({ "label": r.label, "icon": r.icon, "sel": r.sel })).collect::<Vec<_>>(), "rows": p.rows.iter().map(|r| json!({ "label": r.label, "icon": r.icon, "sel": r.sel })).collect::<Vec<_>>(),
}), }),
} }
@@ -474,13 +502,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
.show(ctx, |ui| { .show(ctx, |ui| {
crate::context_menu::menu_frame(ui, |ui| { crate::context_menu::menu_frame(ui, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover()); let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover());
ui.painter().text( let title = crate::i18n::tr_label("Select Other").to_uppercase();
pos2(r.left() + 8.0, r.center().y), let title = if p.total > p.rows.len() { format!("{title} · {} / {}", p.rows.len(), p.total) } else { title };
Align2::LEFT_CENTER, ui.painter().text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, title, FontId::proportional(10.5), t.text_dim);
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| { egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for (i, row) in p.rows.iter().enumerate() { for (i, row) in p.rows.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
+24
View File
@@ -170,3 +170,27 @@ fn rows_name_points_by_their_curves() {
let rows = labels(&h); let rows = labels(&h);
assert!(rows.iter().any(|r| r.starts_with("Point (") && r.contains("centre")), "{rows:?}"); assert!(rows.iter().any(|r| r.starts_with("Point (") && r.contains("centre")), "{rows:?}");
} }
#[test]
fn a_dense_spot_is_capped_per_kind_and_reports_the_total() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
// Thirty lines drawn on top of each other: one kind of thing, far beyond the per-kind cap.
for _ in 0..30 {
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.step(&json!({"hold": at})).unwrap();
assert!(crate::select_other::is_open(&h.app));
let d = crate::select_other::describe(&h.app);
let rows = d["rows"].as_array().expect("rows");
let lines = rows.iter().filter(|r| r["label"].as_str().is_some_and(|l| l.starts_with("Line "))).count();
assert_eq!(lines, 20, "at most 20 rows of one kind, nearest first: {rows:?}");
assert_eq!(rows[0]["label"], json!("Line 1"), "the nearest still leads");
let total = d["total"].as_u64().expect("total");
assert!(total >= 30, "the total counts every candidate, not just the rows: {total}");
assert!((total as usize) < rows.len() + 30, "the list is truncated: {total} total, {} rows", rows.len());
}
+1 -1
View File
@@ -56,7 +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.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.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.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.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`, `total`: everything under the cursor, `rows`: label, icon, sel, at most 20 per kind, nearest first) |
| `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened | | `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.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 | | `ui.worldToScreen` | `points`: `[[x, y, z]…]` world points | their screen points |