diff --git a/src/app/block_actions.rs b/src/app/block_actions.rs index 95841f92d..5a8c77cf2 100644 --- a/src/app/block_actions.rs +++ b/src/app/block_actions.rs @@ -95,7 +95,7 @@ impl OpenCADStudio { }; if !args.is_empty() && kind.is_none() { self.command_line.push_error( - crate::t!("Supported actions: Move, Scale, Stretch, Rotate, Flip. Other action types are not wired yet.") + crate::t!("Supported actions: Move, Scale, Stretch, Rotate, Flip, Array. Other action types are not wired yet.") .as_ref(), ); return Task::none(); @@ -181,6 +181,7 @@ impl OpenCADStudio { targets, location, frame, + array_offsets, } => { if self.tabs[i].id != document || self.tabs[i] @@ -195,7 +196,7 @@ impl OpenCADStudio { return Task::none(); } let mut staged = self.tabs[i].scene.document.clone(); - match actions::create_with_frame( + match actions::create_with_options( &mut staged, definition, kind, @@ -203,6 +204,7 @@ impl OpenCADStudio { &targets, location, frame, + array_offsets, ) { Ok(_) => { self.push_undo_snapshot(i, &format!("{} action", kind.name())); diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 47e911186..86a629629 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -1575,6 +1575,7 @@ fn block_actions_rejected_outside_editor_preserve_running_command_and_stale_resu targets: members(&app), location: glam::DVec3::ZERO, frame: None, + array_offsets: None, }); assert_eq!(app.tabs[i].scene.document, before); let _ = app.dispatch_command("LINE"); diff --git a/src/app/commands/bvisibility.rs b/src/app/commands/bvisibility.rs index a572b6bc7..062aaaaa6 100644 --- a/src/app/commands/bvisibility.rs +++ b/src/app/commands/bvisibility.rs @@ -395,11 +395,17 @@ impl OpenCADStudio { cmd if cmd == "BACTION" || cmd.starts_with("BACTION ") || cmd == "BACTIONTOOL" - || cmd.starts_with("BACTIONTOOL ") => + || cmd.starts_with("BACTIONTOOL ") + || cmd == "BARRAY" + || cmd.starts_with("BARRAY ") => { Some( self.start_block_action( - cmd.split_once(' ').map_or("", |(_, args)| args.trim()), + if cmd == "BARRAY" || cmd.starts_with("BARRAY ") { + "ARRAY" + } else { + cmd.split_once(' ').map_or("", |(_, args)| args.trim()) + }, ), ) } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 3685bfb40..d8c349f5e 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -181,12 +181,16 @@ impl OpenCADStudio { return Task::none(); } } - if matches!(cmd.split(' ').next(), Some("BACTION" | "BACTIONTOOL")) { - let args = cmd.split_once(' ').map_or("", |(_, a)| a.trim()); + if matches!(cmd.split(' ').next(), Some("BACTION" | "BACTIONTOOL" | "BARRAY")) { + let args = if cmd == "BARRAY" || cmd.starts_with("BARRAY ") { + "ARRAY" + } else { + cmd.split_once(' ').map_or("", |(_, a)| a.trim()) + }; if !args.is_empty() && crate::scene::block_actions::Kind::parse(args).is_none() { self.command_line.push_error( crate::t!( - "Supported actions: Move, Scale, Stretch, Rotate, Flip. Other action types are not wired yet." + "Supported actions: Move, Scale, Stretch, Rotate, Flip, Array. Other action types are not wired yet." ) .as_ref(), ); diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 0a652c7ff..d7de6de73 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -1334,6 +1334,31 @@ fn block_actions_stretch_palette_requires_distance_parameter() { }); } +#[test] +fn block_actions_array_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-array-action"); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + let _ = app.run_command_line("BPARAMETER POINT 0.5,0.5 0.7,0.7"); + let point = authoring_control(&app, "authoring-tool-array-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-array-action"); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab] + .active_cmd + .as_ref() + .unwrap() + .prompt() + .contains("Array")); + }); +} + #[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 e58e93cab..fe8c0c544 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -416,6 +416,7 @@ impl OpenCADStudio { 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)), + self.action_error(Some(crate::scene::block_actions::Kind::Array)), ) } crate::ui::dock::PanelId::BlockPalette => { diff --git a/src/app/update/command.rs b/src/app/update/command.rs index a85ae7f7c..06d1d7360 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -5267,7 +5267,7 @@ mod tablestyle_buffer_tests { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.ts_cell_textstyle[0] = "STALE_SENTINEL".to_string(); - app.style_new(StyleKind::Table); + let _ = app.style_new(StyleKind::Table); assert_ne!( app.ts_cell_textstyle[0], "STALE_SENTINEL", "buffers must reload from the fresh style" diff --git a/src/command.rs b/src/command.rs index 97143695b..0dd679d70 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1537,6 +1537,7 @@ pub enum CmdResult { targets: Vec, location: DVec3, frame: Option<[DVec3; 2]>, + array_offsets: Option<(f64, f64)>, }, EvaluateBlockParameter { insert: Option, diff --git a/src/modules/block_editor/action.rs b/src/modules/block_editor/action.rs index 65bfde5f3..a576f561d 100644 --- a/src/modules/block_editor/action.rs +++ b/src/modules/block_editor/action.rs @@ -5,7 +5,7 @@ use codec::Handle; use glam::DVec3; inventory::submit!(crate::command::CommandRegistration { - names: &["BACTION", "BACTIONTOOL"] + names: &["BACTION", "BACTIONTOOL", "BARRAY"] }); pub(crate) struct ActionCommand { @@ -17,6 +17,8 @@ pub(crate) struct ActionCommand { targets: Vec, selecting: bool, frame: Vec, + column_offset: Option, + row_offset: Option, context: Option, } impl ActionCommand { @@ -35,6 +37,8 @@ impl ActionCommand { targets: vec![], selecting: false, frame: vec![], + column_offset: None, + row_offset: None, context: None, } } @@ -55,6 +59,16 @@ impl CadCommand for ActionCommand { } fn input_kind(&self) -> crate::command::InputKind { if self.parameter.is_some() { + if self.kind == Some(Kind::Array) && !self.selecting { + let is_xy = self + .candidates + .iter() + .find(|p| Some(p.handle) == self.parameter) + .is_some_and(|p| p.xy); + if self.column_offset.is_none() || (is_xy && self.row_offset.is_none()) { + return crate::command::InputKind::SingleToken; + } + } crate::command::InputKind::Point } else if self.kind.is_some() { crate::command::InputKind::FreeText @@ -64,7 +78,7 @@ impl CadCommand for ActionCommand { } fn prompt(&self) -> String { if self.kind.is_none() { - return crate::t!("BACTION Enter action type [Move/Scale/Stretch/Rotate/Flip]:") + return crate::t!("BACTION Enter action type [Move/Scale/Stretch/Rotate/Flip/Array]:") .into_owned(); } if self.parameter.is_none() { @@ -74,18 +88,33 @@ impl CadCommand for ActionCommand { ) .into_owned(); } - 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:" + if self.kind == Some(Kind::Stretch) && self.frame.is_empty() { + return crate::t!("BACTION Specify first corner of stretch frame:").into_owned(); + } + if self.kind == Some(Kind::Stretch) && self.frame.len() == 1 { + return crate::t!("BACTION Specify opposite corner of stretch frame:").into_owned(); + } + if self.selecting { + return crate::t!("BACTION Select objects for the action, then press Enter:") + .into_owned(); + } + if self.kind == Some(Kind::Array) { + let is_xy = self + .candidates + .iter() + .find(|p| Some(p.handle) == self.parameter) + .is_some_and(|p| p.xy); + if self.column_offset.is_none() { + return crate::t!( + "BACTION Specify distance between columns (or distance between items):" + ) + .into_owned(); } - ) - .into_owned() + if is_xy && self.row_offset.is_none() { + return crate::t!("BACTION Specify distance between rows:").into_owned(); + } + } + crate::t!("BACTION Specify action marker location:").into_owned() } fn options(&self) -> Vec { if self.kind.is_none() { @@ -95,6 +124,7 @@ impl CadCommand for ActionCommand { CmdOption::new("Stretch", "Stretch"), CmdOption::new("Rotate", "Rotate"), CmdOption::new("Flip", "Flip"), + CmdOption::new("Array", "Array"), ] } else { vec![] @@ -107,7 +137,7 @@ impl CadCommand for ActionCommand { CmdResult::NeedPoint } else { CmdResult::ReportError( - crate::t!("Choose Move, Scale, Stretch, Rotate or Flip.").into_owned(), + crate::t!("Choose Move, Scale, Stretch, Rotate, Flip or Array.").into_owned(), ) }); } @@ -129,6 +159,41 @@ impl CadCommand for ActionCommand { ) }); } + if self.kind == Some(Kind::Array) && !self.selecting { + let is_xy = self + .candidates + .iter() + .find(|p| Some(p.handle) == self.parameter) + .is_some_and(|p| p.xy); + if self.column_offset.is_none() { + let Ok(dist) = s.trim().parse::() else { + return Some(CmdResult::ReportError( + crate::t!("Specify a positive distance.").into_owned(), + )); + }; + if dist <= 1e-6 || !dist.is_finite() { + return Some(CmdResult::ReportError( + crate::t!("Specify a positive distance.").into_owned(), + )); + } + self.column_offset = Some(dist); + return Some(CmdResult::NeedPoint); + } + if is_xy && self.row_offset.is_none() { + let Ok(dist) = s.trim().parse::() else { + return Some(CmdResult::ReportError( + crate::t!("Specify a positive distance.").into_owned(), + )); + }; + if dist <= 1e-6 || !dist.is_finite() { + return Some(CmdResult::ReportError( + crate::t!("Specify a positive distance.").into_owned(), + )); + } + self.row_offset = Some(dist); + return Some(CmdResult::NeedPoint); + } + } None } fn wants_point_pick_context(&self) -> bool { @@ -188,6 +253,33 @@ impl CadCommand for ActionCommand { } return CmdResult::NeedPoint; } + if self.kind == Some(Kind::Array) && !self.selecting { + let p = self + .candidates + .iter() + .find(|p| Some(p.handle) == self.parameter) + .unwrap(); + if self.column_offset.is_none() { + let dist = point.distance(p.base); + if dist <= 1e-6 || !dist.is_finite() { + return CmdResult::ReportError( + crate::t!("Specify a positive distance.").into_owned(), + ); + } + self.column_offset = Some(dist); + return CmdResult::NeedPoint; + } + if p.xy && self.row_offset.is_none() { + let dist = (point.y - p.base.y).abs().max(point.distance(p.base)); + if dist <= 1e-6 || !dist.is_finite() { + return CmdResult::ReportError( + crate::t!("Specify a positive distance.").into_owned(), + ); + } + self.row_offset = Some(dist); + return CmdResult::NeedPoint; + } + } if self.selecting { return CmdResult::NeedPoint; } @@ -196,6 +288,13 @@ impl CadCommand for ActionCommand { crate::t!("Specify a finite action location.").into_owned(), ); } + let array_offsets = if self.kind == Some(Kind::Array) { + let col = self.column_offset.unwrap_or(1.0); + let row = self.row_offset.unwrap_or(1.0); + Some((col, row)) + } else { + None + }; CmdResult::PlaceBlockAction { document: self.document, definition: self.definition, @@ -204,8 +303,10 @@ impl CadCommand for ActionCommand { targets: self.targets.clone(), location: point, frame: (self.frame.len() == 2).then(|| [self.frame[0], self.frame[1]]), + array_offsets, } } + fn on_preview_wires(&mut self, point: DVec3) -> Vec { frame_preview(&self.frame, point) } diff --git a/src/scene/block_actions.rs b/src/scene/block_actions.rs index 5be67e5aa..7b41e2b63 100644 --- a/src/scene/block_actions.rs +++ b/src/scene/block_actions.rs @@ -1,9 +1,9 @@ -//! Native Move/Scale/Stretch/Rotate/Flip authoring and deterministic block evaluation. +//! Native Move/Scale/Stretch/Rotate/Flip/Array authoring and deterministic block evaluation. use super::block_authoring::{self as authoring, *}; use codec::objects::{ - BlockAction, BlockActionOffsets, BlockActionWithBasePoint, BlockBasePointAction, - BlockConnection, BlockElement, BlockEvalValue, BlockMoveAction, BlockFlipAction, - BlockStretchAction, DynamicBlockData, ObjectType, + BlockAction, BlockActionOffsets, BlockActionWithBasePoint, BlockArrayAction, + BlockBasePointAction, BlockConnection, BlockElement, BlockEvalValue, BlockFlipAction, + BlockMoveAction, BlockStretchAction, DynamicBlockData, ObjectType, }; use codec::types::{Transform, Vector3}; use codec::{CadDocument, EntityType, Handle}; @@ -21,6 +21,7 @@ pub enum Kind { Rotate, Flip, Stretch, + Array, } impl Kind { pub(crate) fn parse(s: &str) -> Option { @@ -30,6 +31,7 @@ impl Kind { "ROTATE" | "R" => Some(Self::Rotate), "FLIP" | "F" => Some(Self::Flip), "STRETCH" | "ST" => Some(Self::Stretch), + "ARRAY" | "A" => Some(Self::Array), _ => None, } } @@ -40,6 +42,7 @@ impl Kind { Self::Rotate => "Rotate", Self::Flip => "Flip", Self::Stretch => "Stretch", + Self::Array => "Array", } } } @@ -87,6 +90,7 @@ pub(crate) fn compatible(p: &Parameter, kind: Kind) -> bool { Kind::Scale => !p.rotation && !p.flip && !p.point && !p.xy, Kind::Rotate => p.rotation, Kind::Flip => p.flip, + Kind::Array => !p.rotation && !p.flip && !p.point, } } @@ -357,9 +361,32 @@ pub(crate) fn create( targets: &[Handle], location: DVec3, ) -> Result { - create_with_frame(doc, definition, kind, parameter, targets, location, None) + create_with_options(doc, definition, kind, parameter, targets, location, None, None) } +#[cfg(test)] +pub(crate) fn create_array( + doc: &mut CadDocument, + definition: Handle, + parameter: Handle, + targets: &[Handle], + location: DVec3, + column_offset: f64, + row_offset: f64, +) -> Result { + create_with_options( + doc, + definition, + Kind::Array, + parameter, + targets, + location, + None, + Some((column_offset, row_offset)), + ) +} + +#[allow(dead_code)] pub(crate) fn create_with_frame( doc: &mut CadDocument, definition: Handle, @@ -368,6 +395,19 @@ pub(crate) fn create_with_frame( targets: &[Handle], location: DVec3, frame: Option<[DVec3; 2]>, +) -> Result { + create_with_options(doc, definition, kind, parameter, targets, location, frame, None) +} + +pub(crate) fn create_with_options( + doc: &mut CadDocument, + definition: Handle, + kind: Kind, + parameter: Handle, + targets: &[Handle], + location: DVec3, + frame: Option<[DVec3; 2]>, + array_offsets: Option<(f64, f64)>, ) -> Result { if !location.is_finite() { return Err("Specify a finite action location."); @@ -393,6 +433,18 @@ pub(crate) fn create_with_frame( }) { return Err("This milestone supports lines, polylines, arcs, circles, ellipses, points and text belonging to this block."); } + let (column_offset, row_offset) = if kind == Kind::Array { + let (col, row) = array_offsets.unwrap_or((1.0, 1.0)); + if col <= 1e-6 || !col.is_finite() { + return Err("Specify a positive column offset."); + } + if p.xy && (row <= 1e-6 || !row.is_finite()) { + return Err("Specify a positive row offset."); + } + (col, row) + } else { + (0.0, 0.0) + }; 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."); @@ -414,6 +466,7 @@ pub(crate) fn create_with_frame( Kind::Rotate => ("BLOCKROTATEACTION", "AcDbBlockRotationAction"), Kind::Flip => ("BLOCKFLIPACTION", "AcDbBlockFlipAction"), Kind::Stretch => ("BLOCKSTRETCHACTION", "AcDbBlockStretchAction"), + Kind::Array => ("BLOCKARRAYACTION", "AcDbBlockArrayAction"), }; register_classes(&mut staged, &[(dxf, cpp, 55)]); let handle = staged.allocate_handle(); @@ -437,6 +490,17 @@ pub(crate) fn create_with_frame( name: name.into(), }; let data = match kind { + Kind::Array => DynamicBlockData::ArrayAction(BlockArrayAction { + action, + connections: [ + connection("Base"), + connection("End"), + connection("UpdatedBase"), + connection("UpdatedEnd"), + ], + column_offset, + row_offset, + }), Kind::Stretch => { let (points, handles) = stretch_binding.unwrap(); DynamicBlockData::StretchAction(BlockStretchAction { @@ -511,6 +575,7 @@ pub(crate) fn create_with_frame( Ok(handle) } + /// Retarget an existing native action, retaining identity, graph and options. pub(crate) fn retarget( doc: &mut CadDocument, @@ -582,6 +647,7 @@ pub(crate) fn retarget( DynamicBlockData::ScaleAction(a) | DynamicBlockData::RotateAction(a) => { a.action.action.dependencies = targets } + DynamicBlockData::ArrayAction(a) => a.action.dependencies = targets, _ => return Err("This action's selection cannot be edited yet."), } if frame.is_some() && kind != Kind::Stretch { @@ -634,6 +700,10 @@ pub(crate) fn actions( .connections .first() .map(|c| (o.handle, &a.action.action, Kind::Scale, c.code)), + DynamicBlockData::ArrayAction(a) => a + .connections + .first() + .map(|c| (o.handle, &a.action, Kind::Array, c.code)), _ => None, } }) @@ -655,6 +725,7 @@ pub(crate) fn prune_erased_targets(doc: &mut CadDocument, definition: Handle) { let action = match &mut o.data { DynamicBlockData::MoveAction(a) => &mut a.action, DynamicBlockData::FlipAction(a) => &mut a.action, + DynamicBlockData::ArrayAction(a) => &mut a.action, DynamicBlockData::StretchAction(a) => { a.handles.retain(|b| live.contains(&b.handle)); &mut a.action @@ -691,6 +762,9 @@ 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::ArrayAction(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); @@ -705,6 +779,7 @@ pub(crate) fn reframe(doc: &mut CadDocument, definition: Handle, t: &Transform) DynamicBlockData::FlipAction(a) => { a.action.display_location = t.apply(a.action.display_location) } + DynamicBlockData::ScaleAction(a) | DynamicBlockData::RotateAction(a) => { a.action.action.display_location = t.apply(a.action.action.display_location); a.action.base_point = t.apply(a.action.base_point); @@ -751,27 +826,35 @@ pub(crate) fn sync_representation( .ok_or("The test representation is unavailable.")? .entity_handles .clone(); - if source.len() != target.len() { - return Err("The test representation no longer matches its definition."); - } - let affected = actions(doc, definition) + let has_array = actions(doc, definition) .iter() - .flat_map(|(_, a, _, _)| a.dependencies.iter().copied()) - .collect::>(); - for (from, to) in source.into_iter().zip(target) { - if !affected.contains(&from) { - continue; + .any(|(_, _, k, _)| *k == Kind::Array); + if source.len() == target.len() && !has_array { + let affected = actions(doc, definition) + .iter() + .flat_map(|(_, a, _, _)| a.dependencies.iter().copied()) + .collect::>(); + for (from, to) in source.into_iter().zip(target) { + if !affected.contains(&from) { + continue; + } + let mut entity = doc + .get_entity(from) + .cloned() + .ok_or("An action target is unavailable.")?; + let current = doc + .get_entity_mut(to) + .ok_or("An action representation target is unavailable.")?; + *entity.common_mut() = current.common().clone(); + *current = entity; } - let mut entity = doc - .get_entity(from) - .cloned() - .ok_or("An action target is unavailable.")?; - let current = doc - .get_entity_mut(to) - .ok_or("An action representation target is unavailable.")?; - *entity.common_mut() = current.common().clone(); - *current = entity; + return Ok(()); } + let evaluated = doc.clone(); + let mut scene = crate::scene::Scene::new(); + scene.document = doc.clone(); + representation::reconcile(&mut scene, definition, name, &evaluated)?; + *doc = scene.document; Ok(()) } @@ -822,8 +905,8 @@ fn evaluate_impl( 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::PolarStretchAction(_)|DynamicBlockData::ArrayAction(_))) + matches!(o, ObjectType::DynamicBlock(o) if o.owner == graph && matches!(&o.data, + DynamicBlockData::PolarStretchAction(_))) }) { return Err("This block contains action types not supported yet."); } @@ -870,6 +953,26 @@ fn evaluate_impl( } let mut staged = doc.clone(); set_value(&mut staged, &p, input)?; + let baseline_members = authoring::members(baseline, definition) + .into_iter() + .collect::>(); + let extra_handles = staged + .entities() + .filter(|e| { + e.common().owner_handle == definition && !baseline_members.contains(&e.common().handle) + }) + .map(|e| e.common().handle) + .collect::>(); + for h in extra_handles { + staged.remove_entity(h); + } + if let Some(record) = staged.block_records.iter_mut().find(|b| b.handle == definition) { + record.entity_handles.retain(|h| { + baseline_members.contains(h) + || *h == record.block_entity_handle + || *h == record.block_end_handle + }); + } let all = actions(baseline, definition); let mut affected = std::collections::BTreeSet::new(); for (h, a, kind, _) in &all { @@ -897,7 +1000,7 @@ fn evaluate_impl( entity.common_mut().invisible = e.common().invisible; } for (action_handle, a, kind, id) in &all { - if !a.dependencies.contains(&h) { + if !a.dependencies.contains(&h) || *kind == Kind::Array { continue; } let q = parameters(baseline, definition, *kind) @@ -942,6 +1045,7 @@ fn evaluate_impl( factor, } } + Kind::Array => unreachable!(), }; // Imported actions with custom multipliers/offsets/chaining must // not silently behave like the simple actions authored here. @@ -1042,10 +1146,123 @@ fn evaluate_impl( .get_entity_mut(h) .ok_or("An action target was removed.")? = entity; } + for (action_handle, a, kind, id) in &all { + if *kind != Kind::Array { + continue; + } + let ObjectType::DynamicBlock(o) = &baseline.objects[action_handle] else { + unreachable!() + }; + let DynamicBlockData::ArrayAction(arr) = &o.data else { + unreachable!() + }; + let q = parameters(baseline, definition, Kind::Array) + .into_iter() + .find(|p| p.id == *id) + .ok_or("An action has an unsupported parameter binding.")?; + if !supported_parameter(baseline, q.handle) { + return Err("This parameter uses value constraints or chaining not supported yet."); + } + if arr.column_offset <= 1e-6 + || !arr.column_offset.is_finite() + || (q.xy && (arr.row_offset <= 1e-6 || !arr.row_offset.is_finite())) + || arr.connections + != ["Base", "End", "UpdatedBase", "UpdatedEnd"].map(|name| BlockConnection { + code: q.id, + name: name.into(), + }) + { + return Err("This Array action uses options not supported yet."); + } + if !a.action_ids.is_empty() { + return Err("Chained actions are not supported yet."); + } + let end = current(&staged, &q); + let displacements: Vec<(usize, DVec3)> = if q.xy { + let dx = (end.x - q.base.x).abs(); + let dy = (end.y - q.base.y).abs(); + let nx = ((dx + 1e-9) / arr.column_offset).floor() as usize + 1; + let ny = ((dy + 1e-9) / arr.row_offset).floor() as usize + 1; + let dir_x = if end.x >= q.base.x { 1.0 } else { -1.0 }; + let dir_y = if end.y >= q.base.y { 1.0 } else { -1.0 }; + let mut list = vec![]; + let mut idx = 1; + for row in 0..ny { + for col in 0..nx { + if col == 0 && row == 0 { + continue; + } + let disp = DVec3::new( + (col as f64) * arr.column_offset * dir_x, + (row as f64) * arr.row_offset * dir_y, + 0.0, + ); + list.push((idx, disp)); + idx += 1; + } + } + list + } else if q.linear { + let axis = (q.end - q.base).normalize(); + let length = (end - q.base).dot(axis); + let n = ((length.max(0.0) + 1e-9) / arr.column_offset).floor() as usize + 1; + (1..n) + .map(|k| (k, (k as f64) * arr.column_offset * axis)) + .collect() + } else { + let delta = end - q.base; + let dist = delta.length(); + let axis = if dist > 1e-9 { + delta / dist + } else { + (q.end - q.base).normalize() + }; + let n = ((dist + 1e-9) / arr.column_offset).floor() as usize + 1; + (1..n) + .map(|k| (k, (k as f64) * arr.column_offset * axis)) + .collect() + }; + for (k, disp) in displacements { + for target_h in &a.dependencies { + let mut clone = staged + .get_entity(*target_h) + .cloned() + .ok_or("An action target is unavailable.")?; + super::view::dispatch::apply_transform( + &mut clone, + &crate::command::EntityTransform::Translate(disp), + ); + let bounds = clone.as_entity().bounding_box(); + if !v(bounds.min).is_finite() || !v(bounds.max).is_finite() { + return Err("The parameter value produces non-finite geometry."); + } + let new_h = staged.allocate_handle(); + clone.common_mut().handle = new_h; + clone.common_mut().owner_handle = definition; + clone.common_mut().raw_record = None; + clone.common_mut().xdictionary_handle = None; + clone.common_mut().reactors.clear(); + crate::scene::Scene::reset_clone_subhandles(&mut staged, &mut clone); + let base_id = authoring::element_id(&clone); + staged + .add_entity(clone) + .map_err(|_| "Could not add array item.")?; + if let Some(base_id) = base_id { + let clone_id = base_id.wrapping_add((k as i32) * 100_000); + authoring::set_entity_tag( + &mut staged, + new_h, + authoring::element_tag(clone_id, new_h), + ); + } + } + } + } *doc = staged; Ok(true) } + fn supported_parameter(doc: &CadDocument, handle: Handle) -> bool { let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&handle) else { return false; diff --git a/src/scene/block_actions/representation.rs b/src/scene/block_actions/representation.rs index ea50cd1e1..22823763c 100644 --- a/src/scene/block_actions/representation.rs +++ b/src/scene/block_actions/representation.rs @@ -16,7 +16,7 @@ pub(super) fn reconcile( .get(name) .ok_or("Missing representation.")? .handle; - let sources = authoring::members(&scene.document, def); + let sources = authoring::members(evaluated, def); let old = scene .document .entities() @@ -41,8 +41,7 @@ pub(super) fn reconcile( let mut mapping = FxHashMap::default(); let mut pairs = vec![]; for &from in &sources { - let source = scene - .document + let source = evaluated .get_entity(from) .ok_or("Missing definition member.")?; let id = diff --git a/src/scene/block_actions/tests.rs b/src/scene/block_actions/tests.rs index 6812fe682..83b51ae81 100644 --- a/src/scene/block_actions/tests.rs +++ b/src/scene/block_actions/tests.rs @@ -1361,3 +1361,387 @@ fn block_actions_topology_nested_insert_attribute_handles_remain_independent() { assert_eq!(copied.attributes[0].common.owner_handle, member); assert_ne!(copied.seqend_handle, original.seqend_handle); } + +#[test] +fn block_actions_array_native_creation_and_dwg_dxf_roundtrip() { + for kind in [distance::Kind::Linear, distance::Kind::Polar, distance::Kind::XY] { + let (mut doc, def, targets) = fixture(); + let end_pt = if kind == distance::Kind::XY { + Vector3::new(10., 10., 0.) + } else { + Vector3::new(10., 0., 0.) + }; + let p = distance::create( + &mut doc, + def, + kind, + Vector3::ZERO, + end_pt, + Vector3::new(5., 2., 0.), + ) + .unwrap(); + let column_offset = 2.5; + let row_offset = if kind == distance::Kind::XY { 3.5 } else { 1.0 }; + let action = create_array( + &mut doc, + def, + p, + &[targets[0]], + DVec3::new(5., 5., 0.), + column_offset, + row_offset, + ) + .unwrap(); + + let ObjectType::DynamicBlock(o) = doc.objects.get(&action).unwrap() else { + panic!() + }; + let DynamicBlockData::ArrayAction(arr) = &o.data else { + panic!() + }; + assert_eq!(arr.column_offset, column_offset); + assert_eq!(arr.row_offset, row_offset); + assert_eq!(arr.action.dependencies, vec![targets[0]]); + assert_eq!(arr.connections.len(), 4); + let param_id = parameters(&doc, def, Kind::Array)[0].id; + assert_eq!( + arr.connections, + [ + BlockConnection { + code: param_id, + name: "Base".into(), + }, + BlockConnection { + code: param_id, + name: "End".into(), + }, + BlockConnection { + code: param_id, + name: "UpdatedBase".into(), + }, + BlockConnection { + code: param_id, + name: "UpdatedEnd".into(), + }, + ] + ); + + // DWG roundtrip + let dwg_bytes = DwgWriter::write_to_vec(&doc).unwrap(); + let dwg_doc = DwgReader::from_stream(Cursor::new(dwg_bytes)).read().unwrap(); + let ObjectType::DynamicBlock(dwg_o) = dwg_doc.objects.get(&action).unwrap() else { + panic!() + }; + let DynamicBlockData::ArrayAction(dwg_arr) = &dwg_o.data else { + panic!() + }; + assert_eq!(dwg_arr.column_offset, column_offset); + assert_eq!(dwg_arr.row_offset, row_offset); + assert_eq!(dwg_arr.action.dependencies, vec![targets[0]]); + assert_eq!(dwg_arr.connections, arr.connections); + + // DXF roundtrip + let dxf_bytes = DxfWriter::new(&doc).write_to_vec().unwrap(); + let dxf_doc = DxfReader::from_reader(Cursor::new(dxf_bytes)).unwrap().read().unwrap(); + let ObjectType::DynamicBlock(dxf_o) = dxf_doc.objects.get(&action).unwrap() else { + panic!() + }; + let DynamicBlockData::ArrayAction(dxf_arr) = &dxf_o.data else { + panic!() + }; + assert_eq!(dxf_arr.column_offset, column_offset); + assert_eq!(dxf_arr.row_offset, row_offset); + assert_eq!(dxf_arr.action.dependencies, vec![targets[0]]); + assert_eq!(dxf_arr.connections, arr.connections); + } +} + +#[test] +fn block_actions_array_linear_and_polar_dynamic_evaluation() { + let (mut doc, def, targets) = fixture(); + let p = distance::create( + &mut doc, + def, + distance::Kind::Linear, + Vector3::ZERO, + Vector3::new(10., 0., 0.), + Vector3::new(5., 2., 0.), + ) + .unwrap(); + create_array( + &mut doc, + def, + p, + &[targets[0]], + DVec3::ZERO, + 2.0, + 1.0, + ) + .unwrap(); + + let baseline = doc.clone(); + + // At distance 1.0 (< column_offset 2.0), only base item exists + evaluate(&mut doc, &baseline, def, p, DVec3::new(1.0, 0.0, 0.0)).unwrap(); + let members = authoring::members(&doc, def); + assert_eq!(members.len(), 2); // original line + circle + + // At distance 2.0 (>= 2.0), 1 clone is added -> 3 members total + evaluate(&mut doc, &baseline, def, p, DVec3::new(2.0, 0.0, 0.0)).unwrap(); + let members = authoring::members(&doc, def); + assert_eq!(members.len(), 3); + let lines: Vec<_> = members + .iter() + .filter_map(|&h| match doc.get_entity(h) { + Some(EntityType::Line(l)) => Some((h, l.clone())), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 2); + // Original line was from (1,0,0) to (2,0,0). Clone should be translated by (2,0,0): from (3,0,0) to (4,0,0) + assert!(lines.iter().any(|(_, l)| (l.start - Vector3::new(3., 0., 0.)).length() < 1e-8)); + + // At distance 6.5, items count = floor(6.5/2.0)+1 = 4 -> 3 clones + 1 base = 4 lines + 1 circle = 5 members + evaluate(&mut doc, &baseline, def, p, DVec3::new(6.5, 0.0, 0.0)).unwrap(); + let members = authoring::members(&doc, def); + assert_eq!(members.len(), 5); + + // Shrink back to 0.5: clones are removed, reverting to original 2 members + evaluate(&mut doc, &baseline, def, p, DVec3::new(0.5, 0.0, 0.0)).unwrap(); + let members = authoring::members(&doc, def); + assert_eq!(members.len(), 2); + + // Polar parameter at angle 45 deg + let (mut doc, def, targets) = fixture(); + let p_polar = distance::create( + &mut doc, + def, + distance::Kind::Polar, + Vector3::ZERO, + Vector3::new(10., 0., 0.), + Vector3::new(5., 2., 0.), + ) + .unwrap(); + create_array( + &mut doc, + def, + p_polar, + &[targets[0]], + DVec3::ZERO, + 2.0, + 1.0, + ) + .unwrap(); + let baseline = doc.clone(); + // Grip moved to (3.0, 4.0, 0) -> distance 5.0, axis (0.6, 0.8, 0) + // n = floor(5.0/2.0) + 1 = 3 items (1 base + 2 clones) + evaluate(&mut doc, &baseline, def, p_polar, DVec3::new(3.0, 4.0, 0.0)).unwrap(); + let members = authoring::members(&doc, def); + assert_eq!(members.len(), 4); // 3 lines + 1 circle + let lines: Vec<_> = members + .iter() + .filter_map(|&h| match doc.get_entity(h) { + Some(EntityType::Line(l)) => Some((h, l.clone())), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 3); + assert!(lines.iter().any(|(_, l)| (l.start - Vector3::new(1.0 + 1.2, 1.6, 0.)).length() < 1e-8)); + assert!(lines.iter().any(|(_, l)| (l.start - Vector3::new(1.0 + 2.4, 3.2, 0.)).length() < 1e-8)); +} + +#[test] +fn block_actions_array_xy_grid_evaluation() { + let (mut doc, def, targets) = fixture(); + let p_xy = distance::create( + &mut doc, + def, + distance::Kind::XY, + Vector3::ZERO, + Vector3::new(10., 10., 0.), + Vector3::new(5., 5., 0.), + ) + .unwrap(); + create_array( + &mut doc, + def, + p_xy, + &[targets[0]], + DVec3::ZERO, + 3.0, + 4.0, + ) + .unwrap(); + let baseline = doc.clone(); + + // Move XY grip to (6.0, 8.0, 0.0) + // nx = floor(6/3)+1 = 3 (cols 0, 3, 6) + // ny = floor(8/4)+1 = 3 (rows 0, 4, 8) + // total items: 3 * 3 = 9 (1 base + 8 clones) + evaluate(&mut doc, &baseline, def, p_xy, DVec3::new(6.0, 8.0, 0.0)).unwrap(); + let members = authoring::members(&doc, def); + assert_eq!(members.len(), 10); // 9 lines + 1 circle + + let lines: Vec<_> = members + .iter() + .filter_map(|&h| match doc.get_entity(h) { + Some(EntityType::Line(l)) => Some(l.clone()), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 9); + + // Verify all 9 grid positions exist: (col*3, row*4) + base (1, 0, 0) + for col in 0..3 { + for row in 0..3 { + let expected_start = Vector3::new(1.0 + (col as f64) * 3.0, (row as f64) * 4.0, 0.0); + assert!( + lines.iter().any(|l| (l.start - expected_start).length() < 1e-8), + "Missing line at col {col}, row {row}" + ); + } + } +} + +#[test] +fn block_actions_array_saved_references_independent_copy_and_roundtrip() { + let (mut doc, def, targets) = fixture(); + let p = distance::create( + &mut doc, + def, + distance::Kind::Linear, + Vector3::ZERO, + Vector3::new(10., 0., 0.), + Vector3::new(5., 2., 0.), + ) + .unwrap(); + create_array( + &mut doc, + def, + p, + &[targets[0]], + DVec3::ZERO, + 2.0, + 1.0, + ) + .unwrap(); + + let a = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "ACTIONS", + Vector3::ZERO, + ))) + .unwrap(); + let b = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "ACTIONS", + Vector3::new(100., 0., 0.), + ))) + .unwrap(); + + // Evaluate A at distance 6.0 (3 clones + 1 base = 4 lines) + let doc = instance::evaluate(&doc, a, p, DVec3::new(6.0, 0.0, 0.0)) + .unwrap() + .unwrap(); + // Evaluate B at distance 2.0 (1 clone + 1 base = 2 lines) + let mut doc = instance::evaluate(&doc, b, p, DVec3::new(2.0, 0.0, 0.0)) + .unwrap() + .unwrap(); + + let EntityType::Insert(ref_a) = doc.get_entity(a).unwrap() else { + panic!() + }; + let EntityType::Insert(ref_b) = doc.get_entity(b).unwrap() else { + panic!() + }; + assert_ne!(ref_a.block_name, ref_b.block_name); + let block_a = doc.block_records.get(&ref_a.block_name).unwrap(); + let block_b = doc.block_records.get(&ref_b.block_name).unwrap(); + let a_lines = block_a + .entity_handles + .iter() + .filter(|&&h| matches!(doc.get_entity(h), Some(EntityType::Line(_)))) + .count(); + let b_lines = block_b + .entity_handles + .iter() + .filter(|&&h| matches!(doc.get_entity(h), Some(EntityType::Line(_)))) + .count(); + assert_eq!(a_lines, 4); + assert_eq!(b_lines, 2); + + // Copy reference A -> C + let mut insert_c = ref_a.clone(); + let c = doc.allocate_handle(); + insert_c.common.handle = c; + insert_c.insert_point = Vector3::new(200., 0., 0.); + doc.add_entity(EntityType::Insert(insert_c)).unwrap(); + + // Roundtrip to DWG + let reopened = DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(); + let EntityType::Insert(reopened_a) = reopened.get_entity(a).unwrap() else { + panic!() + }; + let EntityType::Insert(reopened_b) = reopened.get_entity(b).unwrap() else { + panic!() + }; + let EntityType::Insert(reopened_c) = reopened.get_entity(c).unwrap() else { + panic!() + }; + let rec_a = reopened.block_records.get(&reopened_a.block_name).unwrap(); + let rec_b = reopened.block_records.get(&reopened_b.block_name).unwrap(); + let rec_c = reopened.block_records.get(&reopened_c.block_name).unwrap(); + assert_eq!( + rec_a + .entity_handles + .iter() + .filter(|&&h| matches!(reopened.get_entity(h), Some(EntityType::Line(_)))) + .count(), + 4 + ); + assert_eq!( + rec_b + .entity_handles + .iter() + .filter(|&&h| matches!(reopened.get_entity(h), Some(EntityType::Line(_)))) + .count(), + 2 + ); + assert_eq!( + rec_c + .entity_handles + .iter() + .filter(|&&h| matches!(reopened.get_entity(h), Some(EntityType::Line(_)))) + .count(), + 4 + ); +} + +#[test] +fn block_actions_array_rejections() { + let (mut doc, def, targets) = fixture(); + let p_point = point::create(&mut doc, def, Vector3::ZERO, Vector3::new(5., 5., 0.)).unwrap(); + assert!(create_array(&mut doc, def, p_point, &targets, DVec3::ZERO, 2.0, 1.0).is_err()); + + let p_rot = orientation( + &mut doc, + def, + super::super::block_orientation_parameter::Kind::Rotation, + ); + assert!(create_array(&mut doc, def, p_rot, &targets, DVec3::ZERO, 2.0, 1.0).is_err()); + + let p_lin = distance::create( + &mut doc, + def, + distance::Kind::Linear, + Vector3::ZERO, + Vector3::new(10., 0., 0.), + Vector3::new(5., 2., 0.), + ) + .unwrap(); + assert!(create_array(&mut doc, def, p_lin, &targets, DVec3::ZERO, 0.0, 1.0).is_err()); + assert!(create_array(&mut doc, def, p_lin, &targets, DVec3::ZERO, -2.0, 1.0).is_err()); +} + diff --git a/src/scene/block_authoring/edit.rs b/src/scene/block_authoring/edit.rs index 36d5548c4..490640db3 100644 --- a/src/scene/block_authoring/edit.rs +++ b/src/scene/block_authoring/edit.rs @@ -287,7 +287,12 @@ pub fn move_part( p.definition_label_point = at } } - DynamicBlockData::MoveAction(p) => p.action.display_location = at, + DynamicBlockData::MoveAction(p) => { + p.action.display_location = at + } + DynamicBlockData::ArrayAction(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) => { diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs index f59268cbe..4cefc576f 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -199,6 +199,7 @@ fn catalog( rotate_error: Option<&'static str>, flip_action_error: Option<&'static str>, stretch_action_error: Option<&'static str>, + array_action_error: Option<&'static str>, ) -> Element<'static, Message> { let (entries, unavailable) = match tab { Tab::Parameters => ( @@ -220,8 +221,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"|"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 geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"|"Stretch"|"Rotate"|"Flip"|"Array"); + let action_error=match *label {"Scale"=>scale_error,"Stretch"=>stretch_action_error,"Rotate"=>rotate_error,"Flip"=>flip_action_error,"Array"=>array_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) @@ -267,7 +268,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(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() + 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 if *label=="Array" {"Select a parameter endpoint, select the objects to array, specify the column offset, 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 { @@ -285,7 +286,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","Stretch"=>"authoring-tool-stretch-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","Array"=>"authoring-tool-array-action",_=>"authoring-tool-flip-action"}).into() } else if lookup_action { container(entry).id("authoring-tool-lookup-table").into() } else { @@ -407,6 +408,7 @@ pub fn view( rotate_error: Option<&'static str>, flip_action_error: Option<&'static str>, stretch_action_error: Option<&'static str>, + array_action_error: Option<&'static str>, ) -> Element<'static, Message> { let tabs = column(Tab::ALL.into_iter().map(|category| { let label = category.label(); @@ -466,7 +468,8 @@ pub fn view( scale_error, rotate_error, flip_action_error, - stretch_action_error + stretch_action_error, + array_action_error, )) .width(Fill) .height(Fill),