From 539d83de4735438adccda789f6ed0eb43c414597 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 4 Aug 2026 16:27:03 +0300 Subject: [PATCH] feat(grips): improve direct editing Refit arcs from fixed control points and highlight hovered grips. Let Lengthen preview from the cursor or accept a typed distance. --- src/app/mod.rs | 6 +- src/app/update/command.rs | 46 +++++++++- src/app/update/viewport.rs | 108 ++++++++++++++++++++---- src/app/view/mod.rs | 6 ++ src/entities/arc.rs | 168 ++++++++++++++++++++++++++++--------- src/entities/line.rs | 28 +++++++ src/entities/traits.rs | 39 +++++++++ src/scene/pick/grip.rs | 15 ++++ src/scene/view/dispatch.rs | 51 +++++++++++ src/ui/overlay.rs | 13 +++ 10 files changed, 421 insertions(+), 59 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index de2ac839e..aef5d70b9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -85,9 +85,9 @@ pub struct GripPopup { } /// 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)`. +/// (Lengthen / Radius / Arc Length / Rotate Text). Lengthen can also resolve +/// from a viewport point; otherwise the next typed number is parsed and routed +/// into `apply_grip_menu_value` for `(handle, grip_id, action)`. #[derive(Clone, Debug)] pub struct GripPendingValue { pub handle: acadrust::Handle, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index a69551fec..09a2dd977 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -9,7 +9,9 @@ use crate::app::helpers::{ }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; +use crate::scene::pick::grip::{ + find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode, +}; use crate::scene::model::object::GripApply; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, @@ -242,9 +244,21 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { "{}: expected a number, got \"{raw}\"", pending.label ).as_ref()); - return Task::none(); + self.grip_pending = Some(pending); + return self.focus_cmd_input(); }; let i = self.active_tab; + let interactive_lengthen = self.tabs[i] + .active_grip + .as_ref() + .is_some_and(|grip| { + grip.mode == GripEditMode::Lengthen + && grip.handle == pending.handle + && grip.grip_id == pending.grip_id + }); + if interactive_lengthen { + self.cancel_active_grip_edit(); + } 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) @@ -576,6 +590,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if self.visibility_popup.take().is_some() { return Task::none(); } + if self.tabs[self.active_tab].active_grip.is_some() { + self.grip_pending = None; + self.command_line.input.clear(); + if self.cancel_active_grip_edit() { + return Task::none(); + } + } if self.grip_pending.take().is_some() { self.command_line.input.clear(); return Task::none(); @@ -987,7 +1008,26 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { action: item.action, label, }); - self.command_line.push_info(crate::tf!("{label}:").as_ref()); + if matches!(item.action, GripMenuAction::Lengthen) { + if let Some((_, grip)) = self.tabs[i] + .selected_grip_handles + .iter() + .zip(self.tabs[i].selected_grips.iter()) + .find(|(owner, grip)| { + **owner == popup.handle && grip.id == popup.grip_id + }) + { + self.tabs[i].active_grip = Some(GripEdit::lengthen( + popup.handle, + popup.grip_id, + grip.world, + )); + } + 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()); + } return self.focus_cmd_input(); } // Break at vertex replaces the entity with the split pieces — diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index a967499d3..33e0ba221 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -11,7 +11,7 @@ use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; use crate::scene::model::object::GripApply; use crate::scene::pick::grip::{ - find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripTarget, + find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode, GripTarget, }; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, @@ -344,6 +344,7 @@ impl OpenCADStudio { grip_id, origin_world: world, last_world: world, + mode: GripEditMode::Stretch, targets, } } @@ -1003,18 +1004,91 @@ impl OpenCADStudio { let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0; let apply_started = Instant::now(); let delta = snapped - grip.last_world; - let actions: Vec<_> = grip - .targets - .iter() - .map(|target| { - let apply = if target.is_translate { - GripApply::Translate(delta) - } else { - GripApply::Absolute(target.last_world + delta) - }; - (target.handle, target.grip_id, apply) - }) - .collect(); + let lengthen = grip.mode == GripEditMode::Lengthen; + let actions: Vec<_> = if lengthen { + Vec::new() + } else { + grip.targets + .iter() + .map(|target| { + let apply = if target.is_translate { + GripApply::Translate(delta) + } else { + GripApply::Absolute(target.last_world + delta) + }; + (target.handle, target.grip_id, apply) + }) + .collect() + }; + if lengthen { + 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, + action, + snapped, + ); + if let Some(value) = value { + if let Some(current) = + self.tabs[i].scene.document.get_entity_mut(grip.handle) + { + *current = original; + crate::entities::traits::EntityTypeOps::apply_grip_menu_value( + current, + grip.grip_id, + action, + value, + ); + } + } + } + } + // Arc point grips are coupled: moving one point must rebuild the + // circle from the drag-start start/middle/end set, otherwise the + // supposedly fixed points drift a little on every mouse event. + let mut arc_grip_edits: rustc_hash::FxHashMap< + Handle, + Vec<(usize, glam::DVec3)>, + > = rustc_hash::FxHashMap::default(); + for (handle, grip_id, apply) in &actions { + if let GripApply::Absolute(point) = apply { + if (1..=3).contains(grip_id) { + arc_grip_edits + .entry(*handle) + .or_default() + .push((*grip_id, *point)); + } + } + } + let mut rebuilt_arcs = rustc_hash::FxHashSet::default(); + for (handle, edits) in arc_grip_edits { + let original = self + .grip_originals + .iter() + .find(|(original_handle, _)| *original_handle == handle) + .map(|(_, entity)| entity.clone()); + let Some(original) = original else { + continue; + }; + let Some(current) = self.tabs[i].scene.document.get_entity_mut(handle) else { + continue; + }; + if crate::scene::view::dispatch::refit_arc_grips( + current, + &original, + &edits, + ) + .is_some() + { + rebuilt_arcs.insert(handle); + } + } let refit_arc_targets: Vec<_> = grip .targets .iter() @@ -1033,7 +1107,9 @@ impl OpenCADStudio { }) .collect(); for (handle, grip_id, apply) in actions { - self.tabs[i].scene.apply_grip(handle, grip_id, apply); + if !rebuilt_arcs.contains(&handle) { + self.tabs[i].scene.apply_grip(handle, grip_id, apply); + } } for (handle, vertex_id, original_bulge) in refit_arc_targets { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { @@ -2334,6 +2410,10 @@ impl OpenCADStudio { // Engaging click — stay hot, wait for the placement click. return Task::none(); } + if grip.mode == GripEditMode::Lengthen { + self.grip_pending = None; + self.command_line.input.clear(); + } let added_vertex_focus = grip.targets.iter().find_map(|target| { let original = self .grip_originals diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 596a8b2f2..7259e8034 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -451,10 +451,16 @@ impl OpenCADStudio { || (Some(handle) == sel_h && Some(grip_id) == current_vertex_grip) }); + let is_hovered = owner.is_some_and(|handle| { + self.grip_hover.as_ref().is_some_and(|hover| { + hover.handle == handle && hover.grip_id == grip_id + }) + }); crate::ui::overlay::GripMarker { pos: screen, shape, is_hot, + is_hovered, dir, } }) diff --git a/src/entities/arc.rs b/src/entities/arc.rs index 0f9e9ddd2..2e08abcef 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -4,7 +4,8 @@ use truck_modeling::{builder, Point3}; use crate::command::EntityTransform; use crate::entities::common::{ - center_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_f64, ro_prop as ro, square_grip, + center_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_f64, ro_prop as ro, + square_grip, }; use crate::entities::traits::TruckConvertible; use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject}; @@ -103,26 +104,114 @@ fn to_truck(arc: &Arc) -> TruckEntity { } } -fn angle_span(start: f32, end: f32) -> f32 { - let mut span = end - start; - if span < 0.0 { - span += std::f32::consts::TAU; +fn control_points(arc: &Arc) -> [glam::DVec3; 3] { + let center = glam::DVec3::new(arc.center.x, arc.center.y, arc.center.z); + let sweep = (arc.end_angle - arc.start_angle).rem_euclid(TAU); + let point = |angle: f64| { + center + + glam::DVec3::new( + arc.radius * angle.cos(), + arc.radius * angle.sin(), + 0.0, + ) + }; + [ + point(arc.start_angle), + point(arc.start_angle + sweep * 0.5), + point(arc.end_angle), + ] +} + +fn circumcircle( + a: glam::DVec3, + b: glam::DVec3, + c: glam::DVec3, +) -> Option<(glam::DVec3, f64)> { + // Work relative to the first point so large drawing coordinates do not + // lose the small differences that define the circle. + let ab = b - a; + let ac = c - a; + let bc = c - b; + let scale2 = ab + .length_squared() + .max(ac.length_squared()) + .max(bc.length_squared()); + if scale2 < 1.0e-18 { + return None; } - span + let det = 2.0 * (ab.x * ac.y - ab.y * ac.x); + if det.abs() <= scale2 * 1.0e-12 { + return None; + } + let ab2 = ab.x * ab.x + ab.y * ab.y; + let ac2 = ac.x * ac.x + ac.y * ac.y; + let center = a + + glam::DVec3::new( + (ab2 * ac.y - ac2 * ab.y) / det, + (ab.x * ac2 - ac.x * ab2) / det, + 0.0, + ); + let radius = center.distance(a); + radius.is_finite().then_some((center, radius)) +} + +pub(crate) fn refit_grips( + arc: &mut Arc, + original: &Arc, + edits: &[(usize, glam::DVec3)], +) -> bool { + let mut points = control_points(original); + let mut changed = false; + for &(grip_id, point) in edits { + let index = match grip_id { + 1 => 0, + 2 => 2, + 3 => 1, + _ => continue, + }; + points[index] = point; + changed = true; + } + if !changed { + return false; + } + + let Some((center, radius)) = circumcircle(points[0], points[1], points[2]) else { + return false; + }; + if radius <= 1.0e-9 { + return false; + } + + let start = (points[0].y - center.y).atan2(points[0].x - center.x); + let middle = (points[1].y - center.y).atan2(points[1].x - center.x); + let end = (points[2].y - center.y).atan2(points[2].x - center.x); + let sweep = (end - start).rem_euclid(TAU); + let middle_sweep = (middle - start).rem_euclid(TAU); + // Crossing the two fixed points makes the three-point definition + // degenerate before it reverses. Keep the last valid preview instead of + // swapping the start and end grip identities under the cursor. + if sweep <= 1.0e-9 || middle_sweep > sweep + 1.0e-9 { + return false; + } + + arc.center.x = center.x; + arc.center.y = center.y; + arc.center.z = original.center.z; + arc.radius = radius; + arc.start_angle = start; + arc.end_angle = end; + true } fn grips(arc: &Arc) -> Vec { let ctr = glam::DVec3::new(arc.center.x, arc.center.y, arc.center.z); - let r = arc.radius; - let sa = arc.start_angle as f32; - let ea = arc.end_angle as f32; - let ma = (sa + angle_span(sa, ea) * 0.5) as f64; - let (sa, ea) = (arc.start_angle, arc.end_angle); + let [start, middle, end] = control_points(arc); vec![ center_grip(0, ctr), - square_grip(1, ctr + glam::DVec3::new(r * sa.cos(), r * sa.sin(), 0.0)), - square_grip(2, ctr + glam::DVec3::new(r * ea.cos(), r * ea.sin(), 0.0)), - center_grip(3, ctr + glam::DVec3::new(r * ma.cos(), r * ma.sin(), 0.0)), + square_grip(1, start), + square_grip(2, end), + square_grip(3, middle), ] } @@ -204,31 +293,9 @@ fn apply_grip(arc: &mut Arc, grip_id: usize, apply: GripApply) { arc.center.y = p.y; arc.center.z = p.z; } - (1, GripApply::Absolute(p)) => { - let dx = p.x - arc.center.x; - let dy = p.y - arc.center.y; - arc.start_angle = dy.atan2(dx); - } - (2, GripApply::Absolute(p)) => { - let dx = p.x - arc.center.x; - let dy = p.y - arc.center.y; - arc.end_angle = dy.atan2(dx); - } - (3, GripApply::Translate(d)) => { - let sa = arc.start_angle; - let ea = arc.end_angle; - let span = angle_span(sa as f32, ea as f32) as f64; - let mid_a = sa + span * 0.5; - let current_mid_x = arc.center.x + arc.radius * mid_a.cos(); - let current_mid_y = arc.center.y + arc.radius * mid_a.sin(); - let new_mid_x = current_mid_x + d.x; - let new_mid_y = current_mid_y + d.y; - let dx = new_mid_x - arc.center.x; - let dy = new_mid_y - arc.center.y; - let new_r = (dx * dx + dy * dy).sqrt(); - if new_r > 1e-6 { - arc.radius = new_r; - } + (1..=3, GripApply::Absolute(p)) => { + let original = arc.clone(); + let _ = refit_grips(arc, &original, &[(grip_id, p)]); } _ => {} } @@ -316,6 +383,29 @@ impl crate::entities::traits::Grippable for Arc { } } + fn grip_menu_point_value( + &self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + point: glam::DVec3, + ) -> Option { + use crate::scene::model::object::GripMenuAction as A; + if !matches!(action, A::Lengthen) || self.radius <= 1.0e-9 { + return None; + } + let cursor_angle = (point.y - self.center.y).atan2(point.x - self.center.x); + let current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU); + let desired_sweep = match grip_id { + 1 => (self.end_angle - cursor_angle).rem_euclid(TAU), + 2 => (cursor_angle - self.start_angle).rem_euclid(TAU), + _ => return None, + }; + if desired_sweep <= 1.0e-9 { + return None; + } + Some((desired_sweep - current_sweep) * self.radius) + } + fn apply_grip_menu_value( &mut self, grip_id: usize, diff --git a/src/entities/line.rs b/src/entities/line.rs index 828f47925..453657949 100644 --- a/src/entities/line.rs +++ b/src/entities/line.rs @@ -210,6 +210,34 @@ impl crate::entities::traits::Grippable for Line { } } + fn grip_menu_point_value( + &self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + point: glam::DVec3, + ) -> Option { + use crate::scene::model::object::GripMenuAction as A; + if !matches!(action, A::Lengthen) { + return None; + } + let direction = glam::DVec3::new( + self.end.x - self.start.x, + self.end.y - self.start.y, + self.end.z - self.start.z, + ); + let length = direction.length(); + if length < 1.0e-12 { + return None; + } + let unit = direction / length; + let value = match grip_id { + 0 => (glam::DVec3::new(self.start.x, self.start.y, self.start.z) - point).dot(unit), + 1 => (point - glam::DVec3::new(self.end.x, self.end.y, self.end.z)).dot(unit), + _ => return None, + }; + (length + value > 1.0e-9).then_some(value) + } + fn apply_grip_menu_value( &mut self, grip_id: usize, diff --git a/src/entities/traits.rs b/src/entities/traits.rs index 980ec0f7b..963cc5db7 100644 --- a/src/entities/traits.rs +++ b/src/entities/traits.rs @@ -57,6 +57,17 @@ pub trait Grippable { ) -> Option<&'static str> { None } + /// Convert an interactive cursor point into the numeric value consumed by + /// `apply_grip_menu_value`. Actions without a point-driven form return + /// `None`. + fn grip_menu_point_value( + &self, + _grip_id: usize, + _action: GripMenuAction, + _point: glam::DVec3, + ) -> Option { + None + } } pub trait PropertyEditable { @@ -163,6 +174,12 @@ pub trait EntityTypeOps { grip_id: usize, action: GripMenuAction, ) -> Option<&'static str>; + fn grip_menu_point_value( + &self, + grip_id: usize, + action: GripMenuAction, + point: glam::DVec3, + ) -> Option; fn apply_grip_menu_value( &mut self, grip_id: usize, @@ -405,6 +422,28 @@ impl EntityTypeOps for EntityType { ) } + fn grip_menu_point_value( + &self, + grip_id: usize, + action: GripMenuAction, + point: glam::DVec3, + ) -> Option { + dispatch!(self, + |e| Grippable::grip_menu_point_value(e, grip_id, action, point), + [ + Line, Circle, Arc, Ellipse, LwPolyline, Polyline, Polyline2D, + Polyline3D, Ray, XLine, RasterImage, Wipeout, + AttributeDefinition, AttributeEntity, MLine, Tolerance, + Solid, Solid3D, Region, Body, Surface, Face3D, PolygonMesh, + PolyfaceMesh, Mesh, Table, Point, Spline, Text, MText, + Viewport, Insert, Leader, MultiLeader, Dimension, Hatch, + Underlay, Shape, Ole2Frame, SectionSymbol, ViewBorder, + Extended, + ], + _ => None, + ) + } + fn apply_grip_menu_value( &mut self, grip_id: usize, diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index ee1931055..263ccc291 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -24,10 +24,18 @@ pub struct GripEdit { pub origin_world: DVec3, /// Last world-space cursor position (needed for incremental delta on translate drags). pub last_world: DVec3, + /// How cursor movement modifies the selected grip. + pub mode: GripEditMode, /// Every hot grip moved by this edit. A normal grip edit contains one target. pub targets: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GripEditMode { + Stretch, + Lengthen, +} + #[derive(Clone, Debug)] pub struct GripTarget { pub handle: Handle, @@ -48,6 +56,7 @@ impl GripEdit { grip_id, origin_world: world, last_world: world, + mode: GripEditMode::Stretch, targets: vec![GripTarget { handle, grip_id, @@ -56,6 +65,12 @@ impl GripEdit { }], } } + + pub fn lengthen(handle: Handle, grip_id: usize, world: DVec3) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::Lengthen; + edit + } } // ── Screen-space helpers ─────────────────────────────────────────────────── diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index 893e0c08a..b7deff19a 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -245,6 +245,57 @@ pub fn apply_grip(entity: &mut EntityType, grip_id: usize, apply: crate::scene:: EntityTypeOps::apply_grip(entity, grip_id, apply); } +/// Rebuild an arc from its drag-start control points and the point grips moved +/// in the current frame. Returns `None` for non-arc entities and `Some(false)` +/// when the requested points are temporarily degenerate. +pub fn refit_arc_grips( + entity: &mut EntityType, + original: &EntityType, + edits: &[(usize, glam::DVec3)], +) -> Option { + let EntityType::Arc(arc) = entity else { + return None; + }; + let EntityType::Arc(original_arc) = original else { + return None; + }; + let edits = match planar_ocs_normal(original) { + Some(normal) => edits + .iter() + .map(|&(grip_id, point)| { + let (x, y, z) = + super::transform::wcs_point_to_ocs((point.x, point.y, point.z), normal); + (grip_id, glam::DVec3::new(x, y, z)) + }) + .collect::>(), + None => edits.to_vec(), + }; + Some(crate::entities::arc::refit_grips( + arc, + original_arc, + &edits, + )) +} + +/// Convert a world-space cursor point into the value expected by an +/// interactive grip-menu action. +pub fn grip_menu_point_value( + entity: &EntityType, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + point: glam::DVec3, +) -> Option { + let point = match planar_ocs_normal(entity) { + Some(normal) => { + let (x, y, z) = + super::transform::wcs_point_to_ocs((point.x, point.y, point.z), normal); + glam::DVec3::new(x, y, z) + } + None => point, + }; + EntityTypeOps::grip_menu_point_value(entity, grip_id, action, point) +} + pub fn apply_transform(entity: &mut EntityType, t: &EntityTransform) { EntityTypeOps::apply_transform(entity, t); } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 2969c4006..5995b322d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -26,6 +26,8 @@ pub struct GripMarker { pub shape: GripShape, /// True → grip is currently being dragged (drawn filled red). pub is_hot: bool, + /// True → pointer is over this grip (drawn with the hover fill). + pub is_hovered: bool, /// World-XY direction vector — only consumed by the `Rectangle` /// shape to orient the box along its segment. `None` for grips /// that don't need rotation. @@ -338,6 +340,17 @@ fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) if grip.is_hot { frame.fill(&path, theme.palette().danger.base.color); + } else if grip.is_hovered { + let pair = theme.palette().primary.strong; + frame.fill(&path, pair.color); + frame.stroke( + &path, + canvas::Stroke { + width: 1.5, + style: canvas::Style::Solid(pair.text), + ..Default::default() + }, + ); } else { let palette = theme.palette(); let color = palette.primary.base.color;