From a1de7e4ecd14de5be771fa7cf7df9110f0227b90 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 18 Oct 2026 08:11:41 +0100 Subject: [PATCH] feat(blocks): author Block Properties Table from the block editor Add the BPROPERTIESTABLE command, the Block Authoring palette row and the grid dialog. The row needs an open block edit session with a Linear or Polar parameter column; OK parses cells to finite numbers, re-checks the table against the session it was opened from, writes it in one undo step, and no-op edits close without an undo. --- src/app/block_properties_table.rs | 127 +++++++ src/app/commands/bvisibility.rs | 3 + src/app/commands/mod.rs | 6 + src/app/dock/tests.rs | 77 +++++ src/app/dock/view.rs | 1 + src/app/mod.rs | 5 + src/app/update/mod.rs | 5 + src/app/view/modal.rs | 7 + src/scene/block_properties_table.rs | 432 ++++++++++++++++++++++++ src/scene/mod.rs | 1 + src/ui/window/authoring_palettes.rs | 18 +- src/ui/window/block_properties_table.rs | 186 ++++++++++ src/ui/window/mod.rs | 1 + 13 files changed, 865 insertions(+), 4 deletions(-) create mode 100644 src/app/block_properties_table.rs create mode 100644 src/scene/block_properties_table.rs create mode 100644 src/ui/window/block_properties_table.rs diff --git a/src/app/block_properties_table.rs b/src/app/block_properties_table.rs new file mode 100644 index 000000000..c9791bf71 --- /dev/null +++ b/src/app/block_properties_table.rs @@ -0,0 +1,127 @@ +use super::{Message, ModalKind, OpenCADStudio}; +use crate::scene::block_properties_table as table; +use crate::ui::window::block_properties_table::{Action, State}; +use iced::Task; + +impl OpenCADStudio { + pub(in crate::app) fn properties_table_error(&self) -> Option<&'static str> { + let tab = &self.tabs[self.active_tab]; + let Some(session) = tab.active_block_edit_session() else { + return Some("Open a block in the Block Editor before editing its Block Properties Table."); + }; + let definition = session.br_handle; + if table::columns(&tab.scene.document, definition).is_empty() { + return Some( + "Place a Linear or Polar parameter before editing the Block Properties Table.", + ); + } + table::table(&tab.scene.document, definition).err() + } + pub(in crate::app) fn open_block_properties_table(&mut self) -> Task { + if let Some(error) = self.properties_table_error() { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + let tab = &self.tabs[self.active_tab]; + let definition = tab.active_block_edit_session().unwrap().br_handle; + let original = match table::table(&tab.scene.document, definition) { + Ok(value) => value, + Err(error) => { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + }; + let (_, columns, rows) = match table::grid_table(&tab.scene.document, definition) { + Ok(Some(value)) => value, + Ok(None) => (codec::Handle::NULL, vec![], vec![]), + Err(error) => { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + }; + self.block_properties_table = Some(State { + document: tab.id, + definition, + epoch: tab.scene.geometry_epoch, + original, + columns, + properties: table::columns(&tab.scene.document, definition), + selected_property: None, + rows, + error: None, + notice: None, + }); + self.active_modal = Some(ModalKind::BlockPropertiesTable); + Task::none() + } + pub(super) fn handle_block_properties_table(&mut self, action: Action) -> Task { + if !matches!(action, Action::Apply) { + if let Some(state) = self.block_properties_table.as_mut() { + state.edit(action); + } + return Task::none(); + } + let Some(state) = self.block_properties_table.as_ref() else { + return Task::none(); + }; + let mut cells = Vec::with_capacity(state.rows.len()); + for row in &state.rows { + let mut parsed = Vec::with_capacity(row.len()); + for value in row { + match value.trim().parse::() { + Ok(value) if value.is_finite() => parsed.push(value), + _ => { + self.block_properties_table.as_mut().unwrap().error = Some( + crate::t!("Each cell must be a finite number.").into_owned(), + ); + return Task::none(); + } + } + } + cells.push(parsed); + } + let i = self.active_tab; + let tab = &self.tabs[i]; + let stale = tab.id != state.document + || tab.active_block_edit_session().map(|s| s.br_handle) != Some(state.definition) + || tab.scene.geometry_epoch != state.epoch + || table::table(&tab.scene.document, state.definition) + .ok() + .flatten() + .as_ref() + != state.original.as_ref(); + if stale { + self.block_properties_table.as_mut().unwrap().error = Some( + crate::t!("The block changed. Cancel and reopen the Block Properties Table.") + .into_owned(), + ); + return Task::none(); + } + let unchanged = state.original.as_ref().is_some_and(|view| { + view.columns == state.columns + && view.rows.iter().eq(cells.iter()) + }); + if unchanged { + self.block_properties_table = None; + self.active_modal = None; + return Task::none(); + } + let mut working = tab.scene.document.clone(); + if let Err(error) = + table::apply(&mut working, state.definition, &state.columns, &cells) + { + self.block_properties_table.as_mut().unwrap().error = + Some(crate::t!(error).into_owned()); + return Task::none(); + } + self.push_undo_snapshot(i, "Block Properties Table"); + self.tabs[i].scene.document = working; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.finish_pending_history(i); + self.refresh_properties(); + self.block_properties_table = None; + self.active_modal = None; + Task::none() + } +} diff --git a/src/app/commands/bvisibility.rs b/src/app/commands/bvisibility.rs index 062aaaaa6..d1801dc60 100644 --- a/src/app/commands/bvisibility.rs +++ b/src/app/commands/bvisibility.rs @@ -412,6 +412,9 @@ impl OpenCADStudio { cmd if cmd == "BLOOKUPTABLE" || cmd.starts_with("BLOOKUPTABLE ") => { Some(self.open_lookup_table(cmd.strip_prefix("BLOOKUPTABLE").unwrap().trim())) } + cmd if cmd == "BPROPERTIESTABLE" || cmd.starts_with("BPROPERTIESTABLE ") => { + Some(self.open_block_properties_table()) + } cmd if cmd == "BPARAMETER" || cmd.starts_with("BPARAMETER ") => { let Some(block) = self.visibility_block(i, "BPARAMETER") else { return Some(Task::none()); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 1419e02f9..8bba52fb1 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -218,6 +218,12 @@ impl OpenCADStudio { return Task::none(); } } + if cmd == "BPROPERTIESTABLE" || cmd.starts_with("BPROPERTIESTABLE ") { + if let Some(error) = self.properties_table_error() { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + } if self.tabs[i].block_test.is_some() { let name = cmd.split([' ', ':']).next().unwrap_or(cmd); if matches!( diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index d7de6de73..2459edc3b 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -1456,6 +1456,83 @@ fn lookup_basepoint_palette_and_table_dialog_real_controls() { }); } +#[test] +fn block_properties_table_palette_dialog_authors_rows() { + with_stack(|| { + use crate::ui::window::{authoring_palettes::Tab, block_properties_table::Action as A}; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 1,0 0,0.3"); + let point = authoring_control(&app, Tab::Actions.widget_id()); + authoring_click(&mut app, point); + let point = authoring_control(&app, "authoring-tool-properties-table"); + authoring_click(&mut app, point); + assert_eq!( + app.active_modal, + Some(crate::app::ModalKind::BlockPropertiesTable) + ); + let _ = app.update(Message::BlockPropertiesTable(A::Property(0))); + let point = find_in_view(&app, "Add Property"); + authoring_click(&mut app, point); + let point = find_in_view(&app, "Add Row"); + authoring_click(&mut app, point); + assert_eq!( + app.block_properties_table.as_ref().unwrap().rows.len(), + 1 + ); + let _ = app.update(Message::BlockPropertiesTable(A::Cell(0, 0, "2.5".into()))); + let point = find_in_view(&app, "OK"); + authoring_click(&mut app, point); + assert!(app.block_properties_table.is_none()); + assert!(app.active_modal.is_none()); + let i = app.active_tab; + let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let view = + crate::scene::block_properties_table::table(&app.tabs[i].scene.document, definition) + .unwrap() + .expect("the table was written"); + assert_eq!(view.columns.len(), 1); + assert_eq!(view.rows, vec![vec![2.5]]); + }); +} + +#[test] +fn block_properties_table_apply_writes_undo_once_and_stale_edits_stay_open() { + with_stack(|| { + use crate::ui::window::block_properties_table::Action as A; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 1,0 0,0.3"); + let i = app.active_tab; + let history = app.tabs[i].history.undo_stack.len(); + let _ = app.dispatch_command("BPROPERTIESTABLE"); + assert_eq!( + app.active_modal, + Some(crate::app::ModalKind::BlockPropertiesTable) + ); + let _ = app.update(Message::BlockPropertiesTable(A::Property(0))); + let _ = app.update(Message::BlockPropertiesTable(A::AddProperty)); + let _ = app.update(Message::BlockPropertiesTable(A::AddRow)); + let _ = app.update(Message::BlockPropertiesTable(A::Apply)); + assert!(app.block_properties_table.is_none()); + assert_eq!(app.tabs[i].history.undo_stack.len(), history + 1); + let _ = app.dispatch_command("BPROPERTIESTABLE"); + assert!(app.block_properties_table.is_some()); + let _ = app.update(Message::BlockPropertiesTable(A::Apply)); + assert!(app.block_properties_table.is_none()); + assert_eq!(app.tabs[i].history.undo_stack.len(), history + 1); + let _ = app.dispatch_command("BPROPERTIESTABLE"); + app.tabs[i].scene.bump_geometry(); + let _ = app.update(Message::BlockPropertiesTable(A::AddRow)); + let _ = app.update(Message::BlockPropertiesTable(A::Apply)); + assert!(app.block_properties_table.is_some()); + assert!(app.block_properties_table.as_ref().unwrap().error.is_some()); + let _ = app.update(Message::CloseModal); + assert!(app.block_properties_table.is_none()); + assert!(app.active_modal.is_none()); + }); +} + #[test] fn authoring_palettes_ribbon_toggle_and_dock_close_leave_document_and_history_unchanged() { use crate::ui::dock::{DockMsg, PanelId}; diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index c0f722de0..25d9ceec1 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -411,6 +411,7 @@ impl OpenCADStudio { self.point_parameter_placement_error(), self.parameter_placement_error("FLIP"), self.lookup_table_error(), + self.properties_table_error(), self.action_error(Some(crate::scene::block_actions::Kind::Move)), self.action_error(Some(crate::scene::block_actions::Kind::Scale)), self.action_error(Some(crate::scene::block_actions::Kind::Rotate)), diff --git a/src/app/mod.rs b/src/app/mod.rs index 352b23c70..d809b21c9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,6 +14,7 @@ mod block_editor_tests; mod block_save_as; mod block_visibility; mod block_lookup; +mod block_properties_table; mod block_actions; mod block_authoring_edit; mod block_properties; @@ -597,6 +598,7 @@ pub(super) struct OpenCADStudio { block_save_as: Option, block_visibility: Option, block_lookup: Option, + block_properties_table: Option, block_visibility_mode: bool, /// PDF dialogs' working copies; `None` while closed. pdf_attach: Option, @@ -2300,6 +2302,7 @@ pub enum ModalKind { BlockSaveAs, BlockVisibility, BlockLookup, + BlockPropertiesTable, SaveDialog, Recovery, /// Fonts referenced by the drawing are missing on this machine. @@ -2955,6 +2958,7 @@ pub enum Message { BlockSaveAsApply, BlockVisibility(crate::ui::window::block_visibility::Action), BlockLookup(crate::ui::window::block_lookup::Action), + BlockPropertiesTable(crate::ui::window::block_properties_table::Action), BlockEditPickerName(String), BlockEditPickerSelect(String), BlockEditPickerApply, @@ -4739,6 +4743,7 @@ impl OpenCADStudio { block_save_as: None, block_visibility: None, block_lookup: None, + block_properties_table: None, block_visibility_mode: false, pdf_attach: None, point_cloud_attach: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a5b3b618a..1ade9ce98 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -238,6 +238,7 @@ impl OpenCADStudio { Some(BlockSaveAs) => self.block_save_as = None, Some(BlockVisibility) => self.block_visibility = None, Some(BlockLookup) => self.block_lookup = None, + Some(BlockPropertiesTable) => self.block_properties_table = None, Some(PdfAttach) => self.pdf_attach = None, Some(UnderlayLayers) => self.underlay_layers = None, Some(PdfImportSettings) => self.pdf_import_settings = None, @@ -9739,6 +9740,9 @@ impl OpenCADStudio { if self.active_modal == Some(super::ModalKind::BlockLookup) { self.block_lookup = None; } + if self.active_modal == Some(super::ModalKind::BlockPropertiesTable) { + self.block_properties_table = None; + } if self.active_modal == Some(super::ModalKind::BlockVisibility) { self.block_visibility = None; } @@ -10764,6 +10768,7 @@ impl OpenCADStudio { Message::BlockSaveAsApply => self.apply_block_save_as_dialog(), Message::BlockVisibility(action) => self.handle_block_visibility(action), Message::BlockLookup(action) => self.handle_block_lookup(action), + Message::BlockPropertiesTable(action) => self.handle_block_properties_table(action), Message::BlockEditPickerName(name) => { if let Some(state) = self.block_edit_picker.as_mut() { state.name = name.clone(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 6ecb0c10e..237be84ed 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -90,6 +90,7 @@ impl OpenCADStudio { Some(K::BlockEditPicker) => crate::t!("Edit Block Definition").into_owned(), Some(K::BlockVisibility) => crate::t!("Visibility States").into_owned(), Some(K::BlockLookup) => crate::t!("Property Lookup Table").into_owned(), + Some(K::BlockPropertiesTable) => crate::t!("Block Properties Table").into_owned(), Some(K::BlockSaveAs) => crate::t!("Save Block As").into_owned(), Some(K::PointStyle) => crate::tr!("modal", "point-style"), Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"), @@ -530,6 +531,12 @@ impl OpenCADStudio { crate::ui::window::block_lookup::view(state, flow) }) } + super::super::ModalKind::BlockPropertiesTable => { + let state = self.block_properties_table.as_ref()?; + sized_flow(ex, 940, 600, |flow| { + crate::ui::window::block_properties_table::view(state, flow) + }) + } super::super::ModalKind::BlockVisibility => { let state = self.block_visibility.as_ref()?; sized_flow(ex, 580, 470, |flow| { diff --git a/src/scene/block_properties_table.rs b/src/scene/block_properties_table.rs new file mode 100644 index 000000000..a7e4e5603 --- /dev/null +++ b/src/scene/block_properties_table.rs @@ -0,0 +1,432 @@ +//! Native Block Properties Table authoring. +use super::block_authoring::{self as authoring, *}; +use codec::objects::{ + BlockElement, BlockEvalValue, BlockOnePointParameter, BlockParameter, BlockPropertiesTable, + BlockPropertiesTableField, BlockPropertiesTableValue, DynamicBlockData, ObjectType, +}; +use codec::{CadDocument, Handle}; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Column { + pub handle: Handle, + pub node_id: i32, + pub connection_name: String, + pub type_code: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct TableView { + pub handle: Handle, + pub table: BlockPropertiesTable, + pub columns: Vec, + pub rows: Vec>, + pub row_start: usize, + pub trailer_start: usize, +} + +pub(crate) type Grid = (Handle, Vec, Vec>); + +fn graph_handle(document: &CadDocument, definition: Handle) -> Option { + let xdict = document.extension_dictionary_handle(definition)?; + let ObjectType::Dictionary(dictionary) = document.objects.get(&xdict)? else { + return None; + }; + dictionary.get("ACAD_ENHANCEDBLOCK") +} + +pub(crate) fn table( + document: &CadDocument, + definition: Handle, +) -> Result, &'static str> { + let graph = graph_handle(document, definition).ok_or("The authoring graph is unavailable.")?; + let mut found = document.objects.values().filter_map(|object| match object { + ObjectType::DynamicBlock(object) if object.owner == graph => match &object.data { + DynamicBlockData::PropertiesTable(table) => Some((object.handle, table.clone())), + _ => None, + }, + _ => None, + }); + let Some((handle, table)) = found.next() else { + return Ok(None); + }; + if found.next().is_some() { + return Err("A block can contain only one Block Properties Table."); + } + let mut view = parse(handle, table)?; + for column in &mut view.columns { + column.node_id = super::block_actions::all_parameters(document, definition) + .iter() + .find(|parameter| parameter.handle == column.handle) + .map(|parameter| parameter.id) + .unwrap_or(0); + } + Ok(Some(view)) +} + +fn integer(field: &BlockPropertiesTableField) -> Option { + match field.value { + BlockPropertiesTableValue::Integer(value) => Some(value), + _ => None, + } +} + +fn real(field: &BlockPropertiesTableField) -> Option { + match field.value { + BlockPropertiesTableValue::Real(value) => Some(value), + _ => None, + } +} + +fn handle(field: &BlockPropertiesTableField) -> Option { + match field.value { + BlockPropertiesTableValue::Handle(value) => Some(value), + _ => None, + } +} + +fn parse(table_handle: Handle, table: BlockPropertiesTable) -> Result { + let fields = &table.fields; + let column_count_index = fields + .iter() + .position(|field| field.code == 91) + .ok_or("The Block Properties Table has no property columns.")?; + let column_count = usize::try_from(integer(&fields[column_count_index]).ok_or( + "The Block Properties Table column count is invalid.", + )?) + .map_err(|_| "The Block Properties Table column count is invalid.")?; + if column_count == 0 || column_count > 64 { + return Err("The Block Properties Table column count is unsupported."); + } + let mut columns = Vec::with_capacity(column_count); + let mut index = column_count_index + 1; + for _ in 0..column_count { + let column_handle = fields + .get(index) + .filter(|field| field.code == 340) + .and_then(handle) + .ok_or("A Block Properties Table property binding is invalid.")?; + index += 1; + let mut name = None; + for field in fields.iter().skip(index) { + if field.code == 301 && name.is_none() { + if let BlockPropertiesTableValue::Text(value) = &field.value { + name = Some(value.clone()); + } + } else if field.code == 90 && name.is_some() { + break; + } + } + let name = name.ok_or("A Block Properties Table property name is missing.")?; + let end = fields + .iter() + .enumerate() + .skip(index) + .find(|(_, field)| field.code == 340) + .map(|(i, _)| i) + .ok_or("A Block Properties Table property record is incomplete.")?; + let property_id = fields + .iter() + .enumerate() + .skip(index) + .take(end - index) + .find(|(_, field)| field.code == 90) + .and_then(|(_, field)| integer(field)) + .ok_or("A Block Properties Table property type is missing.")?; + columns.push(Column { + handle: column_handle, + node_id: 0, + connection_name: name, + type_code: property_id, + }); + index = end + 1; + } + let row_count_index = fields + .iter() + .enumerate() + .skip(index) + .find(|(_, field)| field.code == 92) + .map(|(i, _)| i) + .ok_or("The Block Properties Table row data is unavailable.")?; + let row_count = usize::try_from(integer(&fields[row_count_index]).ok_or( + "The Block Properties Table row count is invalid.", + )?) + .map_err(|_| "The Block Properties Table row count is invalid.")?; + if row_count == 0 || row_count > 1000 { + return Err("The Block Properties Table row count is unsupported."); + } + let row_start = row_count_index + 1; + let mut rows = Vec::with_capacity(row_count); + index = row_start; + for expected_row in 0..row_count { + let row_index = fields + .get(index) + .filter(|field| field.code == 90) + .and_then(integer) + .ok_or("A Block Properties Table row index is invalid.")?; + if row_index != expected_row as i64 { + return Err("The Block Properties Table rows are not ordered."); + } + index += 1; + let mut row = Vec::with_capacity(column_count); + for _ in 0..column_count { + let value_type = fields + .get(index) + .filter(|field| field.code == 170) + .and_then(integer) + .ok_or("A Block Properties Table cell type is unsupported.")?; + if value_type != 40 { + return Err("Only numeric Block Properties Table cells are supported."); + } + let value = fields + .get(index + 1) + .filter(|field| field.code == 140) + .and_then(real) + .filter(|value| value.is_finite()) + .ok_or("A Block Properties Table cell value is invalid.")?; + row.push(value); + index += 2; + } + rows.push(row); + } + let trailer_start = fields + .iter() + .enumerate() + .skip(index) + .find(|(_, field)| field.code == 93) + .map(|(i, _)| i) + .ok_or("The Block Properties Table trailer is unavailable.")?; + Ok(TableView { + handle: table_handle, + table, + columns, + rows, + row_start, + trailer_start, + }) +} + +pub(crate) fn columns(document: &CadDocument, definition: Handle) -> Vec { + let parameters = super::block_actions::all_parameters(document, definition); + super::block_lookup::properties(document, definition) + .into_iter() + .filter_map(|property| { + let parameter = parameters.iter().find(|parameter| { + parameter.id == property.column.node_id + })?; + let type_code = if property + .column + .connection_name + .eq_ignore_ascii_case("UpdatedDistance") + { + 14 + } else if property + .column + .connection_name + .eq_ignore_ascii_case("UpdatedAngle") + { + 21 + } else { + return None; + }; + Some(Column { + handle: parameter.handle, + node_id: parameter.id, + connection_name: property.column.connection_name, + type_code, + }) + }) + .collect() +} + +pub(crate) fn grid_table( + document: &CadDocument, + definition: Handle, +) -> Result, &'static str> { + let Some(view) = table(document, definition)? else { + return Ok(None); + }; + let rows = view + .rows + .iter() + .map(|row| row.iter().map(ToString::to_string).collect()) + .collect(); + Ok(Some((view.handle, view.columns, rows))) +} + +pub(crate) fn apply( + document: &mut CadDocument, + definition: Handle, + columns: &[Column], + rows: &[Vec], +) -> Result { + if columns.is_empty() || columns.len() > 64 || rows.is_empty() || rows.len() > 1000 { + return Err("Add at least one property and one complete row."); + } + if rows.iter().any(|row| { + row.len() != columns.len() || row.iter().any(|value| !value.is_finite()) + }) { + return Err("Complete each numeric property cell with a finite value."); + } + let mut working = document.clone(); + let graph = graph_handle(&working, definition).ok_or("The authoring graph is unavailable.")?; + let previous = table(&working, definition)?; + let handle = if let Some(view) = &previous { + view.handle + } else { + let (graph, slots) = authoring::prepare(&mut working, definition).map_err(|_| { + "The block authoring graph cannot accept a Block Properties Table." + })?; + register_classes( + &mut working, + &[("BLOCKPROPERTIESTABLE", "AcDbBlockPropertiesTable", 1153)], + ); + let handle = working.allocate_handle(); + let element = BlockElement { + eval: expression(slots.eval, NO_VALUE, BlockEvalValue::None), + name: "Block Table".into(), + major: MAJOR, + minor: MINOR, + eed_1071: 0, + }; + let parameter = BlockOnePointParameter { + parameter: BlockParameter { + element, + show_properties: true, + chain_actions: false, + }, + property_count: slots.eval + 1, + ..Default::default() + }; + let table = BlockPropertiesTable { + parameter, + fields: vec![], + }; + working.objects.insert( + handle, + ObjectType::DynamicBlock(dynamic( + handle, + graph, + "BLOCKPROPERTIESTABLE", + "AcDbBlockPropertiesTable", + DynamicBlockData::PropertiesTable(table), + )), + ); + authoring::append_single(&mut working, graph, slots, handle); + handle + }; + let old_fields = previous.as_ref().map(|view| view.table.fields.as_slice()); + let new_fields = encode_fields(columns, rows, old_fields)?; + let Some(ObjectType::DynamicBlock(object)) = working.objects.get_mut(&handle) else { + return Err("The Block Properties Table object is unavailable."); + }; + let DynamicBlockData::PropertiesTable(table) = &mut object.data else { + return Err("The selected object is not a Block Properties Table."); + }; + table.fields = new_fields; + let _ = graph; + *document = working; + Ok(handle) +} + +fn encode_fields( + columns: &[Column], + rows: &[Vec], + old: Option<&[BlockPropertiesTableField]>, +) -> Result, &'static str> { + let mut fields = if let Some(fields) = old { + fields.to_vec() + } else { + vec![ + int(90, 2), + text(300, "Block Properties Table"), + text(301, ""), + ] + }; + if old.is_none() { + fields.push(int(91, columns.len() as i64)); + for column in columns { + fields.extend([ + handle_field(340, column.handle), + int(170, 0), + int(171, -1), + text(300, ""), + text(301, &column.connection_name), + int(90, column.type_code), + int(170, -9999), + int(170, -9999), + boolean(290, false), + boolean(291, true), + boolean(292, true), + boolean(293, column.type_code == 21), + boolean(294, false), + text(302, ""), + handle_field(340, Handle::NULL), + ]); + } + } else { + let view = parse(Handle::NULL, BlockPropertiesTable { + parameter: Default::default(), + fields: fields.clone(), + })?; + if view.columns.len() != columns.len() + || view.columns.iter().zip(columns).any(|(old, new)| { + old.handle != new.handle + || old.connection_name != new.connection_name + || old.type_code != new.type_code + }) + { + return Err("Changing Block Properties Table columns is not supported for an existing table."); + } + fields.truncate(view.row_start - 1); + } + fields.push(int(92, rows.len() as i64)); + for (row_index, row) in rows.iter().enumerate() { + fields.push(int(90, row_index as i64)); + for value in row { + fields.push(int(170, 40)); + fields.push(real_field(140, *value)); + } + } + fields.push(int(93, rows.len() as i64)); + fields.extend([ + boolean(290, false), + boolean(291, true), + boolean(292, false), + ]); + Ok(fields) +} + +fn int(code: i32, value: i64) -> BlockPropertiesTableField { + BlockPropertiesTableField { + code, + value: BlockPropertiesTableValue::Integer(value), + } +} + +fn real_field(code: i32, value: f64) -> BlockPropertiesTableField { + BlockPropertiesTableField { + code, + value: BlockPropertiesTableValue::Real(value), + } +} + +fn text(code: i32, value: &str) -> BlockPropertiesTableField { + BlockPropertiesTableField { + code, + value: BlockPropertiesTableValue::Text(value.into()), + } +} + +fn boolean(code: i32, value: bool) -> BlockPropertiesTableField { + BlockPropertiesTableField { + code, + value: BlockPropertiesTableValue::Boolean(value), + } +} + +fn handle_field(code: i32, value: Handle) -> BlockPropertiesTableField { + BlockPropertiesTableField { + code, + value: BlockPropertiesTableValue::Handle(value), + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 3297b5433..82ec95014 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -13,6 +13,7 @@ pub mod block_visibility; pub(crate) mod block_authoring; pub(crate) mod block_basepoint; pub(crate) mod block_lookup; +pub(crate) mod block_properties_table; pub(crate) mod block_actions; pub(crate) mod block_point_parameter; pub(crate) mod block_distance_parameter; diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs index 2748d90e8..2d5fd6a91 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -188,12 +188,14 @@ const PARAMETER_SETS: &[(&str, &[u8])] = &[ ]; /// Catalog rows remain inert until their authoring commands exist. +#[allow(clippy::too_many_arguments)] fn catalog( tab: Tab, visibility_error: Option<&'static str>, point_error: Option<&'static str>, flip_error: Option<&'static str>, lookup_error: Option<&'static str>, + properties_table_error: Option<&'static str>, move_error: Option<&'static str>, scale_error: Option<&'static str>, rotate_error: Option<&'static str>, @@ -222,15 +224,16 @@ 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 properties_table_action=tab==Tab::Actions&&*label=="Block Properties Table"; let geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"|"Stretch"|"Polar Stretch"|"Rotate"|"Flip"|"Array"); let action_error=match *label {"Scale"=>scale_error,"Stretch"=>stretch_action_error,"Polar Stretch"=>polar_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 enabled = (geometry_action&&action_error.is_none())||(single&&point_error.is_none())||(lookup_action&&lookup_error.is_none())||(properties_table_action&&properties_table_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) } else { icons::semantic_disabled(icon, 28.0) }; - let icon = if tab != Tab::Parameters && *label != "Block Properties Table" { + let icon = if tab != Tab::Parameters { stack![ base, container(if enabled { @@ -248,8 +251,8 @@ fn catalog( base }; let message = enabled.then(|| Message::RibbonToolClick { - tool_id: if geometry_action {format!("BACTION_{}",label.to_ascii_uppercase())}else if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER_{}", label.to_ascii_uppercase())}, - event: crate::modules::ModuleEvent::Command(if geometry_action {format!("BACTION {}",label.to_ascii_uppercase())}else if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER {}", label.to_ascii_uppercase())}), + tool_id: if geometry_action {format!("BACTION_{}",label.to_ascii_uppercase())}else if lookup_action {"BLOOKUPTABLE".into()}else if properties_table_action {"BPROPERTIESTABLE".into()}else{format!("BPARAMETER_{}", label.to_ascii_uppercase())}, + event: crate::modules::ModuleEvent::Command(if geometry_action {format!("BACTION {}",label.to_ascii_uppercase())}else if lookup_action {"BLOOKUPTABLE".into()}else if properties_table_action {"BPROPERTIESTABLE".into()}else{format!("BPARAMETER {}", label.to_ascii_uppercase())}), }); let description = if visibility { crate::t!(visibility_error @@ -272,6 +275,8 @@ fn catalog( 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 if properties_table_action { + crate::t!(properties_table_error.unwrap_or("Create or edit this block's properties table; rows bind Linear and Polar parameters.")).into_owned() } else { unavailable.to_string() }; @@ -290,6 +295,8 @@ fn catalog( 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 if properties_table_action { + container(entry).id("authoring-tool-properties-table").into() } else { entry } @@ -396,6 +403,7 @@ fn constraints() -> Element<'static, Message> { .into() } +#[allow(clippy::too_many_arguments)] pub fn view( selected_tab: Tab, width: f32, @@ -404,6 +412,7 @@ pub fn view( point_error: Option<&'static str>, flip_error: Option<&'static str>, lookup_error: Option<&'static str>, + properties_table_error: Option<&'static str>, move_error: Option<&'static str>, scale_error: Option<&'static str>, rotate_error: Option<&'static str>, @@ -466,6 +475,7 @@ pub fn view( point_error, flip_error, lookup_error, + properties_table_error, move_error, scale_error, rotate_error, diff --git a/src/ui/window/block_properties_table.rs b/src/ui/window/block_properties_table.rs new file mode 100644 index 000000000..d66208540 --- /dev/null +++ b/src/ui/window/block_properties_table.rs @@ -0,0 +1,186 @@ +use crate::app::Message; +use crate::scene::block_properties_table::{Column, TableView}; +use codec::Handle; +use iced::{ + widget::{button, column, container, pick_list, row, scrollable, text, text_input, Space}, + Element, Fill, +}; + +#[derive(Debug, Clone)] +pub enum Action { + Property(usize), + AddProperty, + RemoveProperty(usize), + Cell(usize, usize, String), + AddRow, + DeleteRow(usize), + Apply, +} + +pub(crate) struct State { + pub document: u64, + pub definition: Handle, + pub epoch: u64, + pub original: Option, + pub columns: Vec, + pub properties: Vec, + pub selected_property: Option, + pub rows: Vec>, + pub error: Option, + pub notice: Option, +} + +impl State { + pub(crate) fn edit(&mut self, action: Action) { + self.error = None; + self.notice = None; + match action { + Action::Property(index) if index < self.properties.len() => { + self.selected_property = Some(index); + return; + } + _ => {} + } + match action { + Action::AddProperty => { + let Some(property) = self.selected_property.and_then(|i| self.properties.get(i)) + else { + return; + }; + if self.columns.len() >= 64 + || self.columns.iter().any(|c| { + c.node_id == property.node_id + && c.connection_name == property.connection_name + }) + { + return; + } + for row in &mut self.rows { + row.push("0".into()); + } + self.columns.push(property.clone()); + self.selected_property = None; + } + Action::RemoveProperty(index) + if self.original.is_none() && index < self.columns.len() => + { + self.columns.remove(index); + for row in &mut self.rows { + if index < row.len() { + row.remove(index); + } + } + } + Action::Cell(row, col, value) + if row < self.rows.len() && col < self.columns.len() && col < self.rows[row].len() => + { + self.rows[row][col] = value; + } + Action::AddRow if !self.columns.is_empty() && self.rows.len() < 1000 => { + self.rows.push(vec!["0".into(); self.columns.len()]); + } + Action::DeleteRow(row) if row < self.rows.len() => { + self.rows.remove(row); + } + _ => {} + } + } +} + +pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + let send = |a| Message::BlockPropertiesTable(a); + let cols = state.columns.len(); + let rows = state.rows.len(); + let mut headings = row![text(" ").width(28)].spacing(6); + for (index, column_binding) in state.columns.iter().enumerate() { + let mut header = column![text(column_binding.connection_name.clone()).size(13)]; + if state.original.is_none() { + header = header.push( + button(text(crate::t!("Remove").into_owned()).size(11)) + .on_press(send(Action::RemoveProperty(index))), + ); + } + headings = headings.push(container(header.spacing(4)).width(160)); + } + let mut grid = column![headings].spacing(7); + for (row_index, row_values) in state.rows.iter().enumerate() { + let mut cells = row![text((row_index + 1).to_string()).width(28)].spacing(6); + for col_index in 0..cols { + cells = cells.push( + text_input("", row_values.get(col_index).map_or("", String::as_str)) + .on_input(move |v| send(Action::Cell(row_index, col_index, v))) + .width(160) + .style(crate::ui::style::form::field_style), + ); + } + cells = cells.push( + button(text(crate::t!("Delete").into_owned())) + .on_press(send(Action::DeleteRow(row_index))), + ); + grid = grid.push(cells); + } + let available = state + .properties + .iter() + .enumerate() + .filter(|(_, p)| { + !state + .columns + .iter() + .any(|c| c.node_id == p.node_id && c.connection_name == p.connection_name) + }) + .map(|(i, _)| i) + .collect::>(); + container( + column![ + row![ + pick_list(state.selected_property, available, |i: &usize| state + .properties[*i] + .connection_name + .clone()) + .on_select(move |i| send(Action::Property(i))) + .placeholder(crate::t!("Choose input property").into_owned()) + .width(Fill), + button(text(crate::t!("Add Property").into_owned())) + .on_press_maybe(state.selected_property.map(|_| send(Action::AddProperty))) + ] + .spacing(8), + text( + crate::t!("Bind Linear or Polar parameters. Existing table columns are retained.") + .into_owned() + ) + .size(12), + scrollable(grid) + .direction(scrollable::Direction::Both { + vertical: Default::default(), + horizontal: Default::default() + }) + .height(Fill) + .width(Fill), + row![ + button(text(crate::t!("Add Row").into_owned())).on_press_maybe( + (cols > 0 && rows < 1000).then_some(send(Action::AddRow)) + ), + Space::new().width(Fill) + ] + .spacing(8), + text(state.error.clone().unwrap_or_default()) + .size(12) + .style(text::danger), + text(state.notice.clone().unwrap_or_default()).size(12), + row![ + Space::new().width(Fill), + button(text(crate::t!("OK").into_owned())) + .on_press(send(Action::Apply)) + .style(button::primary), + button(text(crate::t!("Cancel").into_owned())).on_press(Message::CloseModal) + ] + .spacing(8), + ] + .spacing(12), + ) + .padding(16) + .width(sizing.width) + .height(sizing.height) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 03ba407e4..a81120945 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -12,6 +12,7 @@ pub mod block_palette; pub mod block_save_as; pub mod block_visibility; pub mod block_lookup; +pub mod block_properties_table; pub mod browser; pub mod count_palette; pub mod drafting_settings;