From 0ea220f1202817aa2276b0f9c64d15bf3ea60ff4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 13 Sep 2026 19:19:19 +0200 Subject: [PATCH 1/8] 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, From 775a891309896f8f77aa69243365abdf555e1144 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 13 Sep 2026 19:33:38 +0200 Subject: [PATCH 2/8] Support parallel offsets for arc polyline segments --- src/entities/lwpolyline.rs | 143 ++++++++++++++++++++++++++++++++++--- 1 file changed, 134 insertions(+), 9 deletions(-) diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index f5c74faa0..21f1d931c 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -1175,6 +1175,7 @@ fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { let i0 = seg; let i1 = (seg + 1) % n; if pline.vertices[i0].bulge.abs() >= 1.0e-9 { + move_arc_segment_parallel(pline, seg, offset); return; } let p0 = Vec2::new(pline.vertices[i0].location.x, pline.vertices[i0].location.y); @@ -1229,6 +1230,89 @@ fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { pline.vertices[i1].location = acadrust::types::Vector2::new(new1.x, new1.y); } +fn move_arc_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { + let n = pline.vertices.len(); + let i0 = seg; + let i1 = (seg + 1) % n; + 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 bulge = pline.vertices[i0].bulge; + let Some(arc) = crate::entities::common::BulgeArc::from_bulge(p0.into(), p1.into(), bulge) + else { + return; + }; + let center = Vec2::from(arc.center); + let radius = arc.radius + offset; + if radius <= Tolerance::default().linear() { + return; + } + + let line_circle_near = |a: Vec2, direction: Vec2, near: Vec2| -> Option { + let aa = direction.dot(direction); + if aa <= Tolerance::default().linear().powi(2) { + return None; + } + let relative = a - center; + let bb = 2.0 * relative.dot(direction); + let cc = relative.dot(relative) - radius * radius; + let discriminant = bb * bb - 4.0 * aa * cc; + if discriminant < -Tolerance::default().linear() { + return None; + } + let root = discriminant.max(0.0).sqrt(); + let first = a + direction * ((-bb - root) / (2.0 * aa)); + let second = a + direction * ((-bb + root) / (2.0 * aa)); + Some(if first.distance(near) <= second.distance(near) { + first + } else { + second + }) + }; + + 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 Some(point) = line_circle_near(a, p0 - a, p0) else { + return; + }; + point + } else { + center + (p0 - center) * (radius / arc.radius) + }; + let count = if pline.is_closed { n } else { n - 1 }; + 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 Some(point) = line_circle_near(p1, d - p1, p1) else { + return; + }; + point + } else { + center + (p1 - center) * (radius / arc.radius) + }; + + let start_angle = (new0.y - center.y).atan2(new0.x - center.x); + let end_angle = (new1.y - center.y).atan2(new1.x - center.x); + let sweep = if arc.sweep >= 0.0 { + (end_angle - start_angle).rem_euclid(TAU) + } else { + -(start_angle - end_angle).rem_euclid(TAU) + }; + let new_bulge = (sweep * 0.25).tan(); + if !new_bulge.is_finite() { + return; + } + pline.vertices[i0].location = acadrust::types::Vector2::new(new0.x, new0.y); + pline.vertices[i1].location = acadrust::types::Vector2::new(new1.x, new1.y); + pline.vertices[i0].bulge = new_bulge.clamp(-1.0e6, 1.0e6); +} + 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 { @@ -1307,12 +1391,10 @@ impl crate::entities::traits::Grippable for LwPolyline { } }; let mut items = Vec::new(); - if !is_arc { - items.push(GripMenuItem { - label: "Move Parallel", - action: GripMenuAction::MoveParallel, - }); - } + 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 { @@ -1352,8 +1434,7 @@ impl crate::entities::traits::Grippable for LwPolyline { } else { n.saturating_sub(1) }; - return (seg < count && self.vertices[seg].bulge.abs() < 1.0e-9) - .then_some("Parallel offset"); + return (seg < count).then_some("Parallel offset"); } (is_rectangle(self) && n == 4 @@ -1380,7 +1461,7 @@ impl crate::entities::traits::Grippable for LwPolyline { } else { n.saturating_sub(1) }; - if seg >= count || self.vertices[seg].bulge.abs() >= 1.0e-9 { + if seg >= count { return None; } let plane = crate::entities::curve::lwpolyline_curve(self)?.plane; @@ -1390,6 +1471,17 @@ impl crate::entities::traits::Grippable for LwPolyline { self.vertices[(seg + 1) % n].location.x, self.vertices[(seg + 1) % n].location.y, ); + let bulge = self.vertices[seg].bulge; + if bulge.abs() >= 1.0e-9 { + let arc = crate::entities::common::BulgeArc::from_bulge( + p0.into(), + p1.into(), + bulge, + )?; + let midpoint = Vec2::from(arc.sample(0.5)); + let radial = (midpoint - Vec2::from(arc.center)).normalize()?; + return Some((point - midpoint).dot(radial)); + } let direction = p1 - p0; let length = direction.length(); if length <= Tolerance::default().linear() { @@ -1688,6 +1780,15 @@ mod tests { assert_eq!(right[2].action, GripMenuAction::Stretch); } + #[test] + fn arc_midpoint_offers_move_parallel_first() { + let mut pl = make_test_lwpolyline(2, 0.0); + pl.vertices[0].bulge = 0.5; + let menu = pl.grip_menu(2); + assert_eq!(menu[0].action, GripMenuAction::MoveParallel); + assert_eq!(menu[1].action, GripMenuAction::Stretch); + } + #[test] fn rectangle_corner_offers_resize_before_stretch() { let pl = make_test_rectangle(); @@ -1754,6 +1855,30 @@ mod tests { assert_eq!(pl.vertices[3].location, Vector2::new(10.0, 0.0)); } + #[test] + fn move_parallel_offsets_arc_concentrically() { + let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0)], false); + pl.vertices[0].bulge = 1.0; + let original = crate::entities::common::BulgeArc::from_bulge( + [0.0, 0.0], + [10.0, 0.0], + pl.vertices[0].bulge, + ) + .unwrap(); + + move_segment_parallel(&mut pl, 0, 2.0); + + let moved = crate::entities::common::BulgeArc::from_bulge( + [pl.vertices[0].location.x, pl.vertices[0].location.y], + [pl.vertices[1].location.x, pl.vertices[1].location.y], + pl.vertices[0].bulge, + ) + .unwrap(); + assert!(Vec2::from(moved.center).distance(original.center.into()) < 1.0e-9); + assert!((moved.radius - original.radius - 2.0).abs() < 1.0e-9); + assert_eq!(moved.sweep.signum(), original.sweep.signum()); + } + #[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); From fb352c34fbe9f560c7c9dfd93e58b7446a2921a4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 14 Sep 2026 10:58:45 +0200 Subject: [PATCH 3/8] Add radius grip editing for LWPOLYLINE arcs --- src/entities/lwpolyline.rs | 229 ++++++++++++++++++++++++++++++++++++- 1 file changed, 228 insertions(+), 1 deletion(-) diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 21f1d931c..a8f1bd552 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -1,6 +1,7 @@ use acadrust::entities::{LwPolyline, LwVertex}; use cadkernel::geom2d::{ - signed_area, Curve, Polyline, PolylineVertex, RectangleFrame, Tolerance, Vec2, + fillet_between_rays, signed_area, Curve, Polyline, PolylineVertex, RectangleFrame, Tolerance, + Vec2, }; use crate::t; @@ -1313,6 +1314,102 @@ fn move_arc_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { pline.vertices[i0].bulge = new_bulge.clamp(-1.0e6, 1.0e6); } +/// Return the corner and the two rays retained by the straight segments on +/// either side of an internal arc. Open endpoint arcs have no fillet frame. +fn arc_fillet_frame(pline: &LwPolyline, seg: usize) -> Option<(Vec2, Vec2, Vec2)> { + let n = pline.vertices.len(); + let count = if pline.is_closed { n } else { n.checked_sub(1)? }; + if seg >= count || (!pline.is_closed && (seg == 0 || seg + 1 == count)) { + return None; + } + let i1 = (seg + 1) % n; + let previous = (seg + n - 1) % n; + let next = (i1 + 1) % n; + if pline.vertices[previous].bulge.abs() >= 1.0e-9 + || pline.vertices[i1].bulge.abs() >= 1.0e-9 + { + return None; + } + let as_vec = |p: acadrust::types::Vector2| Vec2::new(p.x, p.y); + let outer0 = as_vec(pline.vertices[previous].location); + let tangent0 = as_vec(pline.vertices[seg].location); + let tangent1 = as_vec(pline.vertices[i1].location); + let outer1 = as_vec(pline.vertices[next].location); + let d0 = (tangent0 - outer0).normalize()?; + let d1 = (outer1 - tangent1).normalize()?; + let arc = crate::entities::common::BulgeArc::from_bulge( + tangent0.into(), + tangent1.into(), + pline.vertices[seg].bulge, + )?; + let center = Vec2::from(arc.center); + let tangent_tolerance = (arc.radius * 1.0e-6).max(Tolerance::default().linear()); + if (tangent0 - center).dot(d0).abs() > tangent_tolerance + || (tangent1 - center).dot(d1).abs() > tangent_tolerance + { + // Merely being between two lines does not make an arc a fillet. An + // ordinary polyline arc must keep its centre while its radius changes. + return None; + } + let cross = d0.cross(d1); + if cross.abs() <= 1.0e-9 { + return None; + } + let apex = outer0 + d0 * ((tangent1 - outer0).cross(d1) / cross); + Some(( + apex, + (outer0 - apex).normalize()?, + (outer1 - apex).normalize()?, + )) +} + +fn resize_arc_segment_radius(pline: &mut LwPolyline, seg: usize, radius: f64) { + if radius <= Tolerance::default().linear() { + return; + } + let n = pline.vertices.len(); + let i1 = (seg + 1) % n; + let (Some(vertex0), Some(vertex1)) = (pline.vertices.get(seg), pline.vertices.get(i1)) else { + return; + }; + let p0 = Vec2::new(vertex0.location.x, vertex0.location.y); + let p1 = Vec2::new(vertex1.location.x, vertex1.location.y); + let original_bulge = vertex0.bulge; + let Some(original) = crate::entities::common::BulgeArc::from_bulge( + p0.into(), p1.into(), original_bulge, + ) else { + return; + }; + + if let Some((apex, keep0, keep1)) = arc_fillet_frame(pline, seg) { + let Some(fillet) = fillet_between_rays(apex.into(), keep0.into(), keep1.into(), radius) + else { + return; + }; + let new0 = Vec2::from(fillet.tangent1); + let new1 = Vec2::from(fillet.tangent2); + let center = Vec2::from(fillet.centre); + let (Some(r0), Some(r1)) = ((new0 - center).normalize(), (new1 - center).normalize()) + else { + return; + }; + let sweep = r0.dot(r1).clamp(-1.0, 1.0).acos(); + let bulge = original_bulge.signum() * (sweep * 0.25).tan(); + if !bulge.is_finite() { + return; + } + pline.vertices[seg].location = acadrust::types::Vector2::new(new0.x, new0.y); + pline.vertices[i1].location = acadrust::types::Vector2::new(new1.x, new1.y); + pline.vertices[seg].bulge = bulge; + return; + } + + // Ordinary arcs keep their centre. Connected endpoints slide along the + // infinite lines of their adjacent straight segments, preserving those + // lines' angles; only free open endpoints move radially. + move_arc_segment_parallel(pline, seg, radius - original.radius); +} + 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 { @@ -1395,6 +1492,12 @@ impl crate::entities::traits::Grippable for LwPolyline { label: "Move Parallel", action: GripMenuAction::MoveParallel, }); + if is_arc { + items.push(GripMenuItem { + label: "Radius", + action: GripMenuAction::Radius, + }); + } if is_rectangle(self) && seg < 4 { // Moving an edge changes the dimension perpendicular to it. items.push(GripMenuItem { @@ -1436,6 +1539,12 @@ impl crate::entities::traits::Grippable for LwPolyline { }; return (seg < count).then_some("Parallel offset"); } + if action == A::Radius && 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("New radius"); + } (is_rectangle(self) && n == 4 && (n..n + 4).contains(&grip_id) @@ -1453,6 +1562,30 @@ impl crate::entities::traits::Grippable for LwPolyline { point: glam::DVec3, ) -> Option { use crate::scene::model::object::GripMenuAction as A; + if action == A::Radius { + 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 { + return None; + } + let plane = crate::entities::curve::lwpolyline_curve(self)?.plane; + let point = Vec2::from(plane.project(point.to_array())?); + let p0 = self.vertices[seg].location; + let p1 = self.vertices[(seg + 1) % n].location; + let arc = crate::entities::common::BulgeArc::from_bulge( + [p0.x, p0.y], [p1.x, p1.y], self.vertices[seg].bulge, + )?; + if let Some((apex, _, _)) = arc_fillet_frame(self, seg) { + let midpoint = Vec2::from(arc.sample(0.5)); + let ray = midpoint - apex; + let scale = (point - apex).dot(ray) / ray.dot(ray); + let radius = arc.radius * scale; + return (radius > Tolerance::default().linear()).then_some(radius); + } + let radius = point.distance(Vec2::from(arc.center)); + return (radius > Tolerance::default().linear()).then_some(radius); + } if action == A::MoveParallel { let n = self.vertices.len(); let seg = grip_id.checked_sub(n)?; @@ -1517,6 +1650,12 @@ impl crate::entities::traits::Grippable for LwPolyline { value: f64, ) { use crate::scene::model::object::GripMenuAction as A; + if action == A::Radius { + if let Some(seg) = grip_id.checked_sub(self.vertices.len()) { + resize_arc_segment_radius(self, seg, value); + } + return; + } if action == A::MoveParallel { if let Some(seg) = grip_id.checked_sub(self.vertices.len()) { move_segment_parallel(self, seg, value); @@ -1879,6 +2018,94 @@ mod tests { assert_eq!(moved.sweep.signum(), original.sweep.signum()); } + #[test] + fn radius_menu_resizes_a_corner_as_a_tangent_fillet() { + let mut pl = polyline( + &[(0.0, 0.0), (8.0, 0.0), (10.0, 2.0), (10.0, 10.0)], false, + ); + pl.vertices[1].bulge = (std::f64::consts::FRAC_PI_2 / 4.0).tan(); + let grip = pl.vertices.len() + 1; + assert!(pl.grip_menu(grip).iter().any(|item| item.action == GripMenuAction::Radius)); + pl.apply_grip_menu_value(grip, GripMenuAction::Radius, 4.0); + assert_eq!(pl.vertices[0].location, Vector2::new(0.0, 0.0)); + assert!((pl.vertices[1].location.x - 6.0).abs() < 1.0e-9); + assert!(pl.vertices[1].location.y.abs() < 1.0e-9); + assert!((pl.vertices[2].location.x - 10.0).abs() < 1.0e-9); + assert!((pl.vertices[2].location.y - 4.0).abs() < 1.0e-9); + assert_eq!(pl.vertices[3].location, Vector2::new(10.0, 10.0)); + let arc = crate::entities::common::BulgeArc::from_bulge( + [pl.vertices[1].location.x, pl.vertices[1].location.y], + [pl.vertices[2].location.x, pl.vertices[2].location.y], + pl.vertices[1].bulge, + ).unwrap(); + assert!((arc.radius - 4.0).abs() < 1.0e-9); + assert!(arc.sweep > 0.0); + } + + #[test] + fn radius_cursor_tracks_the_fillet_midpoint_ray() { + let mut pl = polyline( + &[(0.0, 0.0), (8.0, 0.0), (10.0, 2.0), (10.0, 10.0)], false, + ); + pl.vertices[1].bulge = (std::f64::consts::FRAC_PI_2 / 4.0).tan(); + let arc = crate::entities::common::BulgeArc::from_bulge( + [pl.vertices[1].location.x, pl.vertices[1].location.y], + [pl.vertices[2].location.x, pl.vertices[2].location.y], + pl.vertices[1].bulge, + ).unwrap(); + let midpoint = Vec2::from(arc.sample(0.5)); + let cursor = Vec2::new(10.0, 0.0) + (midpoint - Vec2::new(10.0, 0.0)) * 2.0; + let value = pl.grip_menu_point_value( + pl.vertices.len() + 1, GripMenuAction::Radius, + glam::DVec3::new(cursor.x, cursor.y, 0.0), + ).unwrap(); + assert!((value - 4.0).abs() < 1.0e-9); + } + + #[test] + fn radius_on_an_open_endpoint_arc_stays_concentric() { + let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0)], false); + pl.vertices[0].bulge = 1.0; + resize_arc_segment_radius(&mut pl, 0, 8.0); + let arc = crate::entities::common::BulgeArc::from_bulge( + [pl.vertices[0].location.x, pl.vertices[0].location.y], + [pl.vertices[1].location.x, pl.vertices[1].location.y], + pl.vertices[0].bulge, + ).unwrap(); + assert!((arc.radius - 8.0).abs() < 1.0e-9); + assert!(Vec2::from(arc.center).distance(Vec2::new(5.0, 0.0)) < 1.0e-9); + assert!(arc.sweep > 0.0); + } + + #[test] + fn radius_on_a_non_tangent_internal_arc_keeps_center_and_side_angles() { + let mut pl = polyline( + &[(0.0, 0.0), (8.0, 0.0), (10.0, 2.0), (10.0, 10.0)], false, + ); + pl.vertices[1].bulge = 1.0; + let before = crate::entities::common::BulgeArc::from_bulge( + [8.0, 0.0], [10.0, 2.0], pl.vertices[1].bulge, + ).unwrap(); + resize_arc_segment_radius(&mut pl, 1, 4.0); + let after = crate::entities::common::BulgeArc::from_bulge( + [pl.vertices[1].location.x, pl.vertices[1].location.y], + [pl.vertices[2].location.x, pl.vertices[2].location.y], + pl.vertices[1].bulge, + ).unwrap(); + assert!(Vec2::from(after.center).distance(Vec2::from(before.center)) < 1.0e-9); + assert!((after.radius - 4.0).abs() < 1.0e-9); + let incoming = Vec2::new( + pl.vertices[1].location.x - pl.vertices[0].location.x, + pl.vertices[1].location.y - pl.vertices[0].location.y, + ); + let outgoing = Vec2::new( + pl.vertices[3].location.x - pl.vertices[2].location.x, + pl.vertices[3].location.y - pl.vertices[2].location.y, + ); + assert!(incoming.cross(Vec2::new(1.0, 0.0)).abs() < 1.0e-9); + assert!(outgoing.cross(Vec2::new(0.0, 1.0)).abs() < 1.0e-9); + } + #[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); From e67872386eb5bd45982eedf8b720e7b435cb55e5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 14 Sep 2026 11:42:30 +0200 Subject: [PATCH 4/8] Preserve connected arc geometry when moving polyline segments --- src/entities/lwpolyline.rs | 143 ++++++++++++++++++++++++++++++++++--- 1 file changed, 133 insertions(+), 10 deletions(-) diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index a8f1bd552..435c6252b 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -1,7 +1,7 @@ use acadrust::entities::{LwPolyline, LwVertex}; use cadkernel::geom2d::{ - fillet_between_rays, signed_area, Curve, Polyline, PolylineVertex, RectangleFrame, Tolerance, - Vec2, + circle_circle_points, fillet_between_rays, signed_area, Curve, Polyline, PolylineVertex, + RectangleFrame, Tolerance, Vec2, }; use crate::t; @@ -1269,31 +1269,97 @@ fn move_arc_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { second }) }; + let circle_circle_near = |other_center: Vec2, other_radius: f64, near: Vec2| { + circle_circle_points(center.into(), radius, other_center.into(), other_radius) + .into_iter() + .map(Vec2::from) + .min_by(|a, b| a.distance(near).total_cmp(&b.distance(near))) + }; + let bulge_on_circle = |circle_center: Vec2, start: Vec2, end: Vec2, sign: f64| { + let start_angle = (start.y - circle_center.y).atan2(start.x - circle_center.x); + let end_angle = (end.y - circle_center.y).atan2(end.x - circle_center.x); + let sweep = if sign >= 0.0 { + (end_angle - start_angle).rem_euclid(TAU) + } else { + -(start_angle - end_angle).rem_euclid(TAU) + }; + let bulge = (sweep * 0.25).tan(); + bulge.is_finite().then_some(bulge.clamp(-1.0e6, 1.0e6)) + }; + let linear_sq = Tolerance::default().linear().powi(2); + let mut sync_previous = None; + let mut previous_arc_bulge = None; 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 Some(point) = line_circle_near(a, p0 - a, p0) else { - return; - }; - point + if pline.vertices[previous].bulge.abs() >= 1.0e-9 { + let Some(neighbor) = crate::entities::common::BulgeArc::from_bulge( + a.into(), p0.into(), pline.vertices[previous].bulge, + ) else { + return; + }; + let neighbor_center = Vec2::from(neighbor.center); + let Some(point) = circle_circle_near(neighbor_center, neighbor.radius, p0) else { + return; + }; + let Some(new_bulge) = bulge_on_circle( + neighbor_center, a, point, neighbor.sweep.signum(), + ) else { + return; + }; + previous_arc_bulge = Some((previous, new_bulge)); + point + } else if (p0 - a).length_squared() <= linear_sq { + sync_previous = Some(previous); + center + (p0 - center) * (radius / arc.radius) + } else { + let Some(point) = line_circle_near(a, p0 - a, p0) else { + return; + }; + point + } } else { center + (p0 - center) * (radius / arc.radius) }; let count = if pline.is_closed { n } else { n - 1 }; + let mut sync_next = None; + let mut next_arc_bulge = None; 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 Some(point) = line_circle_near(p1, d - p1, p1) else { - return; - }; - point + if pline.vertices[i1].bulge.abs() >= 1.0e-9 { + let Some(neighbor) = crate::entities::common::BulgeArc::from_bulge( + p1.into(), d.into(), pline.vertices[i1].bulge, + ) else { + return; + }; + let neighbor_center = Vec2::from(neighbor.center); + let Some(point) = circle_circle_near(neighbor_center, neighbor.radius, p1) else { + return; + }; + let Some(new_bulge) = bulge_on_circle( + neighbor_center, point, d, neighbor.sweep.signum(), + ) else { + return; + }; + next_arc_bulge = Some((i1, new_bulge)); + point + } else if (d - p1).length_squared() <= linear_sq { + sync_next = Some(next); + center + (p1 - center) * (radius / arc.radius) + } else { + let Some(point) = line_circle_near(p1, d - p1, p1) else { + return; + }; + point + } } else { center + (p1 - center) * (radius / arc.radius) }; @@ -1311,6 +1377,18 @@ fn move_arc_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { } pline.vertices[i0].location = acadrust::types::Vector2::new(new0.x, new0.y); pline.vertices[i1].location = acadrust::types::Vector2::new(new1.x, new1.y); + if let Some(previous) = sync_previous { + pline.vertices[previous].location = acadrust::types::Vector2::new(new0.x, new0.y); + } + if let Some(next) = sync_next { + pline.vertices[next].location = acadrust::types::Vector2::new(new1.x, new1.y); + } + if let Some((previous, bulge)) = previous_arc_bulge { + pline.vertices[previous].bulge = bulge; + } + if let Some((next, bulge)) = next_arc_bulge { + pline.vertices[next].bulge = bulge; + } pline.vertices[i0].bulge = new_bulge.clamp(-1.0e6, 1.0e6); } @@ -1881,6 +1959,7 @@ impl crate::entities::traits::MassPropsCalc for acadrust::entities::LwPolyline { #[cfg(test)] mod tests { use super::*; + use crate::entities::traits::{Grippable, PropertyEditable}; use crate::scene::model::object::GripMenuAction; use acadrust::entities::{LwPolyline, LwVertex}; @@ -2018,6 +2097,50 @@ mod tests { assert_eq!(moved.sweep.signum(), original.sweep.signum()); } + #[test] + fn move_parallel_allows_an_open_arc_with_a_duplicate_end_vertex() { + let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0), (10.0, 0.0)], false); + pl.vertices[0].bulge = 1.0; + + move_segment_parallel(&mut pl, 0, 2.0); + + let moved = crate::entities::common::BulgeArc::from_bulge( + [pl.vertices[0].location.x, pl.vertices[0].location.y], + [pl.vertices[1].location.x, pl.vertices[1].location.y], + pl.vertices[0].bulge, + ).unwrap(); + assert!((moved.radius - 7.0).abs() < 1.0e-9); + assert_eq!(pl.vertices[2].location, pl.vertices[1].location); + } + + #[test] + fn resizing_an_end_arc_only_changes_the_connected_arcs_length() { + let shared = Vec2::new(3.0, 4.0); + let mut pl = polyline(&[(-5.0, 0.0), (shared.x, shared.y), (11.0, 0.0)], false); + let ccw_bulge = |center: Vec2, start: Vec2, end: Vec2| { + let a0 = (start.y - center.y).atan2(start.x - center.x); + let a1 = (end.y - center.y).atan2(end.x - center.x); + ((a1 - a0).rem_euclid(TAU) * 0.25).tan() + }; + pl.vertices[0].bulge = ccw_bulge(Vec2::ZERO, Vec2::new(-5.0, 0.0), shared); + pl.vertices[1].bulge = ccw_bulge(Vec2::new(6.0, 0.0), shared, Vec2::new(11.0, 0.0)); + let before = crate::entities::common::BulgeArc::from_bulge( + [shared.x, shared.y], [11.0, 0.0], pl.vertices[1].bulge, + ).unwrap(); + + move_segment_parallel(&mut pl, 0, 1.0); + + let after = crate::entities::common::BulgeArc::from_bulge( + [pl.vertices[1].location.x, pl.vertices[1].location.y], + [pl.vertices[2].location.x, pl.vertices[2].location.y], + pl.vertices[1].bulge, + ).unwrap(); + assert!(Vec2::from(after.center).distance(Vec2::from(before.center)) < 1.0e-9); + assert!((after.radius - before.radius).abs() < 1.0e-9); + assert_eq!(pl.vertices[2].location, Vector2::new(11.0, 0.0)); + assert!((after.sweep - before.sweep).abs() > 1.0e-6); + } + #[test] fn radius_menu_resizes_a_corner_as_a_tangent_fillet() { let mut pl = polyline( From 6f008faa9a2ac32370f26b3497c934b271420d95 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 14 Sep 2026 12:19:03 +0200 Subject: [PATCH 5/8] Preserve connected arc geometry when moving polyline segments --- src/entities/lwpolyline.rs | 104 +++++++++++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 16 deletions(-) diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 435c6252b..4e851a16f 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -1198,37 +1198,86 @@ fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { let t = (delta.x * selected.y - delta.y * selected.x) / cross; Some(a + direction * t) }; + let intersect_circle = |center: Vec2, radius: f64, near: Vec2| -> Option { + let relative = q0 - center; + let aa = selected.dot(selected); + let bb = 2.0 * relative.dot(selected); + let cc = relative.dot(relative) - radius * radius; + let discriminant = bb * bb - 4.0 * aa * cc; + if discriminant < -Tolerance::default().linear() { + return None; + } + let root = discriminant.max(0.0).sqrt(); + let first = q0 + selected * ((-bb - root) / (2.0 * aa)); + let second = q0 + selected * ((-bb + root) / (2.0 * aa)); + Some(if first.distance(near) <= second.distance(near) { first } else { second }) + }; + let bulge_on_circle = |center: Vec2, start: Vec2, end: Vec2, sign: f64| { + let a0 = (start.y - center.y).atan2(start.x - center.x); + let a1 = (end.y - center.y).atan2(end.x - center.x); + let sweep = if sign >= 0.0 { + (a1 - a0).rem_euclid(TAU) + } else { + -(a0 - a1).rem_euclid(TAU) + }; + let bulge = (sweep * 0.25).tan(); + bulge.is_finite().then_some(bulge.clamp(-1.0e6, 1.0e6)) + }; + let mut previous_arc_bulge = None; 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 + if pline.vertices[previous].bulge.abs() >= 1.0e-9 { + let Some(neighbor) = crate::entities::common::BulgeArc::from_bulge( + a.into(), p0.into(), pline.vertices[previous].bulge, + ) else { return; }; + let center = Vec2::from(neighbor.center); + let Some(point) = intersect_circle(center, neighbor.radius, p0) else { return; }; + let Some(bulge) = bulge_on_circle(center, a, point, neighbor.sweep.signum()) else { return; }; + previous_arc_bulge = Some((previous, bulge)); + point + } else { + let Some(point) = intersect(a, p0 - a) else { return; }; + point + } } else { q0 }; + let mut next_arc_bulge = None; 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 + if pline.vertices[i1].bulge.abs() >= 1.0e-9 { + let Some(neighbor) = crate::entities::common::BulgeArc::from_bulge( + p1.into(), d.into(), pline.vertices[i1].bulge, + ) else { return; }; + let center = Vec2::from(neighbor.center); + let Some(point) = intersect_circle(center, neighbor.radius, p1) else { return; }; + let Some(bulge) = bulge_on_circle(center, point, d, neighbor.sweep.signum()) else { return; }; + next_arc_bulge = Some((i1, bulge)); + point + } else { + let Some(point) = intersect(p1, d - p1) 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); + if let Some((previous, bulge)) = previous_arc_bulge { + pline.vertices[previous].bulge = bulge; + } + if let Some((next, bulge)) = next_arc_bulge { + pline.vertices[next].bulge = bulge; + } } fn move_arc_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { @@ -1570,12 +1619,6 @@ impl crate::entities::traits::Grippable for LwPolyline { label: "Move Parallel", action: GripMenuAction::MoveParallel, }); - if is_arc { - items.push(GripMenuItem { - label: "Radius", - action: GripMenuAction::Radius, - }); - } if is_rectangle(self) && seg < 4 { // Moving an edge changes the dimension perpendicular to it. items.push(GripMenuItem { @@ -1598,6 +1641,12 @@ impl crate::entities::traits::Grippable for LwPolyline { }, convert, ]); + if is_arc { + items.push(GripMenuItem { + label: "Radius", + action: GripMenuAction::Radius, + }); + } items } @@ -2073,6 +2122,29 @@ mod tests { assert_eq!(pl.vertices[3].location, Vector2::new(10.0, 0.0)); } + #[test] + fn moving_a_line_parallel_only_changes_the_connected_arcs_length() { + let mut pl = polyline(&[(-5.0, 0.0), (0.0, 0.0), (5.0, 5.0)], false); + pl.vertices[1].bulge = -(std::f64::consts::FRAC_PI_2 * 0.25).tan(); + let before = crate::entities::common::BulgeArc::from_bulge( + [0.0, 0.0], [5.0, 5.0], pl.vertices[1].bulge, + ).unwrap(); + + move_segment_parallel(&mut pl, 0, 1.0); + + let after = crate::entities::common::BulgeArc::from_bulge( + [pl.vertices[1].location.x, pl.vertices[1].location.y], + [pl.vertices[2].location.x, pl.vertices[2].location.y], + pl.vertices[1].bulge, + ).unwrap(); + assert!(Vec2::from(after.center).distance(Vec2::from(before.center)) < 1.0e-9); + assert!((after.radius - before.radius).abs() < 1.0e-9); + assert_eq!(pl.vertices[2].location, Vector2::new(5.0, 5.0)); + assert!((after.sweep - before.sweep).abs() > 1.0e-6); + assert!((pl.vertices[0].location.y - 1.0).abs() < 1.0e-9); + assert!((pl.vertices[1].location.y - 1.0).abs() < 1.0e-9); + } + #[test] fn move_parallel_offsets_arc_concentrically() { let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0)], false); From 9ae1e6dd763309b711a76ab4fe3833b418b3e5fc Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 14 Sep 2026 12:30:51 +0200 Subject: [PATCH 6/8] Preserve valid grip preview when edits have no geometric solution --- src/app/update/viewport.rs | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 850047811..ef1c44f94 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1687,16 +1687,36 @@ impl OpenCADStudio { snapped, ); if let Some(value) = value { + let origin_value = + crate::scene::view::dispatch::grip_menu_point_value( + &original, + grip.grip_id, + action, + grip.origin_world, + ); + let mut candidate = original.clone(); + crate::entities::traits::EntityTypeOps::apply_grip_menu_value( + &mut candidate, + grip.grip_id, + action, + value, + ); + let returns_to_origin = origin_value.is_some_and(|origin_value| { + (value - origin_value).abs() + <= 1.0e-9 * value.abs().max(origin_value.abs()).max(1.0) + }); 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, - ); + // Some radius/parallel positions have no geometric + // solution. Their entity edit is deliberately a + // no-op; retain the last valid preview instead of + // flashing back to the drag-start geometry. The + // exact origin remains reachable when the cursor + // moves back there. + if candidate != original || returns_to_origin { + *current = candidate; + } } } } From dfc859dcf5d6da2b89e4a85f50ff965b9bebec0c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 14 Sep 2026 12:47:17 +0200 Subject: [PATCH 7/8] Restrict parallel offsets to straight polyline segments --- src/entities/lwpolyline.rs | 52 +++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 4e851a16f..96e1948f6 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -1176,7 +1176,6 @@ fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { let i0 = seg; let i1 = (seg + 1) % n; if pline.vertices[i0].bulge.abs() >= 1.0e-9 { - move_arc_segment_parallel(pline, seg, offset); return; } let p0 = Vec2::new(pline.vertices[i0].location.x, pline.vertices[i0].location.y); @@ -1280,7 +1279,9 @@ fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { } } -fn move_arc_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { +/// Resize an arc concentrically while preserving its neighboring geometry. +/// This is used exclusively by the Radius grip-menu action. +fn resize_arc_concentrically(pline: &mut LwPolyline, seg: usize, offset: f64) { let n = pline.vertices.len(); let i0 = seg; let i1 = (seg + 1) % n; @@ -1534,7 +1535,7 @@ fn resize_arc_segment_radius(pline: &mut LwPolyline, seg: usize, radius: f64) { // Ordinary arcs keep their centre. Connected endpoints slide along the // infinite lines of their adjacent straight segments, preserving those // lines' angles; only free open endpoints move radially. - move_arc_segment_parallel(pline, seg, radius - original.radius); + resize_arc_concentrically(pline, seg, radius - original.radius); } fn apply_transform(pline: &mut LwPolyline, t: &EntityTransform) { @@ -1615,10 +1616,17 @@ impl crate::entities::traits::Grippable for LwPolyline { } }; let mut items = Vec::new(); - items.push(GripMenuItem { - label: "Move Parallel", - action: GripMenuAction::MoveParallel, - }); + if is_arc { + items.push(GripMenuItem { + label: "Radius", + action: GripMenuAction::Radius, + }); + } else { + 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 { @@ -1641,12 +1649,6 @@ impl crate::entities::traits::Grippable for LwPolyline { }, convert, ]); - if is_arc { - items.push(GripMenuItem { - label: "Radius", - action: GripMenuAction::Radius, - }); - } items } @@ -1664,7 +1666,8 @@ impl crate::entities::traits::Grippable for LwPolyline { } else { n.saturating_sub(1) }; - return (seg < count).then_some("Parallel offset"); + return (seg < count && self.vertices[seg].bulge.abs() < 1.0e-9) + .then_some("Parallel offset"); } if action == A::Radius && grip_id >= n { let seg = grip_id - n; @@ -1721,7 +1724,7 @@ impl crate::entities::traits::Grippable for LwPolyline { } else { n.saturating_sub(1) }; - if seg >= count { + if seg >= count || self.vertices[seg].bulge.abs() >= 1.0e-9 { return None; } let plane = crate::entities::curve::lwpolyline_curve(self)?.plane; @@ -1785,7 +1788,9 @@ impl crate::entities::traits::Grippable for LwPolyline { } if action == A::MoveParallel { if let Some(seg) = grip_id.checked_sub(self.vertices.len()) { - move_segment_parallel(self, seg, value); + if self.vertices.get(seg).is_some_and(|vertex| vertex.bulge.abs() < 1.0e-9) { + move_segment_parallel(self, seg, value); + } } return; } @@ -2048,12 +2053,13 @@ mod tests { } #[test] - fn arc_midpoint_offers_move_parallel_first() { + fn arc_midpoint_offers_radius_first_without_move_parallel() { let mut pl = make_test_lwpolyline(2, 0.0); pl.vertices[0].bulge = 0.5; let menu = pl.grip_menu(2); - assert_eq!(menu[0].action, GripMenuAction::MoveParallel); + assert_eq!(menu[0].action, GripMenuAction::Radius); assert_eq!(menu[1].action, GripMenuAction::Stretch); + assert!(!menu.iter().any(|item| item.action == GripMenuAction::MoveParallel)); } #[test] @@ -2146,7 +2152,7 @@ mod tests { } #[test] - fn move_parallel_offsets_arc_concentrically() { + fn radius_offsets_arc_concentrically() { let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0)], false); pl.vertices[0].bulge = 1.0; let original = crate::entities::common::BulgeArc::from_bulge( @@ -2156,7 +2162,7 @@ mod tests { ) .unwrap(); - move_segment_parallel(&mut pl, 0, 2.0); + resize_arc_concentrically(&mut pl, 0, 2.0); let moved = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[0].location.x, pl.vertices[0].location.y], @@ -2170,11 +2176,11 @@ mod tests { } #[test] - fn move_parallel_allows_an_open_arc_with_a_duplicate_end_vertex() { + fn radius_allows_an_open_arc_with_a_duplicate_end_vertex() { let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0), (10.0, 0.0)], false); pl.vertices[0].bulge = 1.0; - move_segment_parallel(&mut pl, 0, 2.0); + resize_arc_concentrically(&mut pl, 0, 2.0); let moved = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[0].location.x, pl.vertices[0].location.y], @@ -2200,7 +2206,7 @@ mod tests { [shared.x, shared.y], [11.0, 0.0], pl.vertices[1].bulge, ).unwrap(); - move_segment_parallel(&mut pl, 0, 1.0); + resize_arc_concentrically(&mut pl, 0, 1.0); let after = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[1].location.x, pl.vertices[1].location.y], From a2adb3f6e3e57a53fe13bbdc1f6bd2e17f3ce591 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 14 Sep 2026 13:15:57 +0200 Subject: [PATCH 8/8] Refactor grip dynamic input and polyline arc geometry --- src/app/update/command.rs | 32 ++---- src/app/update/mod.rs | 14 +-- src/entities/lwpolyline.rs | 227 +++++++++++++++++++++---------------- src/scene/pick/grip.rs | 28 +++++ 4 files changed, 174 insertions(+), 127 deletions(-) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 9ee276552..4f4dddce2 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -203,17 +203,11 @@ 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 - | GripEditMode::RectangleWidth - | GripEditMode::RectangleHeight - | GripEditMode::MoveParallel - ) - }) { + if self.tabs[i] + .active_grip + .as_ref() + .is_some_and(|grip| grip.mode.uses_scalar_dynamic_input()) + { self.command_line.input.push_str(&s); } } else { @@ -259,17 +253,11 @@ 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 - | GripEditMode::RectangleWidth - | GripEditMode::RectangleHeight - | GripEditMode::MoveParallel - ) - }) { + if self.tabs[i] + .active_grip + .as_ref() + .is_some_and(|grip| grip.mode.uses_scalar_dynamic_input()) + { self.command_line.input.pop(); } return self.focus_cmd_input(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 79046b306..09010a10d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1609,16 +1609,10 @@ impl OpenCADStudio { 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 - | crate::scene::pick::grip::GripEditMode::RectangleWidth - | crate::scene::pick::grip::GripEditMode::RectangleHeight - ) - }) + && self.tabs[i] + .active_grip + .as_ref() + .is_some_and(|grip| grip.mode.uses_scalar_dynamic_input()) && !self.tabs[i].dyn_fields.is_empty() { let a = self.tabs[i] diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 96e1948f6..22eb2fc5f 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -1163,6 +1163,64 @@ fn apply_grip(pline: &mut LwPolyline, grip_id: usize, apply: GripApply) { } } +fn nearest_line_circle_intersection( + line_point: Vec2, + line_direction: Vec2, + circle_center: Vec2, + circle_radius: f64, + near: Vec2, +) -> Option { + let aa = line_direction.dot(line_direction); + if aa <= Tolerance::default().linear().powi(2) { + return None; + } + let relative = line_point - circle_center; + let bb = 2.0 * relative.dot(line_direction); + let cc = relative.dot(relative) - circle_radius * circle_radius; + let discriminant = bb * bb - 4.0 * aa * cc; + if discriminant < -Tolerance::default().linear() { + return None; + } + let root = discriminant.max(0.0).sqrt(); + let first = line_point + line_direction * ((-bb - root) / (2.0 * aa)); + let second = line_point + line_direction * ((-bb + root) / (2.0 * aa)); + Some(if first.distance(near) <= second.distance(near) { + first + } else { + second + }) +} + +fn nearest_circle_circle_intersection( + first_center: Vec2, + first_radius: f64, + second_center: Vec2, + second_radius: f64, + near: Vec2, +) -> Option { + circle_circle_points( + first_center.into(), + first_radius, + second_center.into(), + second_radius, + ) + .into_iter() + .map(Vec2::from) + .min_by(|a, b| a.distance(near).total_cmp(&b.distance(near))) +} + +fn bulge_for_circle_arc(center: Vec2, start: Vec2, end: Vec2, direction: f64) -> Option { + let start_angle = (start.y - center.y).atan2(start.x - center.x); + let end_angle = (end.y - center.y).atan2(end.x - center.x); + let sweep = if direction >= 0.0 { + (end_angle - start_angle).rem_euclid(TAU) + } else { + -(start_angle - end_angle).rem_euclid(TAU) + }; + let bulge = (sweep * 0.25).tan(); + bulge.is_finite().then_some(bulge.clamp(-1.0e6, 1.0e6)) +} + fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { let n = pline.vertices.len(); let count = if pline.is_closed { @@ -1197,32 +1255,6 @@ fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { let t = (delta.x * selected.y - delta.y * selected.x) / cross; Some(a + direction * t) }; - let intersect_circle = |center: Vec2, radius: f64, near: Vec2| -> Option { - let relative = q0 - center; - let aa = selected.dot(selected); - let bb = 2.0 * relative.dot(selected); - let cc = relative.dot(relative) - radius * radius; - let discriminant = bb * bb - 4.0 * aa * cc; - if discriminant < -Tolerance::default().linear() { - return None; - } - let root = discriminant.max(0.0).sqrt(); - let first = q0 + selected * ((-bb - root) / (2.0 * aa)); - let second = q0 + selected * ((-bb + root) / (2.0 * aa)); - Some(if first.distance(near) <= second.distance(near) { first } else { second }) - }; - let bulge_on_circle = |center: Vec2, start: Vec2, end: Vec2, sign: f64| { - let a0 = (start.y - center.y).atan2(start.x - center.x); - let a1 = (end.y - center.y).atan2(end.x - center.x); - let sweep = if sign >= 0.0 { - (a1 - a0).rem_euclid(TAU) - } else { - -(a0 - a1).rem_euclid(TAU) - }; - let bulge = (sweep * 0.25).tan(); - bulge.is_finite().then_some(bulge.clamp(-1.0e6, 1.0e6)) - }; - let mut previous_arc_bulge = None; let new0 = if pline.is_closed || seg > 0 { let previous = (i0 + n - 1) % n; @@ -1232,15 +1264,28 @@ fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { ); if pline.vertices[previous].bulge.abs() >= 1.0e-9 { let Some(neighbor) = crate::entities::common::BulgeArc::from_bulge( - a.into(), p0.into(), pline.vertices[previous].bulge, - ) else { return; }; + a.into(), + p0.into(), + pline.vertices[previous].bulge, + ) else { + return; + }; let center = Vec2::from(neighbor.center); - let Some(point) = intersect_circle(center, neighbor.radius, p0) else { return; }; - let Some(bulge) = bulge_on_circle(center, a, point, neighbor.sweep.signum()) else { return; }; + let Some(point) = + nearest_line_circle_intersection(q0, selected, center, neighbor.radius, p0) + else { + return; + }; + let Some(bulge) = bulge_for_circle_arc(center, a, point, neighbor.sweep.signum()) + else { + return; + }; previous_arc_bulge = Some((previous, bulge)); point } else { - let Some(point) = intersect(a, p0 - a) else { return; }; + let Some(point) = intersect(a, p0 - a) else { + return; + }; point } } else { @@ -1255,15 +1300,28 @@ fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { ); if pline.vertices[i1].bulge.abs() >= 1.0e-9 { let Some(neighbor) = crate::entities::common::BulgeArc::from_bulge( - p1.into(), d.into(), pline.vertices[i1].bulge, - ) else { return; }; + p1.into(), + d.into(), + pline.vertices[i1].bulge, + ) else { + return; + }; let center = Vec2::from(neighbor.center); - let Some(point) = intersect_circle(center, neighbor.radius, p1) else { return; }; - let Some(bulge) = bulge_on_circle(center, point, d, neighbor.sweep.signum()) else { return; }; + let Some(point) = + nearest_line_circle_intersection(q0, selected, center, neighbor.radius, p1) + else { + return; + }; + let Some(bulge) = bulge_for_circle_arc(center, point, d, neighbor.sweep.signum()) + else { + return; + }; next_arc_bulge = Some((i1, bulge)); point } else { - let Some(point) = intersect(p1, d - p1) else { return; }; + let Some(point) = intersect(p1, d - p1) else { + return; + }; point } } else { @@ -1283,6 +1341,10 @@ fn move_segment_parallel(pline: &mut LwPolyline, seg: usize, offset: f64) { /// This is used exclusively by the Radius grip-menu action. fn resize_arc_concentrically(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; let p0 = Vec2::new(pline.vertices[i0].location.x, pline.vertices[i0].location.y); @@ -1298,45 +1360,6 @@ fn resize_arc_concentrically(pline: &mut LwPolyline, seg: usize, offset: f64) { return; } - let line_circle_near = |a: Vec2, direction: Vec2, near: Vec2| -> Option { - let aa = direction.dot(direction); - if aa <= Tolerance::default().linear().powi(2) { - return None; - } - let relative = a - center; - let bb = 2.0 * relative.dot(direction); - let cc = relative.dot(relative) - radius * radius; - let discriminant = bb * bb - 4.0 * aa * cc; - if discriminant < -Tolerance::default().linear() { - return None; - } - let root = discriminant.max(0.0).sqrt(); - let first = a + direction * ((-bb - root) / (2.0 * aa)); - let second = a + direction * ((-bb + root) / (2.0 * aa)); - Some(if first.distance(near) <= second.distance(near) { - first - } else { - second - }) - }; - let circle_circle_near = |other_center: Vec2, other_radius: f64, near: Vec2| { - circle_circle_points(center.into(), radius, other_center.into(), other_radius) - .into_iter() - .map(Vec2::from) - .min_by(|a, b| a.distance(near).total_cmp(&b.distance(near))) - }; - let bulge_on_circle = |circle_center: Vec2, start: Vec2, end: Vec2, sign: f64| { - let start_angle = (start.y - circle_center.y).atan2(start.x - circle_center.x); - let end_angle = (end.y - circle_center.y).atan2(end.x - circle_center.x); - let sweep = if sign >= 0.0 { - (end_angle - start_angle).rem_euclid(TAU) - } else { - -(start_angle - end_angle).rem_euclid(TAU) - }; - let bulge = (sweep * 0.25).tan(); - bulge.is_finite().then_some(bulge.clamp(-1.0e6, 1.0e6)) - }; - let linear_sq = Tolerance::default().linear().powi(2); let mut sync_previous = None; let mut previous_arc_bulge = None; @@ -1353,10 +1376,12 @@ fn resize_arc_concentrically(pline: &mut LwPolyline, seg: usize, offset: f64) { return; }; let neighbor_center = Vec2::from(neighbor.center); - let Some(point) = circle_circle_near(neighbor_center, neighbor.radius, p0) else { + let Some(point) = nearest_circle_circle_intersection( + center, radius, neighbor_center, neighbor.radius, p0, + ) else { return; }; - let Some(new_bulge) = bulge_on_circle( + let Some(new_bulge) = bulge_for_circle_arc( neighbor_center, a, point, neighbor.sweep.signum(), ) else { return; @@ -1367,7 +1392,9 @@ fn resize_arc_concentrically(pline: &mut LwPolyline, seg: usize, offset: f64) { sync_previous = Some(previous); center + (p0 - center) * (radius / arc.radius) } else { - let Some(point) = line_circle_near(a, p0 - a, p0) else { + let Some(point) = nearest_line_circle_intersection( + a, p0 - a, center, radius, p0, + ) else { return; }; point @@ -1375,7 +1402,6 @@ fn resize_arc_concentrically(pline: &mut LwPolyline, seg: usize, offset: f64) { } else { center + (p0 - center) * (radius / arc.radius) }; - let count = if pline.is_closed { n } else { n - 1 }; let mut sync_next = None; let mut next_arc_bulge = None; let new1 = if pline.is_closed || seg + 1 < count { @@ -1391,10 +1417,12 @@ fn resize_arc_concentrically(pline: &mut LwPolyline, seg: usize, offset: f64) { return; }; let neighbor_center = Vec2::from(neighbor.center); - let Some(point) = circle_circle_near(neighbor_center, neighbor.radius, p1) else { + let Some(point) = nearest_circle_circle_intersection( + center, radius, neighbor_center, neighbor.radius, p1, + ) else { return; }; - let Some(new_bulge) = bulge_on_circle( + let Some(new_bulge) = bulge_for_circle_arc( neighbor_center, point, d, neighbor.sweep.signum(), ) else { return; @@ -1405,7 +1433,9 @@ fn resize_arc_concentrically(pline: &mut LwPolyline, seg: usize, offset: f64) { sync_next = Some(next); center + (p1 - center) * (radius / arc.radius) } else { - let Some(point) = line_circle_near(p1, d - p1, p1) else { + let Some(point) = nearest_line_circle_intersection( + p1, d - p1, center, radius, p1, + ) else { return; }; point @@ -1414,17 +1444,9 @@ fn resize_arc_concentrically(pline: &mut LwPolyline, seg: usize, offset: f64) { center + (p1 - center) * (radius / arc.radius) }; - let start_angle = (new0.y - center.y).atan2(new0.x - center.x); - let end_angle = (new1.y - center.y).atan2(new1.x - center.x); - let sweep = if arc.sweep >= 0.0 { - (end_angle - start_angle).rem_euclid(TAU) - } else { - -(start_angle - end_angle).rem_euclid(TAU) - }; - let new_bulge = (sweep * 0.25).tan(); - if !new_bulge.is_finite() { + let Some(new_bulge) = bulge_for_circle_arc(center, new0, new1, arc.sweep.signum()) else { return; - } + }; pline.vertices[i0].location = acadrust::types::Vector2::new(new0.x, new0.y); pline.vertices[i1].location = acadrust::types::Vector2::new(new1.x, new1.y); if let Some(previous) = sync_previous { @@ -1439,7 +1461,7 @@ fn resize_arc_concentrically(pline: &mut LwPolyline, seg: usize, offset: f64) { if let Some((next, bulge)) = next_arc_bulge { pline.vertices[next].bulge = bulge; } - pline.vertices[i0].bulge = new_bulge.clamp(-1.0e6, 1.0e6); + pline.vertices[i0].bulge = new_bulge; } /// Return the corner and the two rays retained by the straight segments on @@ -1496,6 +1518,10 @@ fn resize_arc_segment_radius(pline: &mut LwPolyline, seg: usize, radius: f64) { return; } let n = pline.vertices.len(); + let count = if pline.is_closed { n } else { n.saturating_sub(1) }; + if seg >= count { + return; + } let i1 = (seg + 1) % n; let (Some(vertex0), Some(vertex1)) = (pline.vertices.get(seg), pline.vertices.get(i1)) else { return; @@ -2278,6 +2304,17 @@ mod tests { assert!(arc.sweep > 0.0); } + #[test] + fn radius_edit_ignores_an_invalid_segment_grip() { + let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0)], false); + pl.vertices[0].bulge = 1.0; + let before = pl.clone(); + + pl.apply_grip_menu_value(99, GripMenuAction::Radius, 8.0); + + assert_eq!(pl, before); + } + #[test] fn radius_on_a_non_tangent_internal_arc_keeps_center_and_side_angles() { let mut pl = polyline( @@ -2320,8 +2357,8 @@ mod tests { 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[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)); } diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index 1326e3bb3..18a96344b 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -64,6 +64,21 @@ pub enum GripEditMode { MoveParallel, } +impl GripEditMode { + /// Whether typed text belongs in the grip's single dynamic-input field. + pub fn uses_scalar_dynamic_input(self) -> bool { + matches!( + self, + Self::Lengthen + | Self::Radius + | Self::ArcLength + | Self::RectangleWidth + | Self::RectangleHeight + | Self::MoveParallel + ) + } +} + #[derive(Clone, Debug)] pub struct GripTarget { pub handle: Handle, @@ -349,3 +364,16 @@ pub fn find_hit_grip_rte( } best } + +#[cfg(test)] +mod tests { + use super::GripEditMode; + + #[test] + fn scalar_dynamic_input_modes_include_move_parallel() { + assert!(GripEditMode::MoveParallel.uses_scalar_dynamic_input()); + assert!(GripEditMode::Radius.uses_scalar_dynamic_input()); + assert!(!GripEditMode::Stretch.uses_scalar_dynamic_input()); + assert!(!GripEditMode::RectangleResize.uses_scalar_dynamic_input()); + } +}