From f30b490831c59a5b5c1ef6f20d455dca9444c998 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 11 Sep 2026 20:15:36 +0200 Subject: [PATCH] Add rectangle width and height grip editing --- src/app/update/command.rs | 32 +++++++ src/app/update/dynamic.rs | 6 ++ src/app/update/mod.rs | 2 + src/app/update/viewport.rs | 7 ++ src/app/view/mod.rs | 8 ++ src/entities/lwpolyline.rs | 157 +++++++++++++++++++++++++++++++- src/modules/draw/draw/shapes.rs | 37 +++++++- src/scene/model/object.rs | 2 + src/scene/pick/grip.rs | 14 +++ 9 files changed, 260 insertions(+), 5 deletions(-) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index acb34b13d..e9bb72f3a 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -209,6 +209,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { GripEditMode::Lengthen | GripEditMode::Radius | GripEditMode::ArcLength + | GripEditMode::RectangleWidth + | GripEditMode::RectangleHeight ) }) { self.command_line.input.push_str(&s); @@ -262,6 +264,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { GripEditMode::Lengthen | GripEditMode::Radius | GripEditMode::ArcLength + | GripEditMode::RectangleWidth + | GripEditMode::RectangleHeight ) }) { self.command_line.input.pop(); @@ -316,6 +320,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::scene::model::object::GripMenuAction::ArcLength => { Some(GripEditMode::ArcLength) } + crate::scene::model::object::GripMenuAction::RectangleWidth => { + Some(GripEditMode::RectangleWidth) + } + crate::scene::model::object::GripMenuAction::RectangleHeight => { + Some(GripEditMode::RectangleHeight) + } _ => None, }; expected_mode.is_some_and(|mode| { @@ -368,6 +378,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { ) | ( GripEditMode::ArcLength, crate::scene::model::object::GripMenuAction::ArcLength, + ) | ( + GripEditMode::RectangleWidth, + crate::scene::model::object::GripMenuAction::RectangleWidth, + ) | ( + GripEditMode::RectangleHeight, + crate::scene::model::object::GripMenuAction::RectangleHeight, ) ) && grip.handle == pending.handle @@ -1410,6 +1426,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { GripMenuAction::Lengthen | GripMenuAction::Radius | GripMenuAction::ArcLength + | GripMenuAction::RectangleWidth + | GripMenuAction::RectangleHeight ) { if let Some((_, grip)) = self.tabs[i] .selected_grip_handles @@ -1439,6 +1457,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { popup.grip_id, grip.world, ), + GripMenuAction::RectangleWidth => GripEdit::rectangle_width( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::RectangleHeight => GripEdit::rectangle_height( + popup.handle, + popup.grip_id, + grip.world, + ), _ => GripEdit::lengthen( popup.handle, popup.grip_id, @@ -1460,6 +1488,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.command_line.push_info( crate::t!("Specify point or enter arc length:").as_ref(), ); + } else if matches!(item.action, GripMenuAction::RectangleWidth) { + 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 { 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 bfe201e6a..621586f72 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -87,6 +87,12 @@ impl OpenCADStudio { crate::scene::pick::grip::GripEditMode::ArcLength => { (grip.origin_world, Some(crate::command::DynRole::Distance)) } + crate::scene::pick::grip::GripEditMode::RectangleWidth => { + (grip.origin_world, Some(crate::command::DynRole::Width)) + } + crate::scene::pick::grip::GripEditMode::RectangleHeight => { + (grip.origin_world, Some(crate::command::DynRole::Height)) + } }); if let Some((origin, scalar_role)) = grip_input { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 1370c45c6..6064d01f8 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1591,6 +1591,8 @@ impl OpenCADStudio { 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].dyn_fields.is_empty() diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 2867268c0..a78e6e370 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1590,6 +1590,12 @@ impl OpenCADStudio { GripEditMode::ArcLength => { Some(crate::scene::model::object::GripMenuAction::ArcLength) } + GripEditMode::RectangleWidth => { + Some(crate::scene::model::object::GripMenuAction::RectangleWidth) + } + GripEditMode::RectangleHeight => { + Some(crate::scene::model::object::GripMenuAction::RectangleHeight) + } GripEditMode::Stretch => None, }; let actions: Vec<_> = if menu_action.is_some() { @@ -3268,6 +3274,7 @@ impl OpenCADStudio { if matches!( grip.mode, GripEditMode::Lengthen | GripEditMode::Radius | GripEditMode::ArcLength + | GripEditMode::RectangleWidth | GripEditMode::RectangleHeight ) { self.grip_pending = None; self.command_line.input.clear(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 263b60b65..3e31f3d60 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -984,6 +984,12 @@ bg={bg_ms:.1}ms n={view_count}" crate::scene::pick::grip::GripEditMode::ArcLength => { crate::scene::model::object::GripMenuAction::ArcLength } + crate::scene::pick::grip::GripEditMode::RectangleWidth => { + crate::scene::model::object::GripMenuAction::RectangleWidth + } + crate::scene::pick::grip::GripEditMode::RectangleHeight => { + crate::scene::model::object::GripMenuAction::RectangleHeight + } _ => return None, }; let original = self @@ -1772,6 +1778,8 @@ bg={bg_ms:.1}ms n={view_count}" 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 ) }); let dyn_capturing = diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 67fa6e418..c5b2a8dac 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -888,6 +888,13 @@ pub(crate) fn is_rectangle(pline: &LwPolyline) -> bool { if pline.common.extended_data.get_record("OCS_RECTANGLE").is_some() { return true; } + is_resizable_rectangle(pline) +} + +/// Geometry check used for constrained rectangle grips. Unlike the semantic +/// marker, this must stop matching as soon as an unrestricted vertex edit has +/// distorted the four-corner rectangle. +fn is_resizable_rectangle(pline: &LwPolyline) -> bool { if !pline.is_closed || pline.vertices.len() != 4 || pline.vertices.iter().any(|vertex| vertex.bulge.abs() > 1.0e-9) @@ -1242,7 +1249,19 @@ impl crate::entities::traits::Grippable for LwPolyline { action: GripMenuAction::ConvertToArc, } }; - vec![ + let mut items = Vec::new(); + if is_resizable_rectangle(self) && seg < 4 { + // Moving an edge changes the dimension perpendicular to it. + items.push(GripMenuItem { + label: if seg % 2 == 0 { "Height" } else { "Width" }, + action: if seg % 2 == 0 { + GripMenuAction::RectangleHeight + } else { + GripMenuAction::RectangleWidth + }, + }); + } + items.extend([ GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch, @@ -1252,7 +1271,94 @@ impl crate::entities::traits::Grippable for LwPolyline { action: GripMenuAction::AddVertex, }, convert, - ] + ]); + items + } + + fn grip_menu_value_prompt( + &self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + ) -> Option<&'static str> { + use crate::scene::model::object::GripMenuAction as A; + let n = self.vertices.len(); + (is_resizable_rectangle(self) + && n == 4 + && (n..n + 4).contains(&grip_id) + && matches!(action, A::RectangleWidth | A::RectangleHeight)) + .then_some(match action { + A::RectangleWidth => "New width", + _ => "New height", + }) + } + + 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 !is_resizable_rectangle(self) + || !matches!(action, A::RectangleWidth | A::RectangleHeight) + { + return None; + } + let seg = grip_id.checked_sub(4)?; + if seg >= 4 { + return None; + } + let opposite_mid = { + let a = self.vertices[(seg + 2) % 4].location; + let b = self.vertices[(seg + 3) % 4].location; + Vec2::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5) + }; + let edge = { + let a = self.vertices[seg].location; + let b = self.vertices[(seg + 1) % 4].location; + Vec2::new(b.x - a.x, b.y - a.y) + }; + let edge_length = edge.length(); + if edge_length <= 1.0e-12 { + return None; + } + let normal = Vec2::new(-edge.y / edge_length, edge.x / edge_length); + let value = (Vec2::new(point.x, point.y) - opposite_mid).dot(normal).abs(); + (value > 1.0e-9).then_some(value) + } + + fn apply_grip_menu_value( + &mut self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + value: f64, + ) { + use crate::scene::model::object::GripMenuAction as A; + if !is_resizable_rectangle(self) || value <= 1.0e-9 + || !matches!(action, A::RectangleWidth | A::RectangleHeight) + { + return; + } + let Some(seg) = grip_id.checked_sub(4) else { + return; + }; + if seg >= 4 { + return; + } + let i0 = seg; + let i1 = (seg + 1) % 4; + let o0 = (seg + 3) % 4; + let o1 = (seg + 2) % 4; + let selected_mid = (self.vertices[i0].location + self.vertices[i1].location) * 0.5; + let opposite_mid = (self.vertices[o0].location + self.vertices[o1].location) * 0.5; + let delta = selected_mid - opposite_mid; + let distance = (delta.x * delta.x + delta.y * delta.y).sqrt(); + if distance <= 1.0e-9 { + return; + } + let direction = acadrust::types::Vector2::new(delta.x / distance, delta.y / distance); + self.vertices[i0].location = self.vertices[o0].location + direction * value; + self.vertices[i1].location = self.vertices[o1].location + direction * value; } fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) { use crate::scene::model::object::GripMenuAction as A; @@ -1440,7 +1546,8 @@ impl crate::entities::traits::MassPropsCalc for acadrust::entities::LwPolyline { #[cfg(test)] mod tests { use super::*; - use crate::entities::traits::PropertyEditable; + use crate::entities::traits::{Grippable, PropertyEditable}; + use crate::scene::model::object::GripMenuAction; use acadrust::entities::{LwPolyline, LwVertex}; use acadrust::Vector2; @@ -1453,6 +1560,50 @@ mod tests { pl } + fn make_test_rectangle() -> LwPolyline { + let mut pl = LwPolyline::default(); + pl.is_closed = true; + pl.vertices = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)] + .into_iter() + .map(|(x, y)| LwVertex::new(Vector2::new(x, y))) + .collect(); + pl + } + + #[test] + 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); + + let right = pl.grip_menu(5); + assert_eq!(right[0].action, GripMenuAction::RectangleWidth); + assert_eq!(right[1].action, GripMenuAction::Stretch); + } + + #[test] + fn rectangle_dimension_edit_keeps_opposite_edge_fixed() { + let mut pl = make_test_rectangle(); + pl.apply_grip_menu_value(6, GripMenuAction::RectangleHeight, 8.0); + assert_eq!(pl.vertices[0].location, Vector2::new(0.0, 0.0)); + assert_eq!(pl.vertices[1].location, Vector2::new(10.0, 0.0)); + assert_eq!(pl.vertices[2].location, Vector2::new(10.0, 8.0)); + assert_eq!(pl.vertices[3].location, Vector2::new(0.0, 8.0)); + assert!(is_resizable_rectangle(&pl)); + } + + #[test] + fn rectangle_point_preview_reports_full_dimension() { + let pl = make_test_rectangle(); + let value = pl.grip_menu_point_value( + 5, + GripMenuAction::RectangleWidth, + glam::DVec3::new(14.0, 2.5, 0.0), + ); + assert_eq!(value, Some(14.0)); + } + #[test] fn test_lwpolyline_uniform_width_shows_global_width() { let pl = make_test_lwpolyline(3, 5.0); diff --git a/src/modules/draw/draw/shapes.rs b/src/modules/draw/draw/shapes.rs index c73f201a5..4dc7b590b 100644 --- a/src/modules/draw/draw/shapes.rs +++ b/src/modules/draw/draw/shapes.rs @@ -86,6 +86,20 @@ fn make_pline(points: &[DVec3], plane: WorkingPlane) -> EntityType { })) } +/// Plain four-corner rectangles still use the lightweight polyline entity, +/// but carry the same semantic hint as rectangles made by the full RECT +/// command. Keep this separate from `make_pline`, which is also used by +/// polygon commands. +fn make_basic_rect(points: &[DVec3; 4], plane: WorkingPlane) -> EntityType { + let mut entity = make_pline(points, plane); + if let EntityType::LwPolyline(polyline) = &mut entity { + let mut marker = acadrust::xdata::ExtendedDataRecord::new("OCS_RECTANGLE"); + marker.add_value(acadrust::xdata::XDataValue::Integer16(1)); + polyline.common.extended_data.add_record(marker); + } + entity +} + #[derive(Clone, Copy)] struct RectStyle { chamfer_first: f64, @@ -894,7 +908,7 @@ impl CadCommand for RectRotCommand { let c = b + perp * h; let d = a + perp * h; let corners = [a, b, c, d].map(|point| self.plane.to_world(point)); - CmdResult::CommitAndExit(make_pline(&corners, self.plane)) + CmdResult::CommitAndExit(make_basic_rect(&corners, self.plane)) } } } @@ -998,7 +1012,7 @@ impl CadCommand for RectCenCommand { return CmdResult::NeedPoint; } let q = ucs_box_around_center(c, pt, self.plane); - CmdResult::CommitAndExit(make_pline(&q, self.plane)) + CmdResult::CommitAndExit(make_basic_rect(&q, self.plane)) } } } @@ -1478,6 +1492,25 @@ fn edge_poly_params( mod tests { use super::*; + #[test] + fn basic_rectangle_constructor_adds_semantic_marker() { + let plane = WorkingPlane::default(); + let corners = [ + DVec3::new(0.0, 0.0, 0.0), + DVec3::new(10.0, 0.0, 0.0), + DVec3::new(10.0, 5.0, 0.0), + DVec3::new(0.0, 5.0, 0.0), + ]; + let EntityType::LwPolyline(polyline) = make_basic_rect(&corners, plane) else { + panic!("rectangle constructor must create an LwPolyline"); + }; + assert!(polyline + .common + .extended_data + .get_record("OCS_RECTANGLE") + .is_some()); + } + #[test] fn polygon_variants_share_last_valid_side_count() { let mut inscribed = PolyCommand::new(); diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 5e7367141..34e539b6c 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -175,6 +175,8 @@ pub enum GripMenuAction { Lengthen, Radius, ArcLength, + RectangleWidth, + RectangleHeight, AddVertex, RemoveVertex, ConvertToArc, diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index 135122a04..757c1496e 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -56,6 +56,8 @@ pub enum GripEditMode { Lengthen, Radius, ArcLength, + RectangleWidth, + RectangleHeight, } #[derive(Clone, Debug)] @@ -106,6 +108,18 @@ impl GripEdit { edit.mode = GripEditMode::ArcLength; edit } + + pub fn rectangle_width(handle: Handle, grip_id: usize, world: DVec3) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::RectangleWidth; + edit + } + + pub fn rectangle_height(handle: Handle, grip_id: usize, world: DVec3) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::RectangleHeight; + edit + } } // ── Screen-space helpers ───────────────────────────────────────────────────