From 559cd7636972ef0dcd58fbd923fc67eb3a7bda9d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 29 May 2026 14:58:35 +0300 Subject: [PATCH] feat(grip): value-prompt actions Lengthen/Radius/Arc Length Lengthen / Radius / Arc Length need a number after the pick. Stash `grip_pending` on the picked action, push prompt to command line, route next `CommandSubmit` parse into `apply_grip_menu_value`. Escape cancels. - `Grippable::grip_menu_value_prompt` returns label when action wants value; `apply_grip_menu_value` runs the edit. Dispatched via `EntityTypeOps`. - Line: Lengthen extends either endpoint along line direction. - Arc: Radius sets radius; Arc Length redrives end angle from `value / radius`; Lengthen extends start / end angle the same way. --- src/app/mod.rs | 14 ++++++++ src/app/update.rs | 59 +++++++++++++++++++++++++++++++-- src/entities/arc.rs | 51 +++++++++++++++++++++++++++-- src/entities/line.rs | 50 +++++++++++++++++++++++++++- src/entities/traits.rs | 74 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 243 insertions(+), 5 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 0e9b2276f..602b5ecd0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -43,6 +43,18 @@ pub struct GripPopup { pub items: Vec, pub selected: usize, } + +/// Pending follow-up value for grip-menu actions that need a number +/// (Lengthen / Radius / Arc Length / Rotate Text). The next number +/// typed in the command line is parsed and routed into +/// `apply_grip_menu_value` for `(handle, grip_id, action)`. +#[derive(Clone, Debug)] +pub struct GripPendingValue { + pub handle: acadrust::Handle, + pub grip_id: usize, + pub action: crate::scene::object::GripMenuAction, + pub label: &'static str, +} use crate::snap::Snapper; use crate::ui::{AppMenu, CommandLine, Ribbon, StatusBar}; use acadrust::types::{Color as AcadColor, LineWeight}; @@ -122,6 +134,7 @@ pub(super) struct OpenCADStudio { /// Open multi-functional grip popup. Persists across mouse moves /// until dismissed (click outside, ESC, cursor leaves the grip). grip_popup: Option, + grip_pending: 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). @@ -806,6 +819,7 @@ impl OpenCADStudio { dyn_user_reshaped: false, grip_hover: None, grip_popup: None, + grip_pending: None, show_ucs_icon: true, show_viewcube: true, show_properties: true, diff --git a/src/app/update.rs b/src/app/update.rs index f385cd505..ea3a2f42d 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1019,6 +1019,39 @@ impl OpenCADStudio { // dropdown so the dispatched command's new prompt is // immediately visible on the overlay. self.command_line.close_history(); + // Grip-menu value prompt — consume the typed number and + // route it through `apply_grip_menu_value`. + if let Some(pending) = self.grip_pending.take() { + let raw = self.command_line.input.trim().to_string(); + self.command_line.input.clear(); + let Ok(v) = raw.parse::() else { + self.command_line.push_error(&format!( + "{}: expected a number, got \"{raw}\"", + pending.label + )); + return Task::none(); + }; + let i = self.active_tab; + use crate::entities::traits::EntityTypeOps; + self.push_undo_snapshot(i, pending.label); + if let Some(entity) = self + .tabs[i] + .scene + .document + .get_entity_mut(pending.handle) + { + entity.apply_grip_menu_value( + pending.grip_id, + pending.action, + v, + ); + } + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.refresh_selected_grips(); + self.refresh_properties(); + return Task::none(); + } // Interactive VPORTS: the entry after a bare `VPORTS` is the // tiled configuration. Empty input defaults to SINGLE. if self.awaiting_vports { @@ -1203,6 +1236,10 @@ impl OpenCADStudio { self.grip_hover = None; return Task::none(); } + if self.grip_pending.take().is_some() { + self.command_line.input.clear(); + return Task::none(); + } // Cancel layout rename / context menus first, then fall through. let i_e = self.active_tab; { @@ -3143,10 +3180,28 @@ impl OpenCADStudio { 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(); } + // Actions that need a follow-up number stash a pending + // state + prompt; the next typed value drives + // `apply_grip_menu_value`. + let prompt = self + .tabs[i] + .scene + .document + .get_entity(popup.handle) + .and_then(|e| e.grip_menu_value_prompt(popup.grip_id, item.action)); + if let Some(label) = prompt { + self.grip_pending = Some(super::GripPendingValue { + handle: popup.handle, + grip_id: popup.grip_id, + action: item.action, + label, + }); + self.command_line.push_info(&format!("{label}:")); + return self.focus_cmd_input(); + } + // One-shot action — apply immediately. self.push_undo_snapshot(i, item.label); if let Some(entity) = self .tabs[i] diff --git a/src/entities/arc.rs b/src/entities/arc.rs index 8fa5a0dfe..e2560371f 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -253,8 +253,55 @@ impl crate::entities::traits::Grippable for Arc { _grip_id: usize, _action: crate::scene::object::GripMenuAction, ) { - // Radius / Arc Length / Lengthen all need a follow-up prompt - // (numeric distance / length / radius) — Phase 3. + // Radius / Arc Length / Lengthen all need a follow-up prompt; + // the actual edit happens in `apply_grip_menu_value`. + } + + fn grip_menu_value_prompt( + &self, + _grip_id: usize, + action: crate::scene::object::GripMenuAction, + ) -> Option<&'static str> { + use crate::scene::object::GripMenuAction as A; + match action { + A::Radius => Some("New radius"), + A::ArcLength => Some("New arc length"), + A::Lengthen => Some("Distance"), + _ => None, + } + } + + fn apply_grip_menu_value( + &mut self, + grip_id: usize, + action: crate::scene::object::GripMenuAction, + value: f64, + ) { + use crate::scene::object::GripMenuAction as A; + match action { + A::Radius if value > 0.0 => self.radius = value, + A::ArcLength if value > 0.0 && self.radius > 1e-9 => { + // Hold start_angle, derive new end_angle from arc length + // = r * Δθ. + let new_span = value / self.radius; + self.end_angle = self.start_angle + new_span; + } + A::Lengthen => { + // Extend either end by `value` arc-length units along + // the arc. Positive `value` lengthens; negative + // shortens. Grip 1 = start endpoint, grip 2 = end endpoint. + if self.radius < 1e-9 { + return; + } + let dtheta = value / self.radius; + match grip_id { + 1 => self.start_angle -= dtheta, + 2 => self.end_angle += dtheta, + _ => {} + } + } + _ => {} + } } } diff --git a/src/entities/line.rs b/src/entities/line.rs index 2227c6d4e..8220c0f51 100644 --- a/src/entities/line.rs +++ b/src/entities/line.rs @@ -182,7 +182,55 @@ impl crate::entities::traits::Grippable for Line { _grip_id: usize, _action: crate::scene::object::GripMenuAction, ) { - // Lengthen requires a follow-up distance prompt — Phase 3. + // Lengthen needs a follow-up distance — handled by + // `apply_grip_menu_value`. + } + + fn grip_menu_value_prompt( + &self, + _grip_id: usize, + action: crate::scene::object::GripMenuAction, + ) -> Option<&'static str> { + use crate::scene::object::GripMenuAction as A; + match action { + A::Lengthen => Some("Distance"), + _ => None, + } + } + + fn apply_grip_menu_value( + &mut self, + grip_id: usize, + action: crate::scene::object::GripMenuAction, + value: f64, + ) { + use crate::scene::object::GripMenuAction as A; + if !matches!(action, A::Lengthen) { + return; + } + let dx = self.end.x - self.start.x; + let dy = self.end.y - self.start.y; + let dz = self.end.z - self.start.z; + let len = (dx * dx + dy * dy + dz * dz).sqrt(); + if len < 1e-12 { + return; + } + let (ux, uy, uz) = (dx / len, dy / len, dz / len); + match grip_id { + 0 => { + // Move start endpoint backward along the line by `value` + // (positive = lengthen; negative = shorten). + self.start.x -= ux * value; + self.start.y -= uy * value; + self.start.z -= uz * value; + } + 1 => { + self.end.x += ux * value; + self.end.y += uy * value; + self.end.z += uz * value; + } + _ => {} + } } } diff --git a/src/entities/traits.rs b/src/entities/traits.rs index 382129a38..ed4463796 100644 --- a/src/entities/traits.rs +++ b/src/entities/traits.rs @@ -35,6 +35,28 @@ pub trait Grippable { /// 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) {} + /// Same as `apply_grip_menu` but for actions that need a numeric + /// follow-up (Lengthen / Radius / Arc Length / Rotate Text / …). + /// The app prompts the user for a value on the command line and + /// calls this with the parsed `f64`. + fn apply_grip_menu_value( + &mut self, + _grip_id: usize, + _action: GripMenuAction, + _value: f64, + ) {} + /// If the popup action this entity defines for `grip_id` needs a + /// numeric prompt, return the prompt string and the action so the + /// app can stash a pending-value state and route the next typed + /// number into `apply_grip_menu_value`. `None` for one-shot + /// actions handled by `apply_grip_menu`. + fn grip_menu_value_prompt( + &self, + _grip_id: usize, + _action: GripMenuAction, + ) -> Option<&'static str> { + None + } } pub trait PropertyEditable { @@ -78,6 +100,17 @@ pub trait EntityTypeOps { 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 grip_menu_value_prompt( + &self, + grip_id: usize, + action: GripMenuAction, + ) -> Option<&'static str>; + fn apply_grip_menu_value( + &mut self, + grip_id: usize, + action: GripMenuAction, + value: f64, + ); fn apply_transform(&mut self, t: &EntityTransform); fn mass_props(&self) -> Option; fn text_content(&self) -> Option; @@ -287,6 +320,47 @@ impl EntityTypeOps for EntityType { ) } + fn grip_menu_value_prompt( + &self, + grip_id: usize, + action: GripMenuAction, + ) -> Option<&'static str> { + dispatch!(self, + |e| Grippable::grip_menu_value_prompt(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, + ], + _ => None, + ) + } + + fn apply_grip_menu_value( + &mut self, + grip_id: usize, + action: GripMenuAction, + value: f64, + ) { + dispatch!(self, + |e| Grippable::apply_grip_menu_value(e, grip_id, action, value), + [ + 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),