diff --git a/src/app/block_actions.rs b/src/app/block_actions.rs index 096e71f9e..16c288bbe 100644 --- a/src/app/block_actions.rs +++ b/src/app/block_actions.rs @@ -38,7 +38,7 @@ impl OpenCADStudio { }; if !args.is_empty() && kind.is_none() { self.command_line.push_error( - crate::t!("Supported actions: Move, Scale, Rotate, Flip. Other action types are not wired yet.") + crate::t!("Supported actions: Move, Scale, Stretch, Rotate, Flip. Other action types are not wired yet.") .as_ref(), ); return Task::none(); @@ -76,6 +76,7 @@ impl OpenCADStudio { parameter, targets, location, + frame, } => { if self.tabs[i].id != document || self.tabs[i] @@ -90,8 +91,15 @@ impl OpenCADStudio { return Task::none(); } let mut staged = self.tabs[i].scene.document.clone(); - match actions::create(&mut staged, definition, kind, parameter, &targets, location) - { + match actions::create_with_frame( + &mut staged, + definition, + kind, + parameter, + &targets, + location, + frame, + ) { Ok(_) => { self.push_undo_snapshot(i, &format!("{} action", kind.name())); self.tabs[i].scene.document = staged; diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 75316765c..d26afb245 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -59,6 +59,138 @@ fn feed(app: &mut OpenCADStudio, text: &str) { let _ = app.update(Message::CommandSubmit); } +#[test] +fn block_actions_stretch_frame_selection_history_test_and_saved_grip() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2"); + let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Stretch)[0].clone(); + let before = app.tabs[i].scene.document.clone(); + // A half-authored frame and Escape create neither objects nor history. + let depth = app.tabs[i].history.undo_stack.len(); + let _ = app.dispatch_command("BACTION Stretch"); + feed(&mut app, &p.name); + feed(&mut app, "0.5,-1"); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[i].scene.document, before); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + let _ = app.dispatch_command("BACTION Stretch"); + feed(&mut app, &p.name); + assert!(!app.tabs[i] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + feed(&mut app, "0.5,-1"); + feed(&mut app, "0.5,1"); // zero-width error retains first corner + assert!(!app.tabs[i] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + feed(&mut app, "2,1"); + assert!(app.tabs[i] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + let targets = members(&app); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete( + targets.clone(), + )); + let _ = app.feed_command(crate::command::StepInput::Enter); + feed(&mut app, "3,4"); + assert!(app.tabs[i].active_cmd.is_none()); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + let action = actions::actions(&app.tabs[i].scene.document, def)[0].0; + assert_eq!( + actions::frame_outline(&app.tabs[i].scene.document, action).len(), + 4 + ); + app.undo_steps(1); + assert!(actions::actions(&app.tabs[i].scene.document, def).is_empty()); + app.redo_steps(1); + let editor = app.tabs[i].scene.document.clone(); + let _ = app.dispatch_command("BTESTBLOCK"); + let ti = app.active_tab; + let test_insert = app.tabs[ti].block_test.as_ref().unwrap().insert; + app.tabs[ti].scene.selected.insert(test_insert); + app.refresh_properties(); + let grip = app.tabs[ti] + .selected_grips + .iter() + .find(|g| super::block_actions::is_test_grip(g.id)) + .unwrap() + .id; + assert!(app.start_test_action_grip(test_insert, grip)); + feed(&mut app, "12,0"); + let EntityType::Line(line) = app.tabs[ti].scene.document.get_entity(targets[0]).unwrap() else { + panic!() + }; + assert_eq!(line.start, Vector3::ZERO); + assert_eq!(line.end, Vector3::new(3., 0., 0.)); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + assert_eq!(app.tabs[i].scene.document, editor); + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BCLOSE"); + app.tabs[i].scene.selected.insert(reference); + app.refresh_properties(); + let grip = app.tabs[i] + .selected_grips + .iter() + .find(|g| super::block_actions::is_test_grip(g.id)) + .unwrap() + .id; + let depth = app.tabs[i].history.undo_stack.len(); + assert!(app.start_test_action_grip(reference, grip)); + feed(&mut app, "15,0"); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + let changed = app.tabs[i].scene.document.clone(); + let EntityType::Insert(insert) = changed.get_entity(reference).unwrap() else { + panic!() + }; + let member = changed + .block_records + .get(&insert.block_name) + .unwrap() + .entity_handles[0]; + let EntityType::Line(line) = changed.get_entity(member).unwrap() else { + panic!() + }; + assert_eq!(line.start, Vector3::ZERO); + assert_eq!(line.end, Vector3::new(6., 0., 0.)); + assert_eq!( + changed.get_entity(targets[0]), + editor.get_entity(targets[0]) + ); + app.undo_steps(1); + assert_eq!( + app.tabs[i].scene.document.get_entity(reference), + editor.get_entity(reference) + ); + app.redo_steps(1); + assert_eq!( + app.tabs[i].scene.document.get_entity(member), + changed.get_entity(member) + ); + let bytes = codec::DwgWriter::write_to_vec(&changed).unwrap(); + app.tabs[i].scene.document = codec::DwgReader::from_stream(std::io::Cursor::new(bytes)) + .read() + .unwrap(); + begin(&mut app, reference); + assert_eq!( + app.tabs[i].active_block_edit_session().unwrap().br_handle, + def + ); + assert_eq!( + actions::frame_outline(&app.tabs[i].scene.document, action).len(), + 4 + ); +} + #[test] fn block_parameters_rotation_only_save_close_reopen_then_flip() { use crate::scene::block_actions::{self as actions, Kind}; @@ -903,6 +1035,7 @@ fn block_actions_rejected_outside_editor_preserve_running_command_and_stale_resu parameter: p, targets: members(&app), location: glam::DVec3::ZERO, + frame: None, }); assert_eq!(app.tabs[i].scene.document, before); let _ = app.dispatch_command("LINE"); diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 61cd36469..f145df36d 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -1244,6 +1244,27 @@ fn block_actions_palette_controls_require_compatible_parameters_and_start_comman }); } +#[test] +fn block_actions_stretch_palette_requires_distance_parameter() { + with_stack(|| { + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + app.authoring_palette_tab = crate::ui::window::authoring_palettes::Tab::Actions; + let point = authoring_control(&app, "authoring-tool-stretch-action"); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2"); + let point = authoring_control(&app, "authoring-tool-stretch-action"); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab] + .active_cmd + .as_ref() + .unwrap() + .prompt() + .contains("Stretch")); + }); +} + #[test] fn block_actions_rotate_flip_palette_requires_matching_parameters() { with_stack(|| { diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index e35f0c4af..e58e93cab 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -415,6 +415,7 @@ impl OpenCADStudio { self.action_error(Some(crate::scene::block_actions::Kind::Scale)), self.action_error(Some(crate::scene::block_actions::Kind::Rotate)), self.action_error(Some(crate::scene::block_actions::Kind::Flip)), + self.action_error(Some(crate::scene::block_actions::Kind::Stretch)), ) } crate::ui::dock::PanelId::BlockPalette => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index e2f9936d4..8cb6d833e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1203,8 +1203,19 @@ bg={bg_ms:.1}ms n={view_count}" session.br_handle, ) .into_iter() - .map(|(_, action, _, _)| { + .map(|(handle, action, _, _)| { let mut g = crate::scene::block_distance_parameter::Graphic::default(); + let frame = crate::scene::block_actions::frame_outline( + &tab.scene.document, + handle, + ); + g.lines.extend( + frame + .iter() + .zip(frame.iter().cycle().skip(1)) + .take(frame.len()) + .map(|(a, b)| [*a, *b]), + ); g.grips.push((action.display_location, false)); g.labels .push((action.display_location, action.element.name.clone())); diff --git a/src/command.rs b/src/command.rs index d25c2f31a..f76a0ac9b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1528,6 +1528,7 @@ pub enum CmdResult { parameter: Handle, targets: Vec, location: DVec3, + frame: Option<[DVec3; 2]>, }, EvaluateBlockParameter { insert: Option, diff --git a/src/modules/block_editor/action.rs b/src/modules/block_editor/action.rs index 519c2cd78..d80dcc7ce 100644 --- a/src/modules/block_editor/action.rs +++ b/src/modules/block_editor/action.rs @@ -16,6 +16,7 @@ pub(crate) struct ActionCommand { parameter: Option, targets: Vec, selecting: bool, + frame: Vec, context: Option, } impl ActionCommand { @@ -33,6 +34,7 @@ impl ActionCommand { parameter: None, targets: vec![], selecting: false, + frame: vec![], context: None, } } @@ -43,7 +45,7 @@ impl ActionCommand { ); } self.parameter = Some(p.handle); - self.selecting = true; + self.selecting = self.kind != Some(Kind::Stretch); CmdResult::DeselectAndContinue } } @@ -62,7 +64,8 @@ impl CadCommand for ActionCommand { } fn prompt(&self) -> String { if self.kind.is_none() { - return crate::t!("BACTION Enter action type [Move/Scale/Rotate/Flip]:").into_owned(); + return crate::t!("BACTION Enter action type [Move/Scale/Stretch/Rotate/Flip]:") + .into_owned(); } if self.parameter.is_none() { return crate::tf!( @@ -71,11 +74,17 @@ impl CadCommand for ActionCommand { ) .into_owned(); } - crate::t!(if self.selecting { - "BACTION Select objects for the action, then press Enter:" - } else { - "BACTION Specify action marker location:" - }) + crate::t!( + if self.kind == Some(Kind::Stretch) && self.frame.is_empty() { + "BACTION Specify first corner of stretch frame:" + } else if self.kind == Some(Kind::Stretch) && self.frame.len() == 1 { + "BACTION Specify opposite corner of stretch frame:" + } else if self.selecting { + "BACTION Select objects for the action, then press Enter:" + } else { + "BACTION Specify action marker location:" + } + ) .into_owned() } fn options(&self) -> Vec { @@ -83,6 +92,7 @@ impl CadCommand for ActionCommand { vec![ CmdOption::new("Move", "Move"), CmdOption::new("Scale", "Scale"), + CmdOption::new("Stretch", "Stretch"), CmdOption::new("Rotate", "Rotate"), CmdOption::new("Flip", "Flip"), ] @@ -97,7 +107,7 @@ impl CadCommand for ActionCommand { CmdResult::NeedPoint } else { CmdResult::ReportError( - crate::t!("Choose Move, Scale, Rotate or Flip.").into_owned(), + crate::t!("Choose Move, Scale, Stretch, Rotate or Flip.").into_owned(), ) }); } @@ -149,6 +159,35 @@ impl CadCommand for ActionCommand { ) }); } + if self.kind == Some(Kind::Stretch) && self.frame.len() < 2 { + let plane = self + .candidates + .iter() + .find(|p| Some(p.handle) == self.parameter) + .unwrap() + .end + .z; + if !point.is_finite() || (point.z - plane).abs() > 1e-8 { + return CmdResult::ReportError( + crate::t!("Specify a finite corner in the parameter's XY plane.").into_owned(), + ); + } + if self + .frame + .first() + .is_some_and(|a| (point.x - a.x).abs() < 1e-9 || (point.y - a.y).abs() < 1e-9) + { + return CmdResult::ReportError( + crate::t!("The stretch frame needs a nonzero width and height.").into_owned(), + ); + } + self.frame.push(point); + if self.frame.len() == 2 { + self.selecting = true; + return CmdResult::DeselectAndContinue; + } + return CmdResult::NeedPoint; + } if self.selecting { return CmdResult::NeedPoint; } @@ -164,8 +203,26 @@ impl CadCommand for ActionCommand { parameter: self.parameter.unwrap(), targets: self.targets.clone(), location: point, + frame: (self.frame.len() == 2).then(|| [self.frame[0], self.frame[1]]), } } + fn on_preview_wires(&mut self, point: DVec3) -> Vec { + let Some(a) = self.frame.first().filter(|_| point.is_finite()) else { + return vec![]; + }; + let b = self.frame.get(1).copied().unwrap_or(point); + vec![crate::scene::WireModel::solid_f64( + "preview_stretch_frame".into(), + vec![ + a.to_array(), + [b.x, a.y, a.z], + [b.x, b.y, a.z], + [a.x, b.y, a.z], + ], + crate::scene::WireModel::CYAN, + true, + )] + } fn is_selection_gathering(&self) -> bool { self.selecting } diff --git a/src/modules/draw/modify/stretch.rs b/src/modules/draw/modify/stretch.rs index 9e1886556..3c7db4250 100644 --- a/src/modules/draw/modify/stretch.rs +++ b/src/modules/draw/modify/stretch.rs @@ -277,7 +277,13 @@ pub fn stretch_entity( /// The bulge of the segment from vertex `k` once its ends moved from /// `before` to `after`: the arc keeps its height, bulge ยท chord / 2. -fn held_bulge(bulge: f64, before: &[[f64; 2]], after: &[[f64; 2]], k: usize, closed: bool) -> f64 { +pub(crate) fn held_bulge( + bulge: f64, + before: &[[f64; 2]], + after: &[[f64; 2]], + k: usize, + closed: bool, +) -> f64 { let next = if k + 1 < before.len() { k + 1 } else if closed { diff --git a/src/scene/block_actions.rs b/src/scene/block_actions.rs index ca01b0c22..5756df495 100644 --- a/src/scene/block_actions.rs +++ b/src/scene/block_actions.rs @@ -1,14 +1,15 @@ -//! Native Move/Scale/Rotate/Flip authoring and deterministic block evaluation. +//! Native Move/Scale/Stretch/Rotate/Flip authoring and deterministic block evaluation. use super::block_authoring::{self as authoring, *}; use codec::objects::{ BlockAction, BlockActionOffsets, BlockActionWithBasePoint, BlockBasePointAction, BlockConnection, BlockElement, BlockEvalValue, BlockMoveAction, BlockFlipAction, - DynamicBlockData, ObjectType, + BlockStretchAction, DynamicBlockData, ObjectType, }; use codec::types::{Transform, Vector3}; use codec::{CadDocument, EntityType, Handle}; use glam::DVec3; pub(crate) mod instance; +mod stretch; #[cfg(test)] mod tests; @@ -18,6 +19,7 @@ pub enum Kind { Scale, Rotate, Flip, + Stretch, } impl Kind { pub(crate) fn parse(s: &str) -> Option { @@ -26,6 +28,7 @@ impl Kind { "SCALE" | "S" => Some(Self::Scale), "ROTATE" | "R" => Some(Self::Rotate), "FLIP" | "F" => Some(Self::Flip), + "STRETCH" | "ST" => Some(Self::Stretch), _ => None, } } @@ -35,6 +38,7 @@ impl Kind { Self::Scale => "Scale", Self::Rotate => "Rotate", Self::Flip => "Flip", + Self::Stretch => "Stretch", } } } @@ -78,6 +82,7 @@ pub(crate) fn all_parameters(doc: &CadDocument, definition: Handle) -> Vec bool { match kind { Kind::Move => !p.rotation && !p.flip, + Kind::Stretch => !p.rotation && !p.flip && !p.point, Kind::Scale => !p.rotation && !p.flip && !p.point && !p.xy, Kind::Rotate => p.rotation, Kind::Flip => p.flip, @@ -342,6 +347,7 @@ pub(crate) fn supported(e: &EntityType) -> bool { | EntityType::MText(_) ) } +#[cfg(test)] pub(crate) fn create( doc: &mut CadDocument, definition: Handle, @@ -349,6 +355,18 @@ pub(crate) fn create( parameter: Handle, targets: &[Handle], location: DVec3, +) -> Result { + create_with_frame(doc, definition, kind, parameter, targets, location, None) +} + +pub(crate) fn create_with_frame( + doc: &mut CadDocument, + definition: Handle, + kind: Kind, + parameter: Handle, + targets: &[Handle], + location: DVec3, + frame: Option<[DVec3; 2]>, ) -> Result { if !location.is_finite() { return Err("Specify a finite action location."); @@ -374,6 +392,19 @@ pub(crate) fn create( }) { return Err("This milestone supports lines, polylines, arcs, circles, ellipses, points and text belonging to this block."); } + let stretch_binding = if kind == Kind::Stretch { + if (location.z - p.end.z).abs() > 1e-8 { + return Err("Place the Stretch marker in the parameter's XY plane."); + } + Some(stretch::bind( + doc, + targets, + frame.ok_or("Specify a stretch frame before selecting objects.")?, + p.end.z, + )?) + } else { + None + }; let mut staged = doc.clone(); let (graph, slots) = authoring::prepare(&mut staged, definition)?; let (dxf, cpp) = match kind { @@ -381,6 +412,7 @@ pub(crate) fn create( Kind::Scale => ("BLOCKSCALEACTION", "AcDbBlockScaleAction"), Kind::Rotate => ("BLOCKROTATEACTION", "AcDbBlockRotationAction"), Kind::Flip => ("BLOCKFLIPACTION", "AcDbBlockFlipAction"), + Kind::Stretch => ("BLOCKSTRETCHACTION", "AcDbBlockStretchAction"), }; register_classes(&mut staged, &[(dxf, cpp, 55)]); let handle = staged.allocate_handle(); @@ -404,6 +436,21 @@ pub(crate) fn create( name: name.into(), }; let data = match kind { + Kind::Stretch => { + let (points, handles) = stretch_binding.unwrap(); + DynamicBlockData::StretchAction(BlockStretchAction { + action, + connections: [connection("EndXDelta"), connection("EndYDelta")], + points, + handles, + codes: vec![], + offsets: BlockActionOffsets { + distance_multiplier: 1., + angle_offset: 0., + flags: 0, + }, + }) + } Kind::Move => DynamicBlockData::MoveAction(BlockMoveAction { action, connections: if p.point { @@ -489,6 +536,9 @@ pub(crate) fn actions( DynamicBlockData::MoveAction(a) => { Some((o.handle, &a.action, Kind::Move, a.connections[0].code)) } + DynamicBlockData::StretchAction(a) => { + Some((o.handle, &a.action, Kind::Stretch, a.connections[0].code)) + } DynamicBlockData::FlipAction(a) => { Some((o.handle, &a.action, Kind::Flip, a.connections[0].code)) } @@ -505,6 +555,16 @@ pub(crate) fn actions( }) .collect() } +pub(crate) fn frame_outline(doc: &CadDocument, handle: Handle) -> Vec { + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&handle) else { + return vec![]; + }; + let DynamicBlockData::StretchAction(a) = &o.data else { + return vec![]; + }; + stretch::outline(&a.points, a.action.display_location.z) +} + pub(crate) fn reframe(doc: &mut CadDocument, definition: Handle, t: &Transform) -> usize { let handles = actions(doc, definition) .iter() @@ -518,6 +578,17 @@ pub(crate) fn reframe(doc: &mut CadDocument, definition: Handle, t: &Transform) DynamicBlockData::MoveAction(a) => { a.action.display_location = t.apply(a.action.display_location) } + DynamicBlockData::StretchAction(a) => { + let plane = a.action.display_location.z; + a.action.display_location = t.apply(a.action.display_location); + a.points = stretch::outline(&a.points, plane) + .into_iter() + .map(|p| { + let p = t.apply(p); + codec::types::Vector2::new(p.x, p.y) + }) + .collect(); + } DynamicBlockData::FlipAction(a) => { a.action.display_location = t.apply(a.action.display_location) } @@ -613,8 +684,10 @@ pub(crate) fn evaluate_value( ) -> Result { let graph = authoring::graph(baseline, definition)? .ok_or("The block authoring graph is unavailable.")?; - if baseline.objects.values().any(|o|matches!(o,ObjectType::DynamicBlock(o) if o.owner==graph && matches!(&o.data, - DynamicBlockData::StretchAction(_)|DynamicBlockData::PolarStretchAction(_)|DynamicBlockData::ArrayAction(_)))) { + if baseline.objects.values().any(|o| { + matches!(o,ObjectType::DynamicBlock(o) if o.owner==graph && matches!(&o.data, + DynamicBlockData::PolarStretchAction(_)|DynamicBlockData::ArrayAction(_))) + }) { return Err("This block contains action types not supported yet."); } let p = all_parameters(baseline, definition) @@ -660,7 +733,16 @@ pub(crate) fn evaluate_value( set_value(&mut staged, &p, input)?; let all = actions(baseline, definition); let mut affected = std::collections::BTreeSet::new(); - for (_, a, _, _) in &all { + for (h, a, kind, _) in &all { + if *kind == Kind::Stretch { + let ObjectType::DynamicBlock(o) = &baseline.objects[h] else { + unreachable!() + }; + let DynamicBlockData::StretchAction(s) = &o.data else { + unreachable!() + }; + stretch::validate(baseline, s)?; + } affected.extend(a.dependencies.iter().copied()); } for h in affected { @@ -688,7 +770,9 @@ pub(crate) fn evaluate_value( } let end = current(&staged, &q); let transform = match kind { - Kind::Move => crate::command::EntityTransform::Translate(end - q.end), + Kind::Move | Kind::Stretch => { + crate::command::EntityTransform::Translate(end - q.end) + } Kind::Rotate => { let a = end - q.base; let b = q.end - q.base; @@ -805,7 +889,11 @@ pub(crate) fn evaluate_value( } _ => {} } - super::view::dispatch::apply_transform(&mut entity, &transform); + if let DynamicBlockData::StretchAction(s) = &o.data { + stretch::apply(&mut entity, s, end - q.end)?; + } else { + super::view::dispatch::apply_transform(&mut entity, &transform); + } let bounds = entity.as_entity().bounding_box(); if !v(bounds.min).is_finite() || !v(bounds.max).is_finite() { return Err("The parameter value produces non-finite geometry."); diff --git a/src/scene/block_actions/stretch.rs b/src/scene/block_actions/stretch.rs new file mode 100644 index 000000000..ca500bc65 --- /dev/null +++ b/src/scene/block_actions/stretch.rs @@ -0,0 +1,195 @@ +//! A native Stretch action freezes its vertex membership at authoring time. +use super::*; +use codec::objects::{BlockStretchAction, BlockStretchHandle}; +use codec::types::Vector2; + +pub(crate) fn control_points(e: &EntityType) -> Result, &'static str> { + let points = match e { + EntityType::Line(l) => vec![v(l.start), v(l.end)], + EntityType::LwPolyline(p) => { + let p = crate::entities::curve::lwpolyline_world_xy(p) + .ok_or("Stretch requires a planar XY lightweight polyline.")?; + p.vertices + .iter() + .map(|v| DVec3::new(v.location.x, v.location.y, p.elevation)) + .collect() + } + _ => return Err("Stretch currently supports lines and planar lightweight polylines."), + }; + if points.is_empty() || points.iter().any(|p| !p.is_finite()) { + return Err("Stretch targets need finite vertices."); + } + Ok(points) +} + +pub(crate) fn bind( + doc: &CadDocument, + targets: &[Handle], + corners: [DVec3; 2], + plane: f64, +) -> Result<(Vec, Vec), &'static str> { + if corners + .iter() + .any(|p| !p.is_finite() || (p.z - plane).abs() > 1e-8) + { + return Err("Specify finite stretch-frame corners in the parameter's XY plane."); + } + let low = corners[0].min(corners[1]); + let high = corners[0].max(corners[1]); + if high.x - low.x < 1e-9 || high.y - low.y < 1e-9 { + return Err("The stretch frame needs a nonzero width and height."); + } + let mut bindings = vec![]; + let mut unique = targets.to_vec(); + unique.sort(); + unique.dedup(); + for handle in unique { + let entity = doc + .get_entity(handle) + .ok_or("A Stretch target is unavailable.")?; + let points = control_points(entity)?; + if points.iter().any(|p| (p.z - plane).abs() > 1e-8) { + return Err("Stretch targets must lie in the parameter's XY plane."); + } + let indexes = points + .iter() + .enumerate() + .filter(|(_, p)| p.x >= low.x && p.x <= high.x && p.y >= low.y && p.y <= high.y) + .map(|(i, _)| i as i32) + .collect::>(); + if !indexes.is_empty() { + bindings.push(BlockStretchHandle { handle, indexes }); + } + } + Ok(( + vec![Vector2::new(low.x, low.y), Vector2::new(high.x, high.y)], + bindings, + )) +} + +pub(crate) fn validate(doc: &CadDocument, a: &BlockStretchAction) -> Result<(), &'static str> { + if a.points.len() < 2 + || a.points + .iter() + .any(|p| !p.x.is_finite() || !p.y.is_finite()) + || a.offsets.distance_multiplier != 1. + || a.offsets.angle_offset != 0. + || a.offsets.flags != 0 + || !a.codes.is_empty() + || !a.action.action_ids.is_empty() + || a.connections[0].code != a.connections[1].code + || a.connections[0].name != "EndXDelta" + || a.connections[1].name != "EndYDelta" + { + return Err("This Stretch action uses options or parameter bindings not supported yet."); + } + let mut seen = std::collections::BTreeSet::new(); + for binding in &a.handles { + if !a.action.dependencies.contains(&binding.handle) || !seen.insert(binding.handle) { + return Err("The Stretch action has invalid target bindings."); + } + let count = control_points( + doc.get_entity(binding.handle) + .ok_or("A Stretch target is unavailable.")?, + )? + .len(); + let unique = binding + .indexes + .iter() + .copied() + .collect::>(); + if unique.len() != binding.indexes.len() + || binding + .indexes + .iter() + .any(|i| *i < 0 || *i as usize >= count) + { + return Err("A Stretch vertex binding no longer matches its object."); + } + } + // Selected objects without a partial vertex binding move as a whole. + for h in &a.action.dependencies { + control_points( + doc.get_entity(*h) + .ok_or("A Stretch target is unavailable.")?, + )?; + } + Ok(()) +} + +pub(crate) fn apply( + e: &mut EntityType, + a: &BlockStretchAction, + delta: DVec3, +) -> Result<(), &'static str> { + let Some(binding) = a + .handles + .iter() + .find(|b| b.handle == e.common().handle && !b.indexes.is_empty()) + else { + crate::scene::view::dispatch::apply_transform( + e, + &crate::command::EntityTransform::Translate(delta), + ); + return Ok(()); + }; + match e { + EntityType::Line(l) => { + for index in &binding.indexes { + let at = match index { + 0 => &mut l.start, + 1 => &mut l.end, + _ => return Err("Invalid Stretch line endpoint."), + }; + *at = *at + native(delta); + } + } + EntityType::LwPolyline(p) => { + let mut world = crate::entities::curve::lwpolyline_world_xy(p) + .ok_or("Stretch requires a planar XY lightweight polyline.")?; + let before = world + .vertices + .iter() + .map(|v| [v.location.x, v.location.y]) + .collect::>(); + for index in &binding.indexes { + let v = world + .vertices + .get_mut(*index as usize) + .ok_or("Invalid Stretch polyline vertex.")?; + v.location.x += delta.x; + v.location.y += delta.y; + } + let after = world + .vertices + .iter() + .map(|v| [v.location.x, v.location.y]) + .collect::>(); + for (i, v) in world.vertices.iter_mut().enumerate() { + v.bulge = crate::modules::draw::modify::stretch::held_bulge( + v.bulge, + &before, + &after, + i, + world.is_closed, + ); + } + *p = world; + } + _ => return Err("Unsupported Stretch target."), + } + Ok(()) +} + +pub(crate) fn outline(points: &[Vector2], z: f64) -> Vec { + let points = if points.len() == 2 { + let (a, b) = (points[0], points[1]); + vec![a, Vector2::new(b.x, a.y), b, Vector2::new(a.x, b.y)] + } else { + points.to_vec() + }; + points + .into_iter() + .map(|p| Vector3::new(p.x, p.y, z)) + .collect() +} diff --git a/src/scene/block_actions/tests.rs b/src/scene/block_actions/tests.rs index 7eaf14d55..129ff0f75 100644 --- a/src/scene/block_actions/tests.rs +++ b/src/scene/block_actions/tests.rs @@ -605,3 +605,264 @@ fn block_actions_reframe_moves_markers_and_retains_targets_and_connections() { assert_eq!(s.action.action.dependencies, b.action.action.dependencies); assert_eq!(s.connections, b.connections); } + +fn stretch_fixture() -> (CadDocument, Handle, Vec, Handle, Handle) { + let mut scene = Scene::new(); + let mut poly = codec::entities::LwPolyline::from_points(vec![ + codec::types::Vector2::new(0., 1.), + codec::types::Vector2::new(10., 1.), + codec::types::Vector2::new(10., 3.), + codec::types::Vector2::new(0., 3.), + ]); + poly.is_closed = true; + poly.vertices[0].bulge = 0.2; + scene.define_block_raw( + "STRETCH", + Vector3::ZERO, + vec![ + EntityType::Line(codec::entities::Line::from_points( + Vector3::ZERO, + Vector3::new(10., 0., 0.), + )), + EntityType::LwPolyline(poly), + EntityType::Line(codec::entities::Line::from_points( + Vector3::new(20., 0., 0.), + Vector3::new(21., 0., 0.), + )), + ], + ); + let br = scene.document.block_records.get("STRETCH").unwrap(); + let (def, handles) = (br.handle, br.entity_handles.clone()); + let mut doc = scene.document; + let p = linear(&mut doc, def); + let action = create_with_frame( + &mut doc, + def, + Kind::Stretch, + p, + &handles, + DVec3::new(5., 4., 0.), + Some([DVec3::new(9., -1., 0.), DVec3::new(11., 4., 0.)]), + ) + .unwrap(); + (doc, def, handles, p, action) +} + +#[test] +fn block_actions_stretch_frozen_vertices_polyline_and_whole_objects_roundtrip() { + let (doc, def, handles, p, action) = stretch_fixture(); + let ObjectType::DynamicBlock(o) = &doc.objects[&action] else { + panic!() + }; + let DynamicBlockData::StretchAction(a) = &o.data else { + panic!() + }; + assert_eq!(a.handles[0].indexes, vec![1]); + assert_eq!(a.handles[1].indexes, vec![1, 2]); + assert_eq!(a.action.dependencies, handles); + let mut evaluated = doc.clone(); + for x in [20., 30., 12.] { + evaluate(&mut evaluated, &doc, def, p, DVec3::new(x, 0., 0.)).unwrap(); + let EntityType::Line(line) = evaluated.get_entity(handles[0]).unwrap() else { + panic!() + }; + assert_eq!(line.start, Vector3::ZERO); + assert_eq!(line.end, Vector3::new(x, 0., 0.)); + // Entirely outside the frame but explicitly in the action selection. + assert_eq!(start(&evaluated, handles[2]), Vector3::new(x + 10., 0., 0.)); + let EntityType::LwPolyline(poly) = evaluated.get_entity(handles[1]).unwrap() else { + panic!() + }; + assert_eq!(poly.vertices[0].location.x, 0.); + assert_eq!(poly.vertices[1].location.x, x); + assert_eq!(poly.vertices[2].location.x, x); + assert_eq!(poly.vertices[3].location.x, 0.); + // Preserve the curved segment's sagitta when its chord changes. + assert!((poly.vertices[0].bulge * x / 2. - 1.).abs() < 1e-8); + } + for reopened in [ + DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(), + DxfReader::from_reader(Cursor::new(DxfWriter::new(&doc).write_to_vec().unwrap())) + .unwrap() + .read() + .unwrap(), + ] { + assert_eq!(reopened.objects[&action], doc.objects[&action]); + let mut changed = reopened.clone(); + evaluate(&mut changed, &reopened, def, p, DVec3::new(14., 0., 0.)).unwrap(); + let EntityType::Line(line) = changed.get_entity(handles[0]).unwrap() else { + panic!() + }; + assert_eq!(line.end, Vector3::new(14., 0., 0.)); + } +} + +#[test] +fn block_actions_stretch_saved_reference_independence_copy_and_reopen() { + let (mut doc, def, handles, p, _) = stretch_fixture(); + let a = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "STRETCH", + Vector3::ZERO, + ))) + .unwrap(); + let b = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "STRETCH", + Vector3::new(40., 0., 0.), + ))) + .unwrap(); + let original = doc.clone(); + doc = instance::evaluate(&doc, a, p, DVec3::new(18., 0., 0.)) + .unwrap() + .unwrap(); + assert_eq!(doc.get_entity(handles[0]), original.get_entity(handles[0])); + assert_eq!(doc.get_entity(b), original.get_entity(b)); + let mut scene = Scene::new(); + scene.document = doc; + let c = scene.copy_entities( + &[a], + &crate::command::EntityTransform::Translate(DVec3::new(60., 0., 0.)), + )[0]; + let mut doc = scene.document; + let EntityType::Insert(ins) = doc.get_entity(a).unwrap() else { + panic!() + }; + let a_member = doc + .block_records + .get(&ins.block_name) + .unwrap() + .entity_handles[0]; + doc = instance::evaluate(&doc, c, p, DVec3::new(22., 0., 0.)) + .unwrap() + .unwrap(); + let EntityType::Line(line) = doc.get_entity(a_member).unwrap() else { + panic!() + }; + assert_eq!(line.end, Vector3::new(18., 0., 0.)); + let reopened = DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(); + let parameter = parameters(&reopened, def, Kind::Stretch)[0].clone(); + assert_eq!( + instance::current(&reopened, c, ¶meter).unwrap(), + DVec3::new(22., 0., 0.) + ); + let changed = instance::evaluate(&reopened, c, p, DVec3::new(13., 0., 0.)) + .unwrap() + .unwrap(); + let EntityType::Insert(ins) = changed.get_entity(c).unwrap() else { + panic!() + }; + let EntityType::Line(line) = changed + .get_entity( + changed + .block_records + .get(&ins.block_name) + .unwrap() + .entity_handles[0], + ) + .unwrap() + else { + panic!() + }; + assert_eq!(line.end, Vector3::new(13., 0., 0.)); +} + +#[test] +fn block_actions_stretch_invalid_frame_binding_and_options_are_atomic() { + let (mut doc, def, handles, p, action) = stretch_fixture(); + let before = doc.clone(); + for frame in [ + None, + Some([DVec3::ZERO, DVec3::X]), + Some([DVec3::ZERO, DVec3::splat(f64::NAN)]), + ] { + assert!(create_with_frame( + &mut doc, + def, + Kind::Stretch, + p, + &handles, + DVec3::ZERO, + frame + ) + .is_err()); + assert_eq!(doc, before); + } + for corrupt in [0, 1, 2] { + let mut invalid = doc.clone(); + let ObjectType::DynamicBlock(o) = invalid.objects.get_mut(&action).unwrap() else { + panic!() + }; + let DynamicBlockData::StretchAction(a) = &mut o.data else { + panic!() + }; + match corrupt { + 0 => a.handles[0].indexes = vec![99], + 1 => a.offsets.distance_multiplier = 2., + _ => a.connections[0].name = "BaseXDelta".into(), + } + let baseline = invalid.clone(); + assert!(evaluate(&mut invalid, &baseline, def, p, DVec3::new(12., 0., 0.)).is_err()); + assert_eq!(invalid, baseline); + } +} + +#[test] +fn block_actions_stretch_polar_xy_and_reframed_marker_keep_native_bindings() { + for kind in [distance::Kind::Polar, distance::Kind::XY] { + let (mut doc, def, targets) = fixture(); + let p = distance::create( + &mut doc, + def, + kind, + Vector3::ZERO, + Vector3::new(10., 1., 0.), + Vector3::new(5., 2., 0.), + ) + .unwrap(); + let action = create_with_frame( + &mut doc, + def, + Kind::Stretch, + p, + &targets[..1], + DVec3::new(3., 4., 0.), + Some([DVec3::new(1.5, -1., 0.), DVec3::new(2.5, 1., 0.)]), + ) + .unwrap(); + let baseline = doc.clone(); + evaluate(&mut doc, &baseline, def, p, DVec3::new(12., 4., 0.)).unwrap(); + let EntityType::Line(line) = doc.get_entity(targets[0]).unwrap() else { + panic!() + }; + assert_eq!(line.start, Vector3::UNIT_X); + assert!(line.end.distance(&Vector3::new(4., 3., 0.)) < 1e-8); + super::super::block_authoring::edit::move_part( + &mut doc, + def, + action, + super::super::block_authoring::edit::Part::Point, + Vector3::new(6., 7., 0.), + ) + .unwrap(); + let frame = frame_outline(&doc, action); + assert_eq!(frame[0], Vector3::new(1.5, -1., 0.)); + reframe( + &mut doc, + def, + &Transform::from_translation(Vector3::new(-2., -3., 0.)), + ); + assert_eq!(frame_outline(&doc, action)[0], Vector3::new(-0.5, -4., 0.)); + let ObjectType::DynamicBlock(o) = &doc.objects[&action] else { + panic!() + }; + let DynamicBlockData::StretchAction(a) = &o.data else { + panic!() + }; + assert_eq!(a.handles[0].indexes, vec![1]); + } +} diff --git a/src/scene/block_authoring/edit.rs b/src/scene/block_authoring/edit.rs index 8a8764a06..36d5548c4 100644 --- a/src/scene/block_authoring/edit.rs +++ b/src/scene/block_authoring/edit.rs @@ -104,7 +104,18 @@ pub fn items(doc: &CadDocument, definition: Handle) -> Vec { result.push(single(p.handle, p.name.clone(), p.def_point)); } for (handle, p, _, _) in crate::scene::block_actions::actions(doc, definition) { - result.push(single(handle, p.element.name.clone(), p.display_location)); + let mut item = single(handle, p.element.name.clone(), p.display_location); + let frame = crate::scene::block_actions::frame_outline(doc, handle); + if !frame.is_empty() { + item.graphic.lines.extend( + frame + .iter() + .zip(frame.iter().cycle().skip(1)) + .take(frame.len()) + .map(|(a, b)| [*a, *b]), + ); + } + result.push(item); } result } @@ -277,6 +288,7 @@ pub fn move_part( } } DynamicBlockData::MoveAction(p) => p.action.display_location = at, + DynamicBlockData::StretchAction(p) => p.action.display_location = at, DynamicBlockData::FlipAction(p) => p.action.display_location = at, DynamicBlockData::ScaleAction(p) | DynamicBlockData::RotateAction(p) => { p.action.action.display_location = at diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 3edb0a8f5..76666ad55 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1725,7 +1725,10 @@ impl Scene { .chain( super::block_actions::actions(&self.document, block) .into_iter() - .map(|(_, a, _, _)| a.display_location), + .flat_map(|(handle, a, _, _)| { + std::iter::once(a.display_location) + .chain(super::block_actions::frame_outline(&self.document, handle)) + }), ); for point in locations { let point = glam::DVec3::new(point.x, point.y, point.z); diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs index fced5729f..f59268cbe 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -198,6 +198,7 @@ fn catalog( scale_error: Option<&'static str>, rotate_error: Option<&'static str>, flip_action_error: Option<&'static str>, + stretch_action_error: Option<&'static str>, ) -> Element<'static, Message> { let (entries, unavailable) = match tab { Tab::Parameters => ( @@ -219,8 +220,8 @@ fn catalog( let orientation_error = if *label=="Flip" {flip_error} else {point_error}; let single=tab==Tab::Parameters&&matches!(*label,"Lookup"|"Basepoint"); let lookup_action=tab==Tab::Actions&&*label=="Lookup"; - let geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"|"Rotate"|"Flip"); - let action_error=match *label {"Scale"=>scale_error,"Rotate"=>rotate_error,"Flip"=>flip_action_error,_=>move_error}; + let geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"|"Stretch"|"Rotate"|"Flip"); + let action_error=match *label {"Scale"=>scale_error,"Stretch"=>stretch_action_error,"Rotate"=>rotate_error,"Flip"=>flip_action_error,_=>move_error}; let enabled = (geometry_action&&action_error.is_none())||(single&&point_error.is_none())||(lookup_action&&lookup_error.is_none())||(orientation && orientation_error.is_none()) || (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); let base = if enabled { icons::semantic(icon, 28.0) @@ -266,7 +267,7 @@ fn catalog( } else if single { crate::t!(point_error.unwrap_or(if *label=="Basepoint" {"Place or relocate the dynamic block insertion anchor."}else{"Place a Lookup parameter, then use the Lookup action to edit its property table."})).into_owned() } else if geometry_action { - crate::t!(action_error.unwrap_or("Select a parameter endpoint, select the objects it controls, then place the action marker. Use the reference parameter grip in the drawing or Test Block.")).into_owned() + crate::t!(action_error.unwrap_or(if *label=="Stretch" {"Select a parameter endpoint, define the stretch frame, select its objects, then place the action marker."}else{"Select a parameter endpoint, select the objects it controls, then place the action marker. Use the reference parameter grip in the drawing or Test Block."})).into_owned() } else if lookup_action { crate::t!(lookup_error.unwrap_or("Create or edit property Lookup tables for this block.")).into_owned() } else { @@ -284,7 +285,7 @@ fn catalog( } else if single { container(entry).id(if *label=="Lookup" {"authoring-tool-lookup"}else{"authoring-tool-basepoint"}).into() } else if geometry_action { - container(entry).id(match *label {"Move"=>"authoring-tool-move-action","Scale"=>"authoring-tool-scale-action","Rotate"=>"authoring-tool-rotate-action",_=>"authoring-tool-flip-action"}).into() + container(entry).id(match *label {"Move"=>"authoring-tool-move-action","Scale"=>"authoring-tool-scale-action","Rotate"=>"authoring-tool-rotate-action","Stretch"=>"authoring-tool-stretch-action",_=>"authoring-tool-flip-action"}).into() } else if lookup_action { container(entry).id("authoring-tool-lookup-table").into() } else { @@ -405,6 +406,7 @@ pub fn view( scale_error: Option<&'static str>, rotate_error: Option<&'static str>, flip_action_error: Option<&'static str>, + stretch_action_error: Option<&'static str>, ) -> Element<'static, Message> { let tabs = column(Tab::ALL.into_iter().map(|category| { let label = category.label(); @@ -463,7 +465,8 @@ pub fn view( move_error, scale_error, rotate_error, - flip_action_error + flip_action_error, + stretch_action_error )) .width(Fill) .height(Fill),