From 26071f20216dddfd929a905c03a8bc2b1cb7c43d Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sat, 17 Oct 2026 12:49:51 +0100 Subject: [PATCH] feat(blocks): implement native Array action authoring and dynamic evaluation Add the native Array action (\BARRAY\, \BACTION ARRAY\, and Authoring Palettes Actions tab \Array\) supporting Linear, Polar, and XY parameters. Prompt for column offset (and row offset for XY), constructing native \DynamicBlockData::ArrayAction(BlockArrayAction)\ with class \AcDbBlockArrayAction\ and 4 native connections (\Base\, \End\, \UpdatedBase\, \UpdatedEnd\) bound to the parameter node ID. Evaluate step displacement along 1D rays and 2D grids, tagging clones with deterministic element IDs to reconcile representations cleanly across incremental grip adjustments without mutating baseline geometry. Validate native DWG/DXF roundtrip, reference independence, COPY isolation, rejections, and dock palette tool state. --- src/app/block_actions.rs | 6 +- src/app/block_editor_tests.rs | 1 + src/app/commands/bvisibility.rs | 10 +- src/app/commands/mod.rs | 10 +- src/app/dock/tests.rs | 25 ++ src/app/dock/view.rs | 1 + src/app/update/command.rs | 2 +- src/command.rs | 1 + src/modules/block_editor/action.rs | 129 +++++++- src/scene/block_actions.rs | 269 +++++++++++++-- src/scene/block_actions/representation.rs | 5 +- src/scene/block_actions/tests.rs | 384 ++++++++++++++++++++++ src/scene/block_authoring/edit.rs | 7 +- src/ui/window/authoring_palettes.rs | 13 +- 14 files changed, 806 insertions(+), 57 deletions(-) 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),