diff --git a/Cargo.lock b/Cargo.lock index b25201540..7fee04c00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,7 +895,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6f3842eca010637ec7eb16688b6bd5ef3628ad83#6f3842eca010637ec7eb16688b6bd5ef3628ad83" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=a3185be8189d78d6e93b4ea5b9238b184da4f3cf#a3185be8189d78d6e93b4ea5b9238b184da4f3cf" dependencies = [ "acadrust", "cavalier_contours", @@ -906,7 +906,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6f3842eca010637ec7eb16688b6bd5ef3628ad83#6f3842eca010637ec7eb16688b6bd5ef3628ad83" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=a3185be8189d78d6e93b4ea5b9238b184da4f3cf#a3185be8189d78d6e93b4ea5b9238b184da4f3cf" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 3119234e3..0b8d05a61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "88fdb556dc8a292931a52b52ba42fc322dd39c23", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6f3842eca010637ec7eb16688b6bd5ef3628ad83", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6f3842eca010637ec7eb16688b6bd5ef3628ad83" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "a3185be8189d78d6e93b4ea5b9238b184da4f3cf", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "a3185be8189d78d6e93b4ea5b9238b184da4f3cf" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index acb34b13d..a23ddf81b 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 @@ -407,13 +423,37 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let i = self.active_tab; if let Some(grip) = self.tabs[i].active_grip.clone() { - if grip.mode == GripEditMode::Stretch { + if matches!(grip.mode, GripEditMode::Stretch | GripEditMode::RectangleResize) { let dyn_locked = self.tabs[i] .dyn_fields .iter() .any(|field| field.buffer.is_some()); - let target = if dyn_locked { + let target = if matches!(grip.mode, GripEditMode::RectangleResize) { + grip.rectangle_frame.map(|(opposite, width_axis, height_axis)| { + let cursor_delta = self.tabs[i].last_cursor_world - opposite; + let mut width = cursor_delta.dot(width_axis); + let mut height = cursor_delta.dot(height_axis); + for field in &self.tabs[i].dyn_fields { + let value = field + .buffer + .as_ref() + .and_then(|buffer| crate::app::expr_eval::eval_number(buffer)); + if let Some(value) = value { + match field.role { + crate::command::DynRole::Width => { + width = value.abs().copysign(width); + } + crate::command::DynRole::Height => { + height = value.abs().copysign(height); + } + _ => {} + } + } + } + opposite + width_axis * width + height_axis * height + }) + } else if dyn_locked { // Distance only: // keep the cursor's current direction. // @@ -490,10 +530,32 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } let delta = target - grip.last_world; - let actions: Vec<_> = grip - .targets - .iter() - .map(|target_grip| { + let actions: Vec<_> = if let Some((opposite, width_axis, height_axis)) = grip.rectangle_frame { + let opposite_id = (grip.grip_id + 2) % 4; + let mut edits = Vec::with_capacity(3); + for adjacent_id in [(opposite_id + 1) % 4, (opposite_id + 3) % 4] { + if let Some(original) = self.tabs[i] + .selected_grip_handles + .iter() + .zip(self.tabs[i].selected_grips.iter()) + .find(|(owner, candidate)| **owner == grip.handle && candidate.id == adjacent_id) + .map(|(_, candidate)| candidate.world) + { + let d = original - opposite; + let axis = if d.dot(width_axis).abs() >= d.dot(height_axis).abs() { + width_axis + } else { + height_axis + }; + edits.push((grip.handle, adjacent_id, GripApply::Absolute( + opposite + axis * (target - opposite).dot(axis), + ))); + } + } + edits.push((grip.handle, grip.grip_id, GripApply::Absolute(target))); + edits + } else { + grip.targets.iter().map(|target_grip| { let apply = if target_grip.is_translate { GripApply::Translate(delta) } else { @@ -507,8 +569,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { target_grip.grip_id, apply, ) - }) - .collect(); + }).collect() + }; for (handle, grip_id, apply) in actions { self.tabs[i] @@ -1324,6 +1386,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if matches!( item.action, GripMenuAction::Stretch + | GripMenuAction::RectangleResize | GripMenuAction::MoveWithText | GripMenuAction::MoveWithDimLine | GripMenuAction::MoveWithLeader @@ -1381,12 +1444,59 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { || (!is_dimension && g.is_midpoint), ) }; - self.tabs[i].active_grip = Some(GripEdit::single( - popup.handle, - grip_id, - is_translate, - g.world, - )); + if matches!(item.action, GripMenuAction::RectangleResize) { + let rectangle = self.tabs[i] + .scene + .document + .get_entity(popup.handle) + .and_then(|entity| { + let AcadEntityType::LwPolyline(polyline) = entity else { + return None; + }; + let (frame, plane) = + crate::entities::lwpolyline::rectangle_frame(polyline)?; + let opposite = polyline + .vertices + .get((popup.grip_id + 2) % 4)? + .location; + Some(( + glam::DVec3::from_array( + plane.point_at([opposite.x, opposite.y]), + ), + glam::DVec3::from_array(plane.vector_at(frame.width_axis)) + .try_normalize()?, + glam::DVec3::from_array(plane.vector_at(frame.height_axis)) + .try_normalize()?, + )) + }); + if let Some((opposite, width_axis, height_axis)) = rectangle { + if self.grip_originals.is_empty() { + self.grip_originals = self.tabs[i] + .scene + .document + .get_entity(popup.handle) + .cloned() + .map(|entity| vec![(popup.handle, entity)]) + .unwrap_or_default(); + } + self.tabs[i].active_grip = Some(GripEdit::rectangle_resize( + popup.handle, + popup.grip_id, + g.world, + opposite, + width_axis, + height_axis, + )); + self.sync_dyn_fields(); + } + } else { + self.tabs[i].active_grip = Some(GripEdit::single( + popup.handle, + grip_id, + is_translate, + g.world, + )); + } } return Task::none(); } @@ -1410,6 +1520,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 +1551,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 +1582,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..89c8c0138 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -71,6 +71,10 @@ impl OpenCADStudio { // // Grip editing is not an `active_cmd`, so handle it before the normal // command-only path below. + let rectangle_frame = self.tabs[i] + .active_grip + .as_ref() + .and_then(|grip| grip.rectangle_frame); let grip_input = self.tabs[i] .active_grip .as_ref() @@ -87,13 +91,26 @@ 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)) + } + crate::scene::pick::grip::GripEditMode::RectangleResize => { + (grip.origin_world, None) + } }); if let Some((origin, scalar_role)) = grip_input { - let wanted_roles: Vec = scalar_role.map_or_else( - || vec![crate::command::DynRole::Distance, crate::command::DynRole::Angle], - |role| vec![role], - ); + let wanted_roles: Vec = if rectangle_frame.is_some() { + vec![crate::command::DynRole::Width, crate::command::DynRole::Height] + } else { + scalar_role.map_or_else( + || vec![crate::command::DynRole::Distance, crate::command::DynRole::Angle], + |role| vec![role], + ) + }; let current: Vec = self.tabs[i] .dyn_fields .iter() @@ -109,12 +126,14 @@ impl OpenCADStudio { self.tabs[i].dyn_active = 0; } - self.tabs[i].dyn_guide = if scalar_role.is_some() { + self.tabs[i].dyn_guide = if rectangle_frame.is_some() { + crate::command::DynGuide::RectSides + } else if scalar_role.is_some() { crate::command::DynGuide::Radius } else { crate::command::DynGuide::Polar }; - self.tabs[i].dyn_anchor = Some(origin); + self.tabs[i].dyn_anchor = rectangle_frame.map(|frame| frame.0).or(Some(origin)); self.tabs[i].dyn_ref = None; return; } 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..f9a0ff386 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -649,6 +649,7 @@ impl OpenCADStudio { mode: GripEditMode::Stretch, axis, targets, + rectangle_frame: None, } } @@ -1579,6 +1580,30 @@ impl OpenCADStudio { )); let apply_started = Instant::now(); + if let Some((opposite, width_axis, height_axis)) = grip.rectangle_frame { + let delta = snapped - opposite; + let mut width = delta.dot(width_axis); + let mut height = delta.dot(height_axis); + for field in &self.tabs[i].dyn_fields { + let Some(buffer) = field.buffer.as_ref() else { + continue; + }; + let Some(value) = crate::app::expr_eval::eval_number(buffer) else { + continue; + }; + match field.role { + crate::command::DynRole::Width => { + width = value.abs().copysign(width); + } + crate::command::DynRole::Height => { + height = value.abs().copysign(height); + } + _ => {} + } + } + snapped = opposite + width_axis * width + height_axis * height; + self.tabs[i].last_cursor_world = snapped; + } let delta = snapped - grip.last_world; let menu_action = match grip.mode { GripEditMode::Lengthen => { @@ -1590,9 +1615,47 @@ 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::RectangleResize => None, GripEditMode::Stretch => None, }; - let actions: Vec<_> = if menu_action.is_some() { + let actions: Vec<_> = if matches!(grip.mode, GripEditMode::RectangleResize) { + let Some((opposite, width_axis, height_axis)) = grip.rectangle_frame else { + return Task::none(); + }; + let opposite_id = (grip.grip_id + 2) % 4; + let adjacent_ids = [(opposite_id + 1) % 4, (opposite_id + 3) % 4]; + let mut edits = Vec::with_capacity(3); + for adjacent_id in adjacent_ids { + let original = self.tabs[i] + .selected_grip_handles + .iter() + .zip(self.tabs[i].selected_grips.iter()) + .find(|(owner, candidate)| { + **owner == grip.handle && candidate.id == adjacent_id + }) + .map(|(_, candidate)| candidate.world); + if let Some(original) = original { + let original_delta = original - opposite; + let axis = if original_delta.dot(width_axis).abs() + >= original_delta.dot(height_axis).abs() + { + width_axis + } else { + height_axis + }; + let point = opposite + axis * (snapped - opposite).dot(axis); + edits.push((grip.handle, adjacent_id, GripApply::Absolute(point))); + } + } + edits.push((grip.handle, grip.grip_id, GripApply::Absolute(snapped))); + edits + } else if menu_action.is_some() { Vec::new() } else { grip.targets @@ -3268,6 +3331,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..42dd3c81e 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 @@ -998,6 +1004,64 @@ bg={bg_ms:.1}ms n={view_count}" w, ) }); + let rectangle_values = tab + .active_grip + .as_ref() + .and_then(|grip| grip.rectangle_frame) + .map(|(opposite, width_axis, height_axis)| { + let delta = w - opposite; + (delta.dot(width_axis).abs(), delta.dot(height_axis).abs()) + }); + let rectangle_label_screens = tab + .active_grip + .as_ref() + .and_then(|grip| grip.rectangle_frame) + .and_then(|(opposite, width_axis, height_axis)| { + let delta = w - opposite; + let width = delta.dot(width_axis); + let height = delta.dot(height_axis); + let width_center = opposite + width_axis * (width * 0.5); + let height_center = opposite + + width_axis * width + + height_axis * (height * 0.5); + let rectangle_center = opposite + + width_axis * (width * 0.5) + + height_axis * (height * 0.5); + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let (camera, bounds) = tab + .scene + .viewport_edit_frame((vw, vh)) + .unwrap_or_else(|| { + ( + tab.scene.camera.borrow().clone(), + tab.scene.active_model_tile_bounds(vw, vh), + ) + }); + let project = |point| { + camera.project(point, bounds).map(|screen| iced::Point::new( + bounds.x + screen.x, + bounds.y + screen.y, + )) + }; + let center = project(rectangle_center)?; + let offset_from_center = |side: iced::Point, pixels: f32| { + let dx = side.x - center.x; + let dy = side.y - center.y; + let length = dx.hypot(dy); + if length > 1.0e-3 { + iced::Point::new( + side.x + dx / length * pixels, + side.y + dy / length * pixels, + ) + } else { + side + } + }; + Some(( + offset_from_center(project(width_center)?, 14.0), + offset_from_center(project(height_center)?, 18.0), + )) + }); let boxes: Vec = tab .dyn_fields .iter() @@ -1005,6 +1069,14 @@ bg={bg_ms:.1}ms n={view_count}" .map(|(idx, f)| { let value = match (&f.buffer, live) { (Some(b), _) => b.clone(), + (None, _) if rectangle_values.is_some() => { + let (width, height) = rectangle_values.unwrap(); + match f.role { + crate::command::DynRole::Width => format!("{width:.4}"), + crate::command::DynRole::Height => format!("{height:.4}"), + _ => String::new(), + } + } // An angle step with a command-supplied live value // (ARC span / direction) shows it in degrees. (None, Some(lv)) if f.component == DynComponent::Angle => { @@ -1033,6 +1105,13 @@ bg={bg_ms:.1}ms n={view_count}" active: idx == tab.dyn_active, locked: f.locked(), role: f.role, + center: rectangle_label_screens.and_then(|(width, height)| { + match f.role { + crate::command::DynRole::Width => Some(width), + crate::command::DynRole::Height => Some(height), + _ => None, + } + }), } }) .collect(); @@ -1772,6 +1851,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..e0c1c26bf 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -1,5 +1,7 @@ use acadrust::entities::{LwPolyline, LwVertex}; -use cadkernel::geom2d::{signed_area, Curve, Polyline, PolylineVertex, Vec2}; +use cadkernel::geom2d::{ + signed_area, Curve, Polyline, PolylineVertex, RectangleFrame, Tolerance, Vec2, +}; use crate::t; @@ -884,31 +886,19 @@ fn set_revision_cloud_arc_length(pline: &mut LwPolyline, requested: f64) { pline.vertices = cloud_vertices(&points, magnitude * sign, width_ratios); } +pub(crate) fn rectangle_frame( + pline: &LwPolyline, +) -> Option<(RectangleFrame, cadkernel::space::Plane)> { + let planar = crate::entities::curve::lwpolyline_curve(pline)?; + let Curve::Polyline(polyline) = &planar.curve else { + return None; + }; + let rectangle = polyline.rectangle_frame(Tolerance::default())?; + Some((rectangle, planar.plane)) +} + pub(crate) fn is_rectangle(pline: &LwPolyline) -> bool { - if pline.common.extended_data.get_record("OCS_RECTANGLE").is_some() { - return true; - } - if !pline.is_closed - || pline.vertices.len() != 4 - || pline.vertices.iter().any(|vertex| vertex.bulge.abs() > 1.0e-9) - { - return false; - } - let edges: Vec = (0..4) - .map(|index| { - let from = pline.vertices[index].location; - let to = pline.vertices[(index + 1) % 4].location; - Vec2::new(to.x - from.x, to.y - from.y) - }) - .collect(); - let lengths: Vec = edges.iter().map(|edge| edge.length()).collect(); - if lengths.iter().any(|length| *length <= 1.0e-12) { - return false; - } - let tolerance = 1.0e-9; - edges[0].dot(edges[1]).abs() <= tolerance * lengths[0] * lengths[1] - && edges[0].cross(edges[2]).abs() <= tolerance * lengths[0] * lengths[2] - && edges[1].cross(edges[3]).abs() <= tolerance * lengths[1] * lengths[3] + rectangle_frame(pline).is_some() } fn properties(pline: &LwPolyline) -> Vec { @@ -1203,7 +1193,14 @@ impl crate::entities::traits::Grippable for LwPolyline { // Vertex grip. Break only where a split is possible: any vertex // of a closed polyline, interior vertices of an open one. let breakable = n >= 3 && (self.is_closed || (grip_id > 0 && grip_id < n - 1)); - let mut items = vec![ + let mut items = Vec::new(); + if is_rectangle(self) { + items.push(GripMenuItem { + label: "Resize", + action: GripMenuAction::RectangleResize, + }); + } + items.extend([ GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch, @@ -1216,7 +1213,7 @@ impl crate::entities::traits::Grippable for LwPolyline { label: "Remove Vertex", action: GripMenuAction::RemoveVertex, }, - ]; + ]); if breakable { items.push(GripMenuItem { label: "Break", @@ -1242,7 +1239,19 @@ impl crate::entities::traits::Grippable for LwPolyline { action: GripMenuAction::ConvertToArc, } }; - vec![ + let mut items = Vec::new(); + if is_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 +1261,93 @@ 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_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; + let (frame, plane) = rectangle_frame(self)?; + let seg = grip_id.checked_sub(4)?; + if seg >= 4 { + return None; + } + let axis = match (seg % 2, action) { + (0, A::RectangleHeight) => Vec2::from(frame.height_axis), + (1, A::RectangleWidth) => Vec2::from(frame.width_axis), + _ => 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 point = Vec2::from(plane.project(point.to_array())?); + let value = (point - opposite_mid).dot(axis).abs(); + (value > Tolerance::default().linear()).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 value <= Tolerance::default().linear() { + return; + } + let Some((frame, _)) = rectangle_frame(self) else { + return; + }; + let Some(seg) = grip_id.checked_sub(4) else { + return; + }; + if seg >= 4 { + return; + } + let axis = match (seg % 2, action) { + (0, A::RectangleHeight) => frame.height_axis, + (1, A::RectangleWidth) => frame.width_axis, + _ => 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 signed_distance = delta.x * axis[0] + delta.y * axis[1]; + if signed_distance.abs() <= Tolerance::default().linear() { + return; + } + let direction = acadrust::types::Vector2::new(axis[0], axis[1]) + * signed_distance.signum(); + 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,9 +1535,10 @@ 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; + use acadrust::{Vector2, Vector3}; fn make_test_lwpolyline(count: usize, constant_width: f64) -> LwPolyline { let mut pl = LwPolyline::default(); @@ -1453,6 +1549,71 @@ 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_corner_offers_resize_before_stretch() { + let pl = make_test_rectangle(); + let corner = pl.grip_menu(0); + assert_eq!(corner[0].action, GripMenuAction::RectangleResize); + assert_eq!(corner[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_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 rectangle_point_preview_uses_entity_plane() { + let mut pl = make_test_rectangle(); + pl.normal = Vector3::new(0.0, 1.0, 0.0); + pl.elevation = 5.0; + let (_, plane) = rectangle_frame(&pl).expect("rectangle frame"); + let point = glam::DVec3::from_array(plane.point_at([14.0, 2.5])); + + let value = pl.grip_menu_point_value(5, GripMenuAction::RectangleWidth, point); + + 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..bf61c9fff 100644 --- a/src/modules/draw/draw/shapes.rs +++ b/src/modules/draw/draw/shapes.rs @@ -272,7 +272,7 @@ fn make_rect_pline( style: RectStyle, ) -> Option { let (geometry, elevation) = rectangle_polyline(corners, plane, style)?; - let mut polyline = LwPolyline { + let polyline = LwPolyline { vertices: geometry .vertices .iter() @@ -289,9 +289,6 @@ fn make_rect_pline( thickness: style.thickness, ..Default::default() }; - let mut marker = acadrust::xdata::ExtendedDataRecord::new("OCS_RECTANGLE"); - marker.add_value(acadrust::xdata::XDataValue::Integer16(1)); - polyline.common.extended_data.add_record(marker); Some(plane.place_entity(EntityType::LwPolyline(polyline))) } diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 5e7367141..8a6079de5 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -175,6 +175,9 @@ pub enum GripMenuAction { Lengthen, Radius, ArcLength, + RectangleWidth, + RectangleHeight, + RectangleResize, AddVertex, RemoveVertex, ConvertToArc, diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index 135122a04..f8f76e080 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -48,6 +48,8 @@ pub struct GripEdit { pub axis: Option, /// Every hot grip moved by this edit. A normal grip edit contains one target. pub targets: Vec, + /// Opposite corner and local width/height axes for rectangle corner resize. + pub rectangle_frame: Option<(DVec3, DVec3, DVec3)>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -56,6 +58,9 @@ pub enum GripEditMode { Lengthen, Radius, ArcLength, + RectangleWidth, + RectangleHeight, + RectangleResize, } #[derive(Clone, Debug)] @@ -86,6 +91,7 @@ impl GripEdit { is_translate, last_world: world, }], + rectangle_frame: None, } } @@ -106,6 +112,32 @@ 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 + } + + pub fn rectangle_resize( + handle: Handle, + grip_id: usize, + world: DVec3, + opposite: DVec3, + width_axis: DVec3, + height_axis: DVec3, + ) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::RectangleResize; + edit.rectangle_frame = Some((opposite, width_axis, height_axis)); + edit + } } // ── Screen-space helpers ─────────────────────────────────────────────────── diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 113057a8d..05e88b46a 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -2685,6 +2685,8 @@ pub struct DynBox { /// User has typed a value (the box no longer tracks the cursor). pub locked: bool, pub role: DynRole, + /// Optional exact viewport position, used by rotated rectangle dimensions. + pub center: Option, } pub fn dynamic_input_overlay<'a>( @@ -3038,7 +3040,7 @@ impl DynInputCanvas { // ── Box placement by role ── for b in &self.boxes { - let center = match b.role { + let center = b.center.unwrap_or_else(|| match b.role { DynRole::Angle => self.label_screen.unwrap_or_else(|| { let a_mid = a_ref + sweep * 0.5; let r = (len - DYN_BOX_H * 2.0).max(len * 0.5); @@ -3049,10 +3051,18 @@ impl DynInputCanvas { }), DynRole::X | DynRole::Width => Point { x: (base.x + cursor.x) * 0.5, - y: base.y + 14.0, + y: if self.guide == DynGuide::RectSides { + base.y - (cursor.y - base.y).signum() * 14.0 + } else { + base.y + 14.0 + }, }, DynRole::Y | DynRole::Height => Point { - x: corner.x + 18.0, + x: if self.guide == DynGuide::RectSides { + corner.x + (cursor.x - base.x).signum() * 18.0 + } else { + corner.x + 18.0 + }, y: (base.y + cursor.y) * 0.5, }, // Perpendicular measure: on the measured segment / dim line. @@ -3071,7 +3081,7 @@ impl DynInputCanvas { x: base.x + dx * len * 0.5 + nx * 16.0, y: base.y + dy * len * 0.5 + ny * 16.0, }, - }; + }); Self::draw_box(frame, b, center, bounds, theme); } // Keep the tracking hint near the crosshair in guided layouts.