From 082018756eaf119d8480c895bb2c18e5dac1070d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 20 Jun 2026 20:43:33 +0300 Subject: [PATCH] fix(hover): defer rollover pick until the cursor stops The rollover hit-test now sweeps wires, hatches, block-internal hatches and shaded solid bodies, which is O(N) per cursor frame and was freezing the crosshair on large drawings. Each idle move now clears the live highlight and arms a dwell timer; HoverDwellTick runs the pick once the cursor has been still for HOVER_DWELL_MS (120 ms), using the active tile's bounds so the deferred pick matches the entity actually under the cursor in tiled viewports. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 27 ++++++++++++ src/app/update.rs | 104 ++++++++++++++++++++++++++++++---------------- src/app/view.rs | 9 ++++ 3 files changed, 105 insertions(+), 35 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 1bbf3e896..462e3d8e6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -48,6 +48,23 @@ pub struct GripHover { pub started: iced::time::Instant, } +/// Cursor dwell awaiting a rollover hit-test. Refreshed on every idle +/// move; `HoverDwellTick` runs the pick once `last_move_at.elapsed()` +/// crosses `HOVER_DWELL_MS`. `point` and `tile_size` are tile-local so +/// the deferred pick uses the same projection the move handler would +/// have — picking with the full canvas bounds in a tiled layout matches +/// the wrong entity under the cursor. +#[derive(Clone, Debug)] +pub struct HoverDwell { + pub last_move_at: iced::time::Instant, + pub point: iced::Point, + pub tile_size: (f32, f32), + pub tab: usize, +} + +/// How long the cursor must sit still before the idle rollover pick runs. +pub const HOVER_DWELL_MS: u128 = 120; + /// Open multi-functional-grip popup state. #[derive(Clone, Debug)] pub struct GripPopup { @@ -259,6 +276,12 @@ pub(super) struct OpenCADStudio { /// updates only the overlay rather than re-tessellating the whole model. /// Committed (un-hidden + one re-tess) when the drag ends. `None` = idle. grip_preview_handle: Option, + /// Pending rollover hit-test. Each idle cursor move stashes + /// `(last_move_at, point, tab)` here and clears the live highlight; + /// `HoverDwellTick` runs the pick once the cursor has been still for + /// `HOVER_DWELL_MS`. Skipping the pick mid-stroke avoids the per-frame + /// O(N) wire+hatch+mesh sweep that froze the cursor on large drawings. + hover_dwell: Option, /// Snapshot of the edited entity taken at the start of a grip drag, used to /// restore it if the user presses Esc to cancel the drag. The drag mutates /// the document live (so grips / properties track), so cancel reverts from @@ -943,6 +966,9 @@ pub enum Message { /// Timer pulse while the cursor is dwelling on a grip; drives the /// dwell-to-popup transition without requiring further mouse motion. GripDwellTick, + /// Timer pulse while a rollover hit-test is queued; fires when the + /// cursor has been still long enough to safely run the pick. + HoverDwellTick, WindowResized(f32, f32), /// Enter pressed globally — finalises the active command (no text-input involvement). CommandFinalize, @@ -1584,6 +1610,7 @@ impl OpenCADStudio { visibility_popup: None, grip_add_provisional: None, grip_preview_handle: None, + hover_dwell: None, grip_original: None, qselect: None, show_ucs_icon: true, diff --git a/src/app/update.rs b/src/app/update.rs index 08f82c967..9d870fd29 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2709,45 +2709,25 @@ impl OpenCADStudio { self.tabs[i].last_cursor_world = world; } - // Rollover highlight: when idle (no active command, no drag), - // highlight the entity under the cursor. Decoupling selection - // from tessellation makes this cheap — it only refreshes the GPU - // xray overlay via `bump_selection`, never re-tessellates. + // Rollover highlight: when idle (no active command, no + // drag), defer the pick until the cursor stops. The full + // pick (wires + hatches + block hatches + shaded meshes) is + // O(N) per frame and stalls the cursor on large drawings, + // so each move clears the current highlight and resets the + // dwell timer — `HoverDwellTick` runs the hit-test only + // once the cursor has been still for `HOVER_DWELL_MS`. if !dragging && self.tabs[i].active_cmd.is_none() { - let bounds = iced::Rectangle { - x: 0.0, - y: 0.0, - width: vp_size.0, - height: vp_size.1, - }; - let view_proj = self.tabs[i].scene.camera.borrow().view_proj(bounds); - let all_wires = self.tabs[i].scene.hit_test_wires(); - // Mirror the click-selection pick order so the rollover - // highlights every selectable object: wire → hatch → - // block-internal hatch → shaded 3D solid body. - let hovered = scene::pick::hit_test::click_hit(p, &all_wires[..], view_proj, bounds) - .and_then(|s| Scene::handle_from_wire_name(s)) - .or_else(|| { - scene::pick::hit_test::click_hit_hatch( - p, - &self.tabs[i].scene.visible_hatches_for_click(), - view_proj, - bounds, - ) - }) - .or_else(|| { - scene::pick::hit_test::click_hit_insert_hatch( - p, - &self.tabs[i].scene.insert_hatches_for_click(), - view_proj, - bounds, - ) - }) - .or_else(|| self.tabs[i].scene.mesh_click_hit(p, view_proj, bounds)); - self.tabs[i].scene.set_hover_highlight(hovered); + self.tabs[i].scene.set_hover_highlight(None); + self.hover_dwell = Some(super::HoverDwell { + last_move_at: Instant::now(), + point: p, + tile_size: vp_size, + tab: i, + }); } else { // Suppress the rollover during a command or a drag. self.tabs[i].scene.set_hover_highlight(None); + self.hover_dwell = None; } if self.tabs[i].active_cmd.is_some() { @@ -4330,6 +4310,60 @@ impl OpenCADStudio { Task::none() } + Message::HoverDwellTick => { + let Some(dwell) = self.hover_dwell.clone() else { + return Task::none(); + }; + if Instant::now() + .duration_since(dwell.last_move_at) + .as_millis() + < super::HOVER_DWELL_MS + { + return Task::none(); + } + let i = dwell.tab; + // Re-check the gate — drag / command may have started + // between the move that armed the dwell and this tick. + if i >= self.tabs.len() || self.tabs[i].active_cmd.is_some() { + self.hover_dwell = None; + return Task::none(); + } + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: dwell.tile_size.0, + height: dwell.tile_size.1, + }; + let p = dwell.point; + let view_proj = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let all_wires = self.tabs[i].scene.hit_test_wires(); + // Mirror the click-selection pick order so the rollover + // highlights every selectable object: wire → hatch → + // block-internal hatch → shaded 3D solid body. + let hovered = scene::pick::hit_test::click_hit(p, &all_wires[..], view_proj, bounds) + .and_then(|s| Scene::handle_from_wire_name(s)) + .or_else(|| { + scene::pick::hit_test::click_hit_hatch( + p, + &self.tabs[i].scene.visible_hatches_for_click(), + view_proj, + bounds, + ) + }) + .or_else(|| { + scene::pick::hit_test::click_hit_insert_hatch( + p, + &self.tabs[i].scene.insert_hatches_for_click(), + view_proj, + bounds, + ) + }) + .or_else(|| self.tabs[i].scene.mesh_click_hit(p, view_proj, bounds)); + self.tabs[i].scene.set_hover_highlight(hovered); + self.hover_dwell = None; + Task::none() + } + Message::VisibilityPick(idx) => { if let Some(popup) = self.visibility_popup.take() { self.apply_visibility_state(popup.insert_handle, idx); diff --git a/src/app/view.rs b/src/app/view.rs index ad9b5f9c8..698f39de6 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1623,6 +1623,14 @@ impl OpenCADStudio { } else { Subscription::none() }; + // While a rollover pick is queued, drive ticks so it fires the + // moment the cursor has been still for the dwell window — without + // this `ViewportMove` alone never re-fires once the user stops. + let hover_dwell = if self.hover_dwell.is_some() { + window::frames().map(|_| Message::HoverDwellTick) + } else { + Subscription::none() + }; // Blink the MText preview caret while the editor is open. let caret_blink = if self.mtext_editor.is_some() { iced::time::every(std::time::Duration::from_millis(530)) @@ -1642,6 +1650,7 @@ impl OpenCADStudio { frames, history_tick, grip_dwell, + hover_dwell, caret_blink, web_fonts, event::listen_with(|ev, status, win_id| {