From 6c1462932dce35e69e45ce845abc5de889842375 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 29 May 2026 09:30:54 +0300 Subject: [PATCH] feat(grip): multi-functional hover popup menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the grip overhaul: when the cursor dwells on a selected entity's grip the overlay opens a popup menu with entity-specific options (Stretch / Add Vertex / Remove Vertex / Convert to Arc-Line / …). Picking an item dispatches `apply_grip_menu`; the default `Stretch` entry is a no-op that lets the user click the grip to start the normal drag. - `GripMenuItem` + `GripMenuAction` (full vocabulary covering stretch, add/remove vertex, convert to arc/line, reverse arrows, text-position variants, hatch parameters, tangent direction). - `Grippable` trait gains `grip_menu` + `apply_grip_menu` with trait-default `Stretch`-only impls so every existing entity gets the popup for free. - `EntityTypeOps` dispatches both methods across the same entity list as the existing grip / property machinery. - `OpenCADStudio` carries `grip_hover` (dwell tracker) + `grip_popup` (open menu). `update_grip_hover` runs from `ViewportMove`: hit-tests grips, refreshes the dwell timer, and opens the popup after 600 ms. Cursor drift past 80 px or a grip click dismisses it. - `view.rs` paints the popup as an absolute-positioned column of buttons at the grip's screen anchor; each button publishes `Message::GripMenuPick(idx)`. - `LwPolyline` overrides `Grippable` manually (skips the standard macro) so its vertex grip exposes Add/Remove Vertex and its segment-midpoint grip exposes Add Vertex + Convert to Arc/Line. `apply_grip_menu` performs the edits: vertex insertion at the chord midpoint inheriting the previous bulge, vertex removal, and bulge toggle for the arc/line conversion. --- src/app/mod.rs | 33 +++++++++ src/app/update.rs | 141 +++++++++++++++++++++++++++++++++++++ src/app/view.rs | 63 +++++++++++++++++ src/entities/lwpolyline.rs | 116 +++++++++++++++++++++++++++++- src/entities/traits.rs | 51 +++++++++++++- src/scene/object.rs | 41 +++++++++++ 6 files changed, 443 insertions(+), 2 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 19b627e8e..6c14dcece 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -23,6 +23,26 @@ pub struct TileDrag { pub orient: TileEdgeOrient, pub last_applied: f32, } + +/// Cursor dwelling over a grip. When `started.elapsed() >=` the popup +/// threshold the multi-functional menu opens (`grip_popup`). +#[derive(Clone, Debug)] +pub struct GripHover { + pub handle: acadrust::Handle, + pub grip_id: usize, + pub screen: iced::Point, + pub started: std::time::Instant, +} + +/// Open multi-functional-grip popup state. +#[derive(Clone, Debug)] +pub struct GripPopup { + pub handle: acadrust::Handle, + pub grip_id: usize, + pub anchor: iced::Point, + pub items: Vec, + pub selected: usize, +} use crate::snap::Snapper; use crate::ui::{AppMenu, CommandLine, Ribbon, StatusBar}; use acadrust::types::{Color as AcadColor, LineWeight}; @@ -94,6 +114,14 @@ pub(super) struct OpenCADStudio { /// reverting to the command-default when `has_base` flips. Cleared /// on point commit / command start. See #35. dyn_user_reshaped: bool, + /// Grip the cursor is currently dwelling on. Set when the cursor + /// stops within `GRIP_THRESHOLD_PX` of a grip; cleared when it + /// drifts away. The instant lets `ViewportMove` detect when the + /// dwell crosses the popup-open threshold. + grip_hover: Option, + /// Open multi-functional grip popup. Persists across mouse moves + /// until dismissed (click outside, ESC, cursor leaves the grip). + grip_popup: Option, /// Show the UCS icon in the bottom-left corner of model space (UCSICON). show_ucs_icon: bool, /// Whether the ViewCube 3D gizmo is visible in model space (NAVVCUBE). @@ -422,6 +450,9 @@ pub enum Message { ViewportScroll(mouse::ScrollDelta), ViewportExit, ViewCubeSnap(CubeRegion), + /// User picked an item in the multi-functional grip popup menu — + /// the index is into `grip_popup.items`. + GripMenuPick(usize), WindowResized(f32, f32), /// Enter pressed globally — finalises the active command (no text-input involvement). CommandFinalize, @@ -770,6 +801,8 @@ impl OpenCADStudio { awaiting_vports: false, tile_drag: None, dyn_user_reshaped: false, + grip_hover: None, + grip_popup: None, show_ucs_icon: true, show_viewcube: true, show_properties: true, diff --git a/src/app/update.rs b/src/app/update.rs index a1c10f722..e84028d03 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1766,6 +1766,11 @@ impl OpenCADStudio { VIEWCUBE_PX, )); + // Multi-functional grip hover: detect cursor sitting on a + // selected entity's grip and, after a dwell, open the + // popup menu. See scene::object::GripMenuItem. + self.update_grip_hover(i, p); + let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.last_move_pos = Some(p); @@ -2269,6 +2274,8 @@ impl OpenCADStudio { origin_world: world, last_world: world, }); + self.grip_hover = None; + self.grip_popup = None; return Task::none(); } } @@ -3060,6 +3067,38 @@ impl OpenCADStudio { Task::none() } + Message::GripMenuPick(idx) => { + let i = self.active_tab; + let Some(popup) = self.grip_popup.take() else { + return Task::none(); + }; + self.grip_hover = None; + let Some(item) = popup.items.get(idx).cloned() else { + return Task::none(); + }; + use crate::entities::traits::EntityTypeOps; + use crate::scene::object::GripMenuAction; + if matches!(item.action, GripMenuAction::Stretch) { + // Default action: nothing — user can click the grip + // to start the normal drag. + return Task::none(); + } + self.push_undo_snapshot(i, item.label); + if let Some(entity) = self + .tabs[i] + .scene + .document + .get_entity_mut(popup.handle) + { + entity.apply_grip_menu(popup.grip_id, item.action); + } + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.refresh_selected_grips(); + self.refresh_properties(); + Task::none() + } + // ── Snap / mode toggles ─────────────────────────────────────── Message::ToggleSnapEnabled => { self.snapper.toggle_global(); @@ -5469,6 +5508,108 @@ impl OpenCADStudio { } } + /// Track cursor dwell over a selected entity's grip. Sets + /// `grip_hover` while the cursor sits within `GRIP_THRESHOLD_PX` of + /// a grip and opens `grip_popup` once the dwell exceeds the + /// threshold. Cursor drift clears both. + fn update_grip_hover(&mut self, i: usize, p: iced::Point) { + const HOVER_OPEN_MS: u128 = 600; + const POPUP_DISMISS_PX: f32 = 80.0; + if self.tabs[i].active_cmd.is_some() + || self.tabs[i].active_grip.is_some() + || self.tabs[i].selected_grips.is_empty() + { + self.grip_hover = None; + self.grip_popup = None; + return; + } + let Some(handle) = self.tabs[i].selected_handle else { + self.grip_hover = None; + self.grip_popup = None; + return; + }; + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: vw, + height: vh, + }; + let is_paper = self.tabs[i].scene.current_layout != "Model"; + let hit = if is_paper { + let cam = self.tabs[i].scene.camera.borrow(); + let aspect = if vh > 0.0 { vw / vh } else { 1.0 }; + let half_h = cam.ortho_size(); + let half_w = half_h * aspect; + let tx = cam.target.x; + let ty = cam.target.y; + drop(cam); + find_hit_grip_paper( + p, + &self.tabs[i].selected_grips, + tx, + ty, + half_w, + half_h, + bounds, + ) + } else { + let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); + find_hit_grip(p, &self.tabs[i].selected_grips, vp_mat, bounds) + }; + match hit { + Some((grip_id, _, _)) => { + let same = self + .grip_hover + .as_ref() + .map_or(false, |h| h.handle == handle && h.grip_id == grip_id); + if !same { + self.grip_hover = Some(super::GripHover { + handle, + grip_id, + screen: p, + started: std::time::Instant::now(), + }); + self.grip_popup = None; + } else if let Some(h) = self.grip_hover.as_mut() { + h.screen = p; + } + // Open popup once dwell crosses the threshold. + if self.grip_popup.is_none() + && self + .grip_hover + .as_ref() + .map_or(false, |h| h.started.elapsed().as_millis() >= HOVER_OPEN_MS) + { + let entity_opt = self.tabs[i].scene.document.get_entity(handle); + if let Some(e) = entity_opt { + use crate::entities::traits::EntityTypeOps; + let items = e.grip_menu(grip_id); + if !items.is_empty() { + self.grip_popup = Some(super::GripPopup { + handle, + grip_id, + anchor: p, + items, + selected: 0, + }); + } + } + } + } + None => { + self.grip_hover = None; + if let Some(popup) = &self.grip_popup { + let dx = p.x - popup.anchor.x; + let dy = p.y - popup.anchor.y; + if (dx * dx + dy * dy).sqrt() > POPUP_DISMISS_PX { + self.grip_popup = None; + } + } + } + } + } + /// Re-run the active command's preview hook against the current /// cursor world position. Keyboard-driven point commits (typed /// coordinates in the command line or dynamic input) don't fire a diff --git a/src/app/view.rs b/src/app/view.rs index d04899bac..aee003b92 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -684,6 +684,69 @@ impl OpenCADStudio { } } + // Multi-functional grip popup (Phase 2). Floating column of + // menu items at the grip's screen position, with the selected + // item highlighted. Each item dispatches `GripMenuPick`. + if let Some(popup) = self.grip_popup.as_ref() { + if !tab.is_start { + let anchor_x = (popup.anchor.x + 12.0).max(0.0); + let anchor_y = (popup.anchor.y + 12.0).max(0.0); + let mut col = column![].spacing(0); + for (idx, item) in popup.items.iter().enumerate() { + let is_sel = idx == popup.selected; + let label = item.label; + let btn = button(text(label).size(12).color(Color::WHITE)) + .on_press(Message::GripMenuPick(idx)) + .padding([4, 10]) + .style(move |_: &Theme, status| iced::widget::button::Style { + background: Some(Background::Color(match (is_sel, status) { + (true, _) => Color { + r: 0.20, + g: 0.45, + b: 0.95, + a: 0.95, + }, + (_, iced::widget::button::Status::Hovered) => Color { + r: 0.20, + g: 0.20, + b: 0.20, + a: 0.95, + }, + _ => Color { + r: 0.10, + g: 0.10, + b: 0.10, + a: 0.92, + }, + })), + border: Border { + color: Color { + r: 0.35, + g: 0.35, + b: 0.35, + a: 1.0, + }, + width: 1.0, + radius: 0.0.into(), + }, + text_color: Color::WHITE, + ..Default::default() + }); + col = col.push(iced::widget::opaque(btn)); + } + let popup_layer = iced::widget::column![ + Space::new().height(iced::Length::Fixed(anchor_y)), + iced::widget::row![ + Space::new().width(iced::Length::Fixed(anchor_x)), + col, + ], + ] + .width(Fill) + .height(Fill); + viewport_stack = viewport_stack.push(popup_layer); + } + } + // Properties / layers panels carry no useful state on the Start tab. // Replace the properties panel with a Recent Documents list there. let properties_el: Element<'_, Message> = if tab.is_start { diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index f44cb2e67..f67cc267d 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -440,7 +440,121 @@ impl TruckConvertible for LwPolyline { } } -crate::impl_entity_basics!(LwPolyline); +impl crate::entities::traits::Grippable for LwPolyline { + fn grips(&self) -> Vec { + grips(self) + } + fn apply_grip( + &mut self, + grip_id: usize, + apply: crate::scene::object::GripApply, + ) { + apply_grip(self, grip_id, apply); + } + fn grip_menu( + &self, + grip_id: usize, + ) -> Vec { + use crate::scene::object::{GripMenuAction, GripMenuItem}; + let n = self.vertices.len(); + if grip_id < n { + // Vertex grip. + return vec![ + GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch }, + GripMenuItem { label: "Add Vertex", action: GripMenuAction::AddVertex }, + GripMenuItem { label: "Remove Vertex", action: GripMenuAction::RemoveVertex }, + ]; + } + // Segment midpoint grip. + let seg = grip_id - n; + let is_arc = self + .vertices + .get(seg) + .map_or(false, |v| v.bulge.abs() >= 1e-9); + let convert = if is_arc { + GripMenuItem { label: "Convert to Line", action: GripMenuAction::ConvertToLine } + } else { + GripMenuItem { label: "Convert to Arc", action: GripMenuAction::ConvertToArc } + }; + vec![ + GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch }, + GripMenuItem { label: "Add Vertex", action: GripMenuAction::AddVertex }, + convert, + ] + } + fn apply_grip_menu( + &mut self, + grip_id: usize, + action: crate::scene::object::GripMenuAction, + ) { + use crate::scene::object::GripMenuAction as A; + let n = self.vertices.len(); + match action { + A::Stretch => {} + A::AddVertex => { + // Insert a new vertex at the chord midpoint between the + // hovered vertex (or segment) and its neighbour. The new + // vertex inherits the previous vertex's bulge so an + // existing arc is split into two arcs of the same chord. + let (i0, i1) = if grip_id < n { + let i0 = grip_id; + let i1 = (grip_id + 1) % n; + (i0, i1) + } else { + let seg = grip_id - n; + (seg, (seg + 1) % n) + }; + if i1 == 0 && !self.is_closed { + return; + } + let v0 = &self.vertices[i0]; + let v1 = &self.vertices[i1]; + let mx = (v0.location.x + v1.location.x) * 0.5; + let my = (v0.location.y + v1.location.y) * 0.5; + let inherited = v0.clone(); + let mut new_v = inherited.clone(); + new_v.location.x = mx; + new_v.location.y = my; + let insert_at = (i0 + 1).min(self.vertices.len()); + self.vertices.insert(insert_at, new_v); + } + A::RemoveVertex if grip_id < n && self.vertices.len() > 2 => { + self.vertices.remove(grip_id); + } + A::ConvertToArc if grip_id >= n => { + if let Some(v) = self.vertices.get_mut(grip_id - n) { + if v.bulge.abs() < 1e-9 { + v.bulge = 0.5; + } + } + } + A::ConvertToLine if grip_id >= n => { + if let Some(v) = self.vertices.get_mut(grip_id - n) { + v.bulge = 0.0; + } + } + _ => {} + } + } +} + +impl crate::entities::traits::PropertyEditable for LwPolyline { + fn geometry_properties( + &self, + _text_style_names: &[String], + ) -> crate::scene::object::PropSection { + properties(self) + } + fn apply_geom_prop(&mut self, field: &str, value: &str) { + apply_geom_prop(self, field, value); + } +} + +impl crate::entities::traits::Transformable for LwPolyline { + fn apply_transform(&mut self, t: &crate::command::EntityTransform) { + apply_transform(self, t); + } +} pub(crate) fn wide_fills(pl: &acadrust::entities::LwPolyline) -> Vec> { let hw_const = (pl.constant_width / 2.0) as f32; diff --git a/src/entities/traits.rs b/src/entities/traits.rs index c05f1aae6..382129a38 100644 --- a/src/entities/traits.rs +++ b/src/entities/traits.rs @@ -2,7 +2,9 @@ use acadrust::{CadDocument, EntityType}; use crate::command::EntityTransform; use crate::scene::acad_to_truck::TruckEntity; -use crate::scene::object::{GripApply, GripDef, PropSection}; +use crate::scene::object::{ + GripApply, GripDef, GripMenuAction, GripMenuItem, PropSection, +}; use crate::scene::tess_util::FallbackGeometry; pub trait TruckConvertible { @@ -20,6 +22,19 @@ pub trait FallbackTess { pub trait Grippable { fn grips(&self) -> Vec; fn apply_grip(&mut self, grip_id: usize, apply: GripApply); + /// Items shown in the hover popup that opens when the cursor + /// dwells on a grip. Default is a single `Stretch` entry — i.e. + /// the popup just confirms the default drag behaviour. Override + /// per entity to add Add Vertex / Convert to Arc / Reverse Arrows / + /// etc. + fn grip_menu(&self, _grip_id: usize) -> Vec { + vec![GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch }] + } + /// React to a popup-menu commit. Default no-op — `Stretch` is the + /// "do nothing extra" path, the normal drag still happens on click. + /// Entities override this to mutate themselves when the user picks + /// Add Vertex / Remove Vertex / Convert / Reverse / … + fn apply_grip_menu(&mut self, _grip_id: usize, _action: GripMenuAction) {} } pub trait PropertyEditable { @@ -58,9 +73,11 @@ pub trait TextContent { pub trait EntityTypeOps { fn to_truck_entity(&self, document: &CadDocument) -> Option; fn grips(&self) -> Vec; + fn grip_menu(&self, grip_id: usize) -> Vec; fn geometry_properties(&self, text_style_names: &[String]) -> Option; fn apply_geom_prop(&mut self, field: &str, value: &str); fn apply_grip(&mut self, grip_id: usize, apply: GripApply); + fn apply_grip_menu(&mut self, grip_id: usize, action: GripMenuAction); fn apply_transform(&mut self, t: &EntityTransform); fn mass_props(&self) -> Option; fn text_content(&self) -> Option; @@ -238,6 +255,38 @@ impl EntityTypeOps for EntityType { ) } + fn grip_menu(&self, grip_id: usize) -> Vec { + dispatch!(self, + |e| Grippable::grip_menu(e, grip_id), + [ + Line, Circle, Arc, Ellipse, LwPolyline, Polyline, Polyline2D, + Polyline3D, Ray, XLine, RasterImage, Wipeout, + AttributeDefinition, AttributeEntity, MLine, Tolerance, + Solid, Solid3D, Region, Body, Face3D, PolygonMesh, + PolyfaceMesh, Mesh, Table, Point, Spline, Text, MText, + Viewport, Insert, Leader, MultiLeader, Dimension, Hatch, + Underlay, Shape, Ole2Frame, + ], + _ => vec![], + ) + } + + fn apply_grip_menu(&mut self, grip_id: usize, action: GripMenuAction) { + dispatch!(self, + |e| Grippable::apply_grip_menu(e, grip_id, action), + [ + Line, Circle, Arc, Ellipse, LwPolyline, Polyline, Polyline2D, + Polyline3D, Ray, XLine, RasterImage, Wipeout, + AttributeDefinition, AttributeEntity, MLine, Tolerance, + Solid, Solid3D, Region, Body, Face3D, PolygonMesh, + PolyfaceMesh, Mesh, Table, Point, Spline, Text, MText, + Viewport, Insert, Leader, MultiLeader, Dimension, Hatch, + Underlay, Shape, Ole2Frame, + ], + _ => {}, + ) + } + fn apply_transform(&mut self, t: &EntityTransform) { dispatch!(self, |e| Transformable::apply_transform(e, t), diff --git a/src/scene/object.rs b/src/scene/object.rs index 8ac5fcba4..8f82850f6 100644 --- a/src/scene/object.rs +++ b/src/scene/object.rs @@ -96,3 +96,44 @@ pub enum GripApply { /// Translate the whole object by this delta vector. Translate(Vec3), } + +/// One entry in the hover-popup menu that opens when the cursor dwells +/// on a grip. The `label` is the user-visible string; `action` is the +/// operation the entity will perform when the item is committed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GripMenuItem { + pub label: &'static str, + pub action: GripMenuAction, +} + +/// All operations a grip popup menu can dispatch. Entity-specific code +/// in `apply_grip_menu` decodes these into edits. `Stretch` is the +/// default no-op-equivalent — picking it just starts the regular +/// stretch drag, identical to clicking the grip with no popup open. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GripMenuAction { + Stretch, + Lengthen, + Radius, + ArcLength, + AddVertex, + RemoveVertex, + ConvertToArc, + ConvertToLine, + StretchVertex, + AddLeader, + RemoveLeader, + ReverseArrows, + MoveWithDimLine, + MoveWithLeader, + MoveIndependent, + ResetText, + RotateText, + AboveDimLine, + Center, + OriginPoint, + HatchAngle, + HatchScale, + HatchPattern, + TangentDirection, +}