From 0ea220f1202817aa2276b0f9c64d15bf3ea60ff4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 13 Sep 2026 19:19:19 +0200 Subject: [PATCH] Add parallel offset grip editing for polylines --- src/app/update/command.rs | 16 ++++ src/app/update/dynamic.rs | 3 + src/app/update/viewport.rs | 4 + src/app/view/mod.rs | 4 + src/entities/lwpolyline.rs | 175 ++++++++++++++++++++++++++++++++++++- src/scene/model/object.rs | 1 + src/scene/pick/grip.rs | 7 ++ 7 files changed, 206 insertions(+), 4 deletions(-) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index a23ddf81b..9ee276552 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -211,6 +211,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { | GripEditMode::ArcLength | GripEditMode::RectangleWidth | GripEditMode::RectangleHeight + | GripEditMode::MoveParallel ) }) { self.command_line.input.push_str(&s); @@ -266,6 +267,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { | GripEditMode::ArcLength | GripEditMode::RectangleWidth | GripEditMode::RectangleHeight + | GripEditMode::MoveParallel ) }) { self.command_line.input.pop(); @@ -326,6 +328,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::scene::model::object::GripMenuAction::RectangleHeight => { Some(GripEditMode::RectangleHeight) } + crate::scene::model::object::GripMenuAction::MoveParallel => { + Some(GripEditMode::MoveParallel) + } _ => None, }; expected_mode.is_some_and(|mode| { @@ -384,6 +389,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { ) | ( GripEditMode::RectangleHeight, crate::scene::model::object::GripMenuAction::RectangleHeight, + ) | ( + GripEditMode::MoveParallel, + crate::scene::model::object::GripMenuAction::MoveParallel, ) ) && grip.handle == pending.handle @@ -1522,6 +1530,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { | GripMenuAction::ArcLength | GripMenuAction::RectangleWidth | GripMenuAction::RectangleHeight + | GripMenuAction::MoveParallel ) { if let Some((_, grip)) = self.tabs[i] .selected_grip_handles @@ -1561,6 +1570,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { popup.grip_id, grip.world, ), + GripMenuAction::MoveParallel => GripEdit::move_parallel( + popup.handle, + popup.grip_id, + grip.world, + ), _ => GripEdit::lengthen( popup.handle, popup.grip_id, @@ -1586,6 +1600,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.command_line.push_info("Specify point or enter width:"); } else if matches!(item.action, GripMenuAction::RectangleHeight) { self.command_line.push_info("Specify point or enter height:"); + } else if matches!(item.action, GripMenuAction::MoveParallel) { + self.command_line.push_info("Specify point or enter parallel offset:"); } else { self.command_line.push_info( crate::t!("Specify point or enter distance:").as_ref(), diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 89c8c0138..2e9161ac0 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -97,6 +97,9 @@ impl OpenCADStudio { crate::scene::pick::grip::GripEditMode::RectangleHeight => { (grip.origin_world, Some(crate::command::DynRole::Height)) } + crate::scene::pick::grip::GripEditMode::MoveParallel => { + (grip.origin_world, Some(crate::command::DynRole::Distance)) + } crate::scene::pick::grip::GripEditMode::RectangleResize => { (grip.origin_world, None) } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index f9a0ff386..850047811 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1621,6 +1621,9 @@ impl OpenCADStudio { GripEditMode::RectangleHeight => { Some(crate::scene::model::object::GripMenuAction::RectangleHeight) } + GripEditMode::MoveParallel => { + Some(crate::scene::model::object::GripMenuAction::MoveParallel) + } GripEditMode::RectangleResize => None, GripEditMode::Stretch => None, }; @@ -3332,6 +3335,7 @@ impl OpenCADStudio { grip.mode, GripEditMode::Lengthen | GripEditMode::Radius | GripEditMode::ArcLength | GripEditMode::RectangleWidth | GripEditMode::RectangleHeight + | GripEditMode::MoveParallel ) { self.grip_pending = None; self.command_line.input.clear(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 7e937e0e0..b1c1d366f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -996,6 +996,9 @@ bg={bg_ms:.1}ms n={view_count}" crate::scene::pick::grip::GripEditMode::RectangleHeight => { crate::scene::model::object::GripMenuAction::RectangleHeight } + crate::scene::pick::grip::GripEditMode::MoveParallel => { + crate::scene::model::object::GripMenuAction::MoveParallel + } _ => return None, }; let original = self @@ -1865,6 +1868,7 @@ bg={bg_ms:.1}ms n={view_count}" | crate::scene::pick::grip::GripEditMode::ArcLength | crate::scene::pick::grip::GripEditMode::RectangleWidth | crate::scene::pick::grip::GripEditMode::RectangleHeight + | crate::scene::pick::grip::GripEditMode::MoveParallel ) }); let dyn_capturing = diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index e0c1c26bf..f5c74faa0 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -1162,6 +1162,73 @@ fn apply_grip(pline: &mut LwPolyline, grip_id: usize, apply: GripApply) { } } +fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { + let n = pline.vertices.len(); + let count = if pline.is_closed { + n + } else { + n.saturating_sub(1) + }; + if seg >= count || !offset.is_finite() { + return; + } + let i0 = seg; + let i1 = (seg + 1) % n; + if pline.vertices[i0].bulge.abs() >= 1.0e-9 { + return; + } + let p0 = Vec2::new(pline.vertices[i0].location.x, pline.vertices[i0].location.y); + let p1 = Vec2::new(pline.vertices[i1].location.x, pline.vertices[i1].location.y); + let selected = p1 - p0; + let length = selected.length(); + if length <= Tolerance::default().linear() { + return; + } + let normal = Vec2::new(-selected.y / length, selected.x / length); + let q0 = p0 + normal * offset; + + let intersect = |a: Vec2, direction: Vec2| -> Option { + let cross = direction.x * selected.y - direction.y * selected.x; + if cross.abs() < 1.0e-9 * direction.length().max(1.0) * length.max(1.0) { + return None; + } + let delta = q0 - a; + let t = (delta.x * selected.y - delta.y * selected.x) / cross; + Some(a + direction * t) + }; + + let new0 = if pline.is_closed || seg > 0 { + let previous = (i0 + n - 1) % n; + let a = Vec2::new( + pline.vertices[previous].location.x, + pline.vertices[previous].location.y, + ); + let direction = p0 - a; + let Some(point) = intersect(a, direction) else { + return; + }; + point + } else { + q0 + }; + let new1 = if pline.is_closed || seg + 1 < count { + let next = (i1 + 1) % n; + let d = Vec2::new( + pline.vertices[next].location.x, + pline.vertices[next].location.y, + ); + let direction = d - p1; + let Some(point) = intersect(p1, direction) else { + return; + }; + point + } else { + p1 + normal * offset + }; + pline.vertices[i0].location = acadrust::types::Vector2::new(new0.x, new0.y); + pline.vertices[i1].location = acadrust::types::Vector2::new(new1.x, new1.y); +} + fn apply_transform(pline: &mut LwPolyline, t: &EntityTransform) { crate::scene::view::transform::apply_standard_entity_transform(pline, t, |entity, p1, p2| { for v in &mut entity.vertices { @@ -1240,6 +1307,12 @@ impl crate::entities::traits::Grippable for LwPolyline { } }; let mut items = Vec::new(); + if !is_arc { + items.push(GripMenuItem { + label: "Move Parallel", + action: GripMenuAction::MoveParallel, + }); + } if is_rectangle(self) && seg < 4 { // Moving an edge changes the dimension perpendicular to it. items.push(GripMenuItem { @@ -1272,6 +1345,16 @@ impl crate::entities::traits::Grippable for LwPolyline { ) -> Option<&'static str> { use crate::scene::model::object::GripMenuAction as A; let n = self.vertices.len(); + if action == A::MoveParallel && grip_id >= n { + let seg = grip_id - n; + let count = if self.is_closed { + n + } else { + n.saturating_sub(1) + }; + return (seg < count && self.vertices[seg].bulge.abs() < 1.0e-9) + .then_some("Parallel offset"); + } (is_rectangle(self) && n == 4 && (n..n + 4).contains(&grip_id) @@ -1289,6 +1372,32 @@ impl crate::entities::traits::Grippable for LwPolyline { point: glam::DVec3, ) -> Option { use crate::scene::model::object::GripMenuAction as A; + if action == A::MoveParallel { + let n = self.vertices.len(); + let seg = grip_id.checked_sub(n)?; + let count = if self.is_closed { + n + } else { + n.saturating_sub(1) + }; + if seg >= count || self.vertices[seg].bulge.abs() >= 1.0e-9 { + return None; + } + let plane = crate::entities::curve::lwpolyline_curve(self)?.plane; + let point = Vec2::from(plane.project(point.to_array())?); + let p0 = Vec2::new(self.vertices[seg].location.x, self.vertices[seg].location.y); + let p1 = Vec2::new( + self.vertices[(seg + 1) % n].location.x, + self.vertices[(seg + 1) % n].location.y, + ); + let direction = p1 - p0; + let length = direction.length(); + if length <= Tolerance::default().linear() { + return None; + } + let normal = Vec2::new(-direction.y / length, direction.x / length); + return Some((point - p0).dot(normal)); + } let (frame, plane) = rectangle_frame(self)?; let seg = grip_id.checked_sub(4)?; if seg >= 4 { @@ -1316,6 +1425,12 @@ impl crate::entities::traits::Grippable for LwPolyline { value: f64, ) { use crate::scene::model::object::GripMenuAction as A; + if action == A::MoveParallel { + if let Some(seg) = grip_id.checked_sub(self.vertices.len()) { + move_segment_parallel(self, seg, value); + } + return; + } if value <= Tolerance::default().linear() { return; } @@ -1563,12 +1678,14 @@ mod tests { fn rectangle_midpoint_offers_dimension_before_stretch() { let pl = make_test_rectangle(); let bottom = pl.grip_menu(4); - assert_eq!(bottom[0].action, GripMenuAction::RectangleHeight); - assert_eq!(bottom[1].action, GripMenuAction::Stretch); + assert_eq!(bottom[0].action, GripMenuAction::MoveParallel); + assert_eq!(bottom[1].action, GripMenuAction::RectangleHeight); + assert_eq!(bottom[2].action, GripMenuAction::Stretch); let right = pl.grip_menu(5); - assert_eq!(right[0].action, GripMenuAction::RectangleWidth); - assert_eq!(right[1].action, GripMenuAction::Stretch); + assert_eq!(right[0].action, GripMenuAction::MoveParallel); + assert_eq!(right[1].action, GripMenuAction::RectangleWidth); + assert_eq!(right[2].action, GripMenuAction::Stretch); } #[test] @@ -1614,6 +1731,56 @@ mod tests { assert_eq!(value, Some(14.0)); } + fn polyline(points: &[(f64, f64)], closed: bool) -> LwPolyline { + let mut pl = LwPolyline::default(); + pl.is_closed = closed; + pl.vertices = points + .iter() + .map(|&(x, y)| LwVertex::new(Vector2::new(x, y))) + .collect(); + pl + } + + #[test] + fn move_parallel_reconnects_internal_edge_to_adjacent_lines() { + let mut pl = polyline( + &[(0.0, 0.0), (2.0, 2.0), (8.0, 2.0), (10.0, 0.0)], + false, + ); + move_segment_parallel(&mut pl, 1, 2.0); + assert_eq!(pl.vertices[0].location, Vector2::new(0.0, 0.0)); + assert_eq!(pl.vertices[1].location, Vector2::new(4.0, 4.0)); + assert_eq!(pl.vertices[2].location, Vector2::new(6.0, 4.0)); + assert_eq!(pl.vertices[3].location, Vector2::new(10.0, 0.0)); + } + + #[test] + fn move_parallel_translates_free_endpoint_of_open_polyline() { + let mut pl = polyline(&[(0.0, 0.0), (6.0, 0.0), (8.0, 2.0)], false); + move_segment_parallel(&mut pl, 0, 2.0); + assert_eq!(pl.vertices[0].location, Vector2::new(0.0, 2.0)); + assert_eq!(pl.vertices[1].location, Vector2::new(8.0, 2.0)); + assert_eq!(pl.vertices[2].location, Vector2::new(8.0, 2.0)); + } + + #[test] + fn move_parallel_wraps_closed_polyline_indices() { + let mut pl = polyline(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], true); + move_segment_parallel(&mut pl, 3, -1.0); + assert_eq!(pl.vertices[0].location, Vector2::new(1.0, 0.0)); + assert_eq!(pl.vertices[3].location, Vector2::new(1.0, 4.0)); + assert_eq!(pl.vertices[1].location, Vector2::new(4.0, 0.0)); + assert_eq!(pl.vertices[2].location, Vector2::new(4.0, 4.0)); + } + + #[test] + fn move_parallel_rejects_parallel_adjacent_line() { + let mut pl = polyline(&[(0.0, 0.0), (2.0, 0.0), (5.0, 0.0), (6.0, 2.0)], false); + let original = pl.vertices.clone(); + move_segment_parallel(&mut pl, 1, 1.0); + assert_eq!(pl.vertices, original); + } + #[test] fn test_lwpolyline_uniform_width_shows_global_width() { let pl = make_test_lwpolyline(3, 5.0); diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 8a6079de5..e5aed1d5d 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -178,6 +178,7 @@ pub enum GripMenuAction { RectangleWidth, RectangleHeight, RectangleResize, + MoveParallel, AddVertex, RemoveVertex, ConvertToArc, diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index f8f76e080..1326e3bb3 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -61,6 +61,7 @@ pub enum GripEditMode { RectangleWidth, RectangleHeight, RectangleResize, + MoveParallel, } #[derive(Clone, Debug)] @@ -125,6 +126,12 @@ impl GripEdit { edit } + pub fn move_parallel(handle: Handle, grip_id: usize, world: DVec3) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::MoveParallel; + edit + } + pub fn rectangle_resize( handle: Handle, grip_id: usize,