From 9f6fb735ddeac1bffbcf396c5eb67210602260e7 Mon Sep 17 00:00:00 2001 From: Alan16888 <168160916+Alan16888@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:41:18 +0800 Subject: [PATCH] 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. --- crates/ui-egui/src/select_other.rs | 48 ++++++++++++++++++------ crates/ui-egui/src/select_other_tests.rs | 24 ++++++++++++ docs/control-protocol.md | 2 +- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/select_other.rs b/crates/ui-egui/src/select_other.rs index 0e14c78e..fbdb2b20 100644 --- a/crates/ui-egui/src/select_other.rs +++ b/crates/ui-egui/src/select_other.rs @@ -5,7 +5,10 @@ //! - 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. +//! vertices, edges, faces, bodies, profiles, planes and axes, each kind nearest first. On a +//! dense mesh (a modelled thread, an imported mesh) thousands of facets can share one cursor, +//! so at most [`MAX_PER_KIND`] rows are kept per kind, nearest first, and the list reports how +//! many things there were in all. //! - Hovering a row lights its entity up in the viewport; clicking it selects it (Shift/Ctrl add, //! as clicks do) or, with a dialog open, gives it to the dialog's active selection input. //! Esc or a click elsewhere closes the list. @@ -28,6 +31,10 @@ use crate::viewport::{Hit, Proj}; pub const HOLD_SECS: f64 = 0.5; /// How far (screen points) a repeat click may be from the last one and still cycle. const CYCLE_SLOP: f32 = 4.0; +/// The most rows kept per kind of thing (vertices, edges, faces, …). The list exists to tell a +/// handful of overlapping things apart; nearest first, farther ones of the same kind are dropped +/// so it stays readable and bounded. +const MAX_PER_KIND: usize = 20; /// One row of the list. #[derive(Clone, Debug)] @@ -43,6 +50,8 @@ pub struct Row { pub struct Popup { pub at: Pos2, pub rows: Vec, + /// How many things were under the cursor in all (the rows are capped per kind). + pub total: usize, /// The row under the pointer (lit in the viewport). pub hovered: Option, /// The model and dialog it was made for: it closes when they change. @@ -78,6 +87,11 @@ pub struct State { /// Every pickable thing under `pos`, ordered by kind (the sketch being edited first), then depth. pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { + rows_capped(app, proj, pos).0 +} + +/// [`rows`], with how many things were under the cursor in all. +fn rows_capped(app: &SolveApp, proj: &Proj, pos: Pos2) -> (Vec, usize) { let hits = crate::viewport::pick_all(app, proj, pos); let mut found = crate::viewport::candidates(app, &hits); let dialog = app.dialog.as_ref().is_some_and(|d| d.wants_picks()); @@ -97,12 +111,25 @@ pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { } let sketching = app.session.active_sketch.is_some(); 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 = std::collections::HashMap::new(); + let rows: Vec = 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)| { let label = label(app, &sel); 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. @@ -255,14 +282,14 @@ pub fn open(app: &mut SolveApp, 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() { 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 }); + Some(Popup { at, rows, total, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false }); true } @@ -306,6 +333,7 @@ pub fn describe(app: &SolveApp) -> Value { "open": true, "at": [p.at.x, p.at.y], "hovered": p.hovered, + "total": p.total, "rows": p.rows.iter().map(|r| json!({ "label": r.label, "icon": r.icon, "sel": r.sel })).collect::>(), }), } @@ -474,13 +502,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .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, - ); + let title = crate::i18n::tr_label("Select Other").to_uppercase(); + let title = if p.total > p.rows.len() { format!("{title} · {} / {}", p.rows.len(), p.total) } else { title }; + ui.painter().text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, title, FontId::proportional(10.5), t.text_dim); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { for (i, row) in p.rows.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); diff --git a/crates/ui-egui/src/select_other_tests.rs b/crates/ui-egui/src/select_other_tests.rs index b4da736a..23b2e8cf 100644 --- a/crates/ui-egui/src/select_other_tests.rs +++ b/crates/ui-egui/src/select_other_tests.rs @@ -170,3 +170,27 @@ fn rows_name_points_by_their_curves() { let rows = labels(&h); 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()); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3354d63c..bc999691 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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.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.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.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 |