diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 3c6a00624..5fbad18e4 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -75,6 +75,10 @@ impl OpenCADStudio { .dispatch_families(cmd, i) .unwrap_or_else(Task::none); } + // A new command owns subsequent keyboard input. Any unfinished grip + // menu value prompt must not survive and intercept that command's + // coordinates or its final empty Enter. + self.grip_pending = None; // Starting a command closes any open ribbon dropdown (e.g. a style // combo left open) so it does not stay stuck behind the new tool. self.ribbon.close_dropdown(); diff --git a/src/app/update/command.rs b/src/app/update/command.rs index a5b76642b..b6ec4808f 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -199,6 +199,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .buffer .get_or_insert_with(String::new) .push_str(&s); + if self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + GripEditMode::Lengthen + | GripEditMode::Radius + | GripEditMode::ArcLength + ) + }) { + self.command_line.input.push_str(&s); + } } else { // Command-line entry is shown uppercase — except in // free-form text prompts, where the typed case is the @@ -242,6 +252,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if buf.is_empty() { self.tabs[i].dyn_fields[a].buffer = None; } + if self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + GripEditMode::Lengthen + | GripEditMode::Radius + | GripEditMode::ArcLength + ) + }) { + self.command_line.input.pop(); + } return self.focus_cmd_input(); } } @@ -277,13 +297,49 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // Grip-menu value prompt — consume the typed number and // route it through `apply_grip_menu_value`. + // Interactive grip prompts are valid only while their matching + // grip edit is alive. Do not let a stale Radius / Lengthen / + // Arc Length prompt consume Enter or coordinates from a later + // drawing command. + let stale_interactive_grip_prompt = self.grip_pending.as_ref().is_some_and(|pending| { + let expected_mode = match pending.action { + crate::scene::model::object::GripMenuAction::Lengthen => { + Some(GripEditMode::Lengthen) + } + crate::scene::model::object::GripMenuAction::Radius => { + Some(GripEditMode::Radius) + } + crate::scene::model::object::GripMenuAction::ArcLength => { + Some(GripEditMode::ArcLength) + } + _ => None, + }; + expected_mode.is_some_and(|mode| { + !self.tabs[self.active_tab].active_grip.as_ref().is_some_and(|grip| { + grip.mode == mode + && grip.handle == pending.handle + && grip.grip_id == pending.grip_id + }) + }) + }); + if stale_interactive_grip_prompt { + self.grip_pending = None; + } if let Some(pending) = self.grip_pending.take() { let i = self.active_tab; if self.reject_locked_edit(i, pending.handle) { self.cancel_active_grip_edit(); return Task::none(); } - let raw = crate::app::expr_eval::eval_to_string(self.command_line.input.trim()); + // Dynamic Input keeps numeric typing in its focused field. + // Fall back to the command-line buffer so the established + // prompt workflow remains unchanged when DYN is disabled. + let dyn_value = self.tabs[i] + .dyn_fields + .iter() + .find_map(|field| field.buffer.as_deref()); + let entered = dyn_value.unwrap_or(self.command_line.input.trim()); + let raw = crate::app::expr_eval::eval_to_string(entered); self.command_line.input.clear(); let Ok(v) = raw.parse::() else { self.command_line.push_error(crate::tf!( @@ -293,15 +349,27 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.grip_pending = Some(pending); return self.focus_cmd_input(); }; - let interactive_lengthen = self.tabs[i] + let interactive_value_grip = self.tabs[i] .active_grip .as_ref() .is_some_and(|grip| { - grip.mode == GripEditMode::Lengthen + matches!( + (grip.mode, pending.action), + ( + GripEditMode::Lengthen, + crate::scene::model::object::GripMenuAction::Lengthen, + ) | ( + GripEditMode::Radius, + crate::scene::model::object::GripMenuAction::Radius, + ) | ( + GripEditMode::ArcLength, + crate::scene::model::object::GripMenuAction::ArcLength, + ) + ) && grip.handle == pending.handle && grip.grip_id == pending.grip_id }); - if interactive_lengthen { + if interactive_value_grip { self.cancel_active_grip_edit(); } use crate::entities::traits::EntityTypeOps; @@ -1306,7 +1374,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { action: item.action, label, }); - if matches!(item.action, GripMenuAction::Lengthen) { + if matches!( + item.action, + GripMenuAction::Lengthen + | GripMenuAction::Radius + | GripMenuAction::ArcLength + ) { if let Some((_, grip)) = self.tabs[i] .selected_grip_handles .iter() @@ -1315,14 +1388,52 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { **owner == popup.handle && grip.id == popup.grip_id }) { - self.tabs[i].active_grip = Some(GripEdit::lengthen( - popup.handle, - popup.grip_id, - grip.world, - )); + if self.grip_originals.is_empty() { + self.grip_originals = self.tabs[i] + .scene + .document + .get_entity(popup.handle) + .cloned() + .map(|entity| vec![(popup.handle, entity)]) + .unwrap_or_default(); + } + self.tabs[i].active_grip = Some(match item.action { + GripMenuAction::Radius => GripEdit::radius( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::ArcLength => GripEdit::arc_length( + popup.handle, + popup.grip_id, + grip.world, + ), + _ => GripEdit::lengthen( + popup.handle, + popup.grip_id, + grip.world, + ), + }); + } + // Popup actions do not pass through the normal grip + // press handler, which is where dynamic fields are + // usually seeded. Build the value field + // immediately so it is visible before the next mouse + // move (and so keyboard input has a field to target). + self.sync_dyn_fields(); + if matches!(item.action, GripMenuAction::Radius) { + self.command_line.push_info( + crate::t!("Specify point or enter radius:").as_ref(), + ); + } else if matches!(item.action, GripMenuAction::ArcLength) { + self.command_line.push_info( + crate::t!("Specify point or enter arc length:").as_ref(), + ); + } else { + self.command_line.push_info( + crate::t!("Specify point or enter distance:").as_ref(), + ); } - self.command_line - .push_info(crate::t!("Specify point or enter distance:").as_ref()); } else { self.command_line.push_info(crate::tf!("{label}:").as_ref()); } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 1e1888055..bfe201e6a 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -65,34 +65,55 @@ impl OpenCADStudio { // A normal 2D grip stretch behaves like a point-placement step: // distance and angle are measured from the grip's original position. + // Lengthen uses the same overlay machinery, but it only needs the + // distance field: the entity itself determines the endpoint direction + // (or arc sweep), so an angle entry would be misleading. // // Grip editing is not an `active_cmd`, so handle it before the normal // command-only path below. - let grip_origin = self.tabs[i] + let grip_input = self.tabs[i] .active_grip .as_ref() - .filter(|grip| { - grip.mode == crate::scene::pick::grip::GripEditMode::Stretch - }) - .map(|grip| grip.origin_world); + .map(|grip| match grip.mode { + crate::scene::pick::grip::GripEditMode::Stretch => { + (grip.origin_world, None) + } + crate::scene::pick::grip::GripEditMode::Lengthen => { + (grip.origin_world, Some(crate::command::DynRole::Distance)) + } + crate::scene::pick::grip::GripEditMode::Radius => { + (grip.origin_world, Some(crate::command::DynRole::Radius)) + } + crate::scene::pick::grip::GripEditMode::ArcLength => { + (grip.origin_world, Some(crate::command::DynRole::Distance)) + } + }); - if let Some(origin) = grip_origin { - let wanted = [DynComponent::Distance, DynComponent::Angle]; - let current: Vec = self.tabs[i] + if let Some((origin, scalar_role)) = grip_input { + let wanted_roles: Vec = scalar_role.map_or_else( + || vec![crate::command::DynRole::Distance, crate::command::DynRole::Angle], + |role| vec![role], + ); + let current: Vec = self.tabs[i] .dyn_fields .iter() - .map(|field| field.component) + .map(|field| field.role) .collect(); - if current.as_slice() != wanted { - self.tabs[i].dyn_fields = wanted - .into_iter() - .map(DynFieldEntry::new) + if current != wanted_roles { + self.tabs[i].dyn_fields = wanted_roles + .iter() + .copied() + .map(DynFieldEntry::from_role) .collect(); self.tabs[i].dyn_active = 0; } - self.tabs[i].dyn_guide = crate::command::DynGuide::Polar; + self.tabs[i].dyn_guide = if scalar_role.is_some() { + crate::command::DynGuide::Radius + } else { + crate::command::DynGuide::Polar + }; self.tabs[i].dyn_anchor = Some(origin); self.tabs[i].dyn_ref = None; return; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 3fbe1ccf6..f84da07ef 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1583,6 +1583,24 @@ impl OpenCADStudio { } let live_input = s.clone(); self.command_line.input = live_input.clone(); + let i = self.active_tab; + if self.dyn_input + && self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + crate::scene::pick::grip::GripEditMode::Lengthen + | crate::scene::pick::grip::GripEditMode::Radius + | crate::scene::pick::grip::GripEditMode::ArcLength + ) + }) + && !self.tabs[i].dyn_fields.is_empty() + { + let a = self.tabs[i] + .dyn_active + .min(self.tabs[i].dyn_fields.len() - 1); + self.tabs[i].dyn_fields[a].buffer = + (!live_input.is_empty()).then_some(live_input.clone()); + } self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); // Live incremental search for INSERT/MINSERT: update picker on each keystroke diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index e6b282c80..606ffa871 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1580,8 +1580,19 @@ impl OpenCADStudio { let apply_started = Instant::now(); let delta = snapped - grip.last_world; - let lengthen = grip.mode == GripEditMode::Lengthen; - let actions: Vec<_> = if lengthen { + let menu_action = match grip.mode { + GripEditMode::Lengthen => { + Some(crate::scene::model::object::GripMenuAction::Lengthen) + } + GripEditMode::Radius => { + Some(crate::scene::model::object::GripMenuAction::Radius) + } + GripEditMode::ArcLength => { + Some(crate::scene::model::object::GripMenuAction::ArcLength) + } + GripEditMode::Stretch => None, + }; + let actions: Vec<_> = if menu_action.is_some() { Vec::new() } else { grip.targets @@ -1596,14 +1607,13 @@ impl OpenCADStudio { }) .collect() }; - if lengthen { + if let Some(action) = menu_action { let original = self .grip_originals .iter() .find(|(handle, _)| *handle == grip.handle) .map(|(_, entity)| entity.clone()); if let Some(original) = original { - let action = crate::scene::model::object::GripMenuAction::Lengthen; let value = crate::scene::view::dispatch::grip_menu_point_value( &original, grip.grip_id, @@ -3232,7 +3242,10 @@ impl OpenCADStudio { // Engaging click — stay hot, wait for the placement click. return Task::none(); } - if grip.mode == GripEditMode::Lengthen { + if matches!( + grip.mode, + GripEditMode::Lengthen | GripEditMode::Radius | GripEditMode::ArcLength + ) { self.grip_pending = None; self.command_line.input.clear(); } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3373e1aeb..3c33785d5 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -927,7 +927,32 @@ bg={bg_ms:.1}ms n={view_count}" // A command may drive a typed scalar by mouse (e.g. a // perpendicular distance to a picked object); show that live // value in the box until the user types over it. - let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)); + let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)).or_else(|| { + let grip = tab.active_grip.as_ref()?; + let action = match grip.mode { + crate::scene::pick::grip::GripEditMode::Lengthen => { + crate::scene::model::object::GripMenuAction::Lengthen + } + crate::scene::pick::grip::GripEditMode::Radius => { + crate::scene::model::object::GripMenuAction::Radius + } + crate::scene::pick::grip::GripEditMode::ArcLength => { + crate::scene::model::object::GripMenuAction::ArcLength + } + _ => return None, + }; + let original = self + .grip_originals + .iter() + .find(|(handle, _)| *handle == grip.handle) + .map(|(_, entity)| entity)?; + crate::scene::view::dispatch::grip_menu_point_value( + original, + grip.grip_id, + action, + w, + ) + }); let boxes: Vec = tab .dyn_fields .iter() @@ -1687,17 +1712,28 @@ bg={bg_ms:.1}ms n={view_count}" // viewport stack rather than as a separate row in the main // column — frees up vertical space when no command is active // and keeps the input close to where the cursor is drawing. - // Autocomplete shows only when no command is collecting its - // own input (otherwise typed prefixes are coordinates / values). - let allow_autocomplete = tab.active_cmd.is_none(); + // Autocomplete shows only when no command or grip-menu action is + // collecting its own input. A pending Lengthen / Radius / Arc Length + // value is numeric input, not the prefix of a new command (for example, + // `3` must not open the 3DALIGN / 3DARRAY suggestions). + let allow_autocomplete = tab.active_cmd.is_none() && self.grip_pending.is_none(); // Dynamic input captures keystrokes when its fields are showing, // so the command-line field must release focus / its on_input. // The MText preview also captures keystrokes (typing edits it), so the // command line must likewise release its on_input there. + let interactive_value_grip = tab.active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + crate::scene::pick::grip::GripEditMode::Lengthen + | crate::scene::pick::grip::GripEditMode::Radius + | crate::scene::pick::grip::GripEditMode::ArcLength + ) + }); let dyn_capturing = (self.dyn_input && (tab.active_cmd.is_some() || tab.active_grip.is_some()) - && !tab.dyn_fields.is_empty()) + && !tab.dyn_fields.is_empty() + && !interactive_value_grip) || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) || self.text_inline.is_some(); // The workspace row is: left edge stack, viewport, right edge stack. diff --git a/src/entities/arc.rs b/src/entities/arc.rs index 3f2ffaffd..b9e0b1172 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -365,13 +365,32 @@ impl crate::entities::traits::Grippable for Arc { point: glam::DVec3, ) -> Option { use crate::scene::model::object::GripMenuAction as A; - if !matches!(action, A::Lengthen) || self.radius <= 1.0e-9 { + if self.radius <= 1.0e-9 { return None; } let (x, y, _) = crate::scene::view::transform::wcs_point_to_ocs( (point.x, point.y, point.z), (self.normal.x, self.normal.y, self.normal.z), ); + if matches!(action, A::Radius) && grip_id == 3 { + let radius = ((x - self.center.x).powi(2) + (y - self.center.y).powi(2)).sqrt(); + return (radius > 1.0e-9).then_some(radius); + } + if matches!(action, A::ArcLength) && grip_id == 3 { + let sweep = (self.end_angle - self.start_angle).rem_euclid(TAU); + let middle_angle = self.start_angle + sweep * 0.5; + let middle_x = self.center.x + self.radius * middle_angle.cos(); + let middle_y = self.center.y + self.radius * middle_angle.sin(); + let tangent_x = -middle_angle.sin(); + let tangent_y = middle_angle.cos(); + let delta = (x - middle_x) * tangent_x + (y - middle_y) * tangent_y; + let length = self.radius * sweep + delta; + return (length > 1.0e-9 && length < self.radius * TAU - 1.0e-9) + .then_some(length); + } + if !matches!(action, A::Lengthen) { + return None; + } let cursor_angle = (y - self.center.y).atan2(x - self.center.x); let current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU); let desired_sweep = match grip_id { diff --git a/src/entities/circle.rs b/src/entities/circle.rs index bc037abfb..7d596506e 100644 --- a/src/entities/circle.rs +++ b/src/entities/circle.rs @@ -256,7 +256,89 @@ impl RenderConvertible for Circle { } } -crate::impl_entity_basics!(Circle); +impl crate::entities::traits::Grippable for Circle { + fn grips(&self) -> Vec { + grips(self) + } + + fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + apply_grip(self, grip_id, apply); + } + + fn grip_menu( + &self, + grip_id: usize, + ) -> Vec { + use crate::scene::model::object::{GripMenuAction, GripMenuItem}; + let mut items = vec![GripMenuItem { + label: "Stretch", + action: GripMenuAction::Stretch, + }]; + if (1..=4).contains(&grip_id) { + items.push(GripMenuItem { + label: "Radius", + action: GripMenuAction::Radius, + }); + } + items + } + + fn grip_menu_value_prompt( + &self, + _grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + ) -> Option<&'static str> { + matches!(action, crate::scene::model::object::GripMenuAction::Radius) + .then_some("New radius") + } + + fn grip_menu_point_value( + &self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + point: glam::DVec3, + ) -> Option { + if !matches!(action, crate::scene::model::object::GripMenuAction::Radius) + || !(1..=4).contains(&grip_id) + { + return None; + } + let plane = crate::entities::curve::circle_curve(self).plane; + let point = plane.project(point.to_array())?; + let radius = (point[0] - self.center.x).hypot(point[1] - self.center.y); + (radius > 1.0e-9).then_some(radius) + } + + fn apply_grip_menu_value( + &mut self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + value: f64, + ) { + if matches!(action, crate::scene::model::object::GripMenuAction::Radius) + && (1..=4).contains(&grip_id) + && value > 1.0e-9 + { + self.radius = value; + } + } +} + +impl crate::entities::traits::PropertyEditable for Circle { + fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { + properties(self) + } + + fn apply_geom_prop(&mut self, field: &str, value: &str) { + apply_geom_prop(self, field, value); + } +} + +impl crate::entities::traits::Transformable for Circle { + fn apply_transform(&mut self, transform: &EntityTransform) { + apply_transform(self, transform); + } +} impl crate::entities::traits::MassPropsCalc for Circle { fn mass_props(&self) -> crate::entities::traits::MassProps { @@ -276,6 +358,28 @@ impl crate::entities::traits::MassPropsCalc for Circle { mod tests { use super::*; + #[test] + fn quadrant_grips_offer_interactive_radius() { + use crate::entities::traits::Grippable; + use crate::scene::model::object::GripMenuAction; + + let mut circle = Circle::default(); + circle.radius = 2.0; + + assert_eq!(circle.grip_menu(0).len(), 1); + assert!(circle + .grip_menu(1) + .iter() + .any(|item| item.action == GripMenuAction::Radius)); + assert_eq!( + circle.grip_menu_value_prompt(1, GripMenuAction::Radius), + Some("New radius") + ); + + circle.apply_grip_menu_value(1, GripMenuAction::Radius, 5.0); + assert_eq!(circle.radius, 5.0); + } + #[test] fn test_circle_segments_scales_with_zoom() { let r = 10.0; @@ -361,4 +465,3 @@ mod tests { assert!(close_count > far_count, "{close_count} <= {far_count}"); } } - diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index e8ff3e72f..623eedaff 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -54,6 +54,8 @@ pub struct GripEdit { pub enum GripEditMode { Stretch, Lengthen, + Radius, + ArcLength, } #[derive(Clone, Debug)] @@ -92,6 +94,18 @@ impl GripEdit { edit.mode = GripEditMode::Lengthen; edit } + + pub fn radius(handle: Handle, grip_id: usize, world: DVec3) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::Radius; + edit + } + + pub fn arc_length(handle: Handle, grip_id: usize, world: DVec3) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::ArcLength; + edit + } } // ── Screen-space helpers ───────────────────────────────────────────────────