From d3e64800f65b6f798bf619882bfa2258015a9b58 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 18 Oct 2026 19:05:17 +0100 Subject: [PATCH] feat(bpt): author native table grips and must-match --- src/app/block_properties_table.rs | 22 ++- src/scene/block_actions.rs | 83 +++++++- src/scene/block_properties_table.rs | 241 +++++++++++++++++++----- src/ui/window/block_properties_table.rs | 19 +- 4 files changed, 299 insertions(+), 66 deletions(-) diff --git a/src/app/block_properties_table.rs b/src/app/block_properties_table.rs index c9791bf71..4b50fa22f 100644 --- a/src/app/block_properties_table.rs +++ b/src/app/block_properties_table.rs @@ -7,7 +7,9 @@ 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."); + 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() { @@ -39,11 +41,13 @@ impl OpenCADStudio { return Task::none(); } }; + let must_match = original.as_ref().is_some_and(|view| view.must_match); self.block_properties_table = Some(State { document: tab.id, definition, epoch: tab.scene.geometry_epoch, original, + must_match, columns, properties: table::columns(&tab.scene.document, definition), selected_property: None, @@ -71,9 +75,8 @@ impl OpenCADStudio { 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(), - ); + self.block_properties_table.as_mut().unwrap().error = + Some(crate::t!("Each cell must be a finite number.").into_owned()); return Task::none(); } } @@ -100,6 +103,7 @@ impl OpenCADStudio { let unchanged = state.original.as_ref().is_some_and(|view| { view.columns == state.columns && view.rows.iter().eq(cells.iter()) + && view.must_match == state.must_match }); if unchanged { self.block_properties_table = None; @@ -107,9 +111,13 @@ impl OpenCADStudio { return Task::none(); } let mut working = tab.scene.document.clone(); - if let Err(error) = - table::apply(&mut working, state.definition, &state.columns, &cells) - { + if let Err(error) = table::apply( + &mut working, + state.definition, + &state.columns, + &cells, + state.must_match, + ) { self.block_properties_table.as_mut().unwrap().error = Some(crate::t!(error).into_owned()); return Task::none(); diff --git a/src/scene/block_actions.rs b/src/scene/block_actions.rs index 1e7e3fb39..bc69b209b 100644 --- a/src/scene/block_actions.rs +++ b/src/scene/block_actions.rs @@ -452,7 +452,9 @@ pub(crate) fn create_with_options( .get_entity(*h) .is_none_or(|e| !supported(e) || e.common().owner_handle != definition) }) { - return Err("This milestone supports lines, polylines, arcs, circles, ellipses, points and text belonging to this block."); + 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)); @@ -1034,14 +1036,81 @@ fn evaluate_impl( if length < 1e-9 { return Err("Specify a positive parameter length."); } - position = p.base + axis * length; + let mut snapped_length = length; + if !force { + if let Some(table) = super::block_properties_table::table(baseline, definition)? + { + if table.must_match + && table.columns.iter().any(|column| { + column.handle == p.handle + && column + .connection_name + .eq_ignore_ascii_case("UpdatedDistance") + }) + { + let values = table + .columns + .iter() + .position(|column| { + column.handle == p.handle + && column + .connection_name + .eq_ignore_ascii_case("UpdatedDistance") + }) + .map(|i| { + table + .rows + .iter() + .filter_map(|r| r.get(i).copied()) + .collect::>() + }) + .unwrap_or_default(); + if let Some(nearest) = values.iter().copied().min_by(|a, b| { + (a - length) + .abs() + .total_cmp(&(b - length).abs()) + .then_with(|| a.total_cmp(b)) + }) { + snapped_length = nearest; + } + } + } + } + position = p.base + axis * snapped_length; } if p.rotation { let delta = position - p.base; if delta.length() < 1e-9 { return Err("Specify a rotation direction away from the parameter base."); } - position = p.base + delta.normalize() * p.end.distance(p.base); + let mut angle = delta.y.atan2(delta.x); + if !force { + if let Some(table) = super::block_properties_table::table(baseline, definition)? + { + if table.must_match { + if let Some(col) = table.columns.iter().position(|column| { + column.handle == p.handle + && column.connection_name.eq_ignore_ascii_case("UpdatedAngle") + }) { + if let Some(nearest) = table + .rows + .iter() + .filter_map(|row| row.get(col).copied()) + .min_by(|a, b| { + (a - angle) + .abs() + .total_cmp(&(b - angle).abs()) + .then_with(|| a.total_cmp(b)) + }) + { + angle = nearest; + } + } + } + } + } + position = + p.base + DVec3::new(angle.cos(), angle.sin(), 0.0) * p.end.distance(p.base); } Value::Point(position) } @@ -1203,7 +1272,7 @@ fn evaluate_impl( ] } => { - return Err("This Move action uses options not supported yet.") + return Err("This Move action uses options not supported yet."); } DynamicBlockData::FlipAction(f) if f.connections @@ -1214,7 +1283,7 @@ fn evaluate_impl( } }) => { - return Err("This Flip action uses options not supported yet.") + return Err("This Flip action uses options not supported yet."); } DynamicBlockData::RotateAction(r) if !r.action.dependent @@ -1230,7 +1299,7 @@ fn evaluate_impl( name: name.into(), }) => { - return Err("This Rotate action uses options not supported yet.") + return Err("This Rotate action uses options not supported yet."); } DynamicBlockData::ScaleAction(s) if !s.action.dependent @@ -1246,7 +1315,7 @@ fn evaluate_impl( name: name.into(), }) => { - return Err("This Scale action uses options not supported yet.") + return Err("This Scale action uses options not supported yet."); } _ => {} } diff --git a/src/scene/block_properties_table.rs b/src/scene/block_properties_table.rs index a7e4e5603..4f1abe005 100644 --- a/src/scene/block_properties_table.rs +++ b/src/scene/block_properties_table.rs @@ -1,8 +1,9 @@ //! Native Block Properties Table authoring. use super::block_authoring::{self as authoring, *}; use codec::objects::{ - BlockElement, BlockEvalValue, BlockOnePointParameter, BlockParameter, BlockPropertiesTable, - BlockPropertiesTableField, BlockPropertiesTableValue, DynamicBlockData, ObjectType, + BlockElement, BlockEvalValue, BlockGrip, BlockGripExpression, BlockOnePointParameter, + BlockParameter, BlockPropertiesTable, BlockPropertiesTableField, BlockPropertiesTableValue, + DynamicBlockData, ObjectType, }; use codec::{CadDocument, Handle}; @@ -11,6 +12,7 @@ pub(crate) struct Column { pub handle: Handle, pub node_id: i32, pub connection_name: String, + pub display_name: String, pub type_code: i64, } @@ -22,11 +24,12 @@ pub(crate) struct TableView { pub rows: Vec>, pub row_start: usize, pub trailer_start: usize, + pub must_match: bool, } pub(crate) type Grid = (Handle, Vec, Vec>); -fn graph_handle(document: &CadDocument, definition: Handle) -> Option { +pub(crate) 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; @@ -59,6 +62,20 @@ pub(crate) fn table( .find(|parameter| parameter.handle == column.handle) .map(|parameter| parameter.id) .unwrap_or(0); + if column.node_id != 0 { + column.type_code = i64::from(column.node_id); + } + column.display_name = super::block_lookup::properties(document, definition) + .into_iter() + .find(|property| { + property.column.node_id == column.node_id + && property + .column + .connection_name + .eq_ignore_ascii_case(&column.connection_name) + }) + .map(|property| property.label) + .unwrap_or_else(|| column.connection_name.clone()); } Ok(Some(view)) } @@ -90,9 +107,10 @@ fn parse(table_handle: Handle, table: BlockPropertiesTable) -> Result 64 { return Err("The Block Properties Table column count is unsupported."); @@ -135,7 +153,8 @@ fn parse(table_handle: Handle, table: BlockPropertiesTable) -> Result Result 1000 { return Err("The Block Properties Table row count is unsupported."); @@ -195,6 +215,13 @@ fn parse(table_handle: Handle, table: BlockPropertiesTable) -> Result Some(v), + _ => None, + }) + .unwrap_or(false); Ok(TableView { handle: table_handle, table, @@ -202,6 +229,7 @@ fn parse(table_handle: Handle, table: BlockPropertiesTable) -> Result Vec 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 + let parameter = parameters + .iter() + .find(|parameter| parameter.id == property.column.node_id)?; + if !property .column .connection_name .eq_ignore_ascii_case("UpdatedDistance") + && !property + .column + .connection_name + .eq_ignore_ascii_case("UpdatedAngle") { - 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, + display_name: property.label, + type_code: i64::from(parameter.id), }) }) .collect() @@ -258,24 +283,26 @@ pub(crate) fn apply( definition: Handle, columns: &[Column], rows: &[Vec], + must_match: bool, ) -> 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()) - }) { + 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.")?; + graph_handle(&working, definition).ok_or("The authoring graph is unavailable.")?; let previous = table(&working, definition)?; + let mut new_table_eval = None; 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." - })?; + 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)], @@ -288,6 +315,7 @@ pub(crate) fn apply( minor: MINOR, eed_1071: 0, }; + new_table_eval = Some(slots.eval); let parameter = BlockOnePointParameter { parameter: BlockParameter { element, @@ -315,7 +343,7 @@ pub(crate) fn apply( handle }; let old_fields = previous.as_ref().map(|view| view.table.fields.as_slice()); - let new_fields = encode_fields(columns, rows, old_fields)?; + let new_fields = encode_fields(columns, rows, old_fields, must_match)?; let Some(ObjectType::DynamicBlock(object)) = working.objects.get_mut(&handle) else { return Err("The Block Properties Table object is unavailable."); }; @@ -323,24 +351,123 @@ pub(crate) fn apply( return Err("The selected object is not a Block Properties Table."); }; table.fields = new_fields; + if let Some(eval) = new_table_eval { + table.parameter.property_count = eval + 1; + } + let graph = graph_handle(&working, definition).ok_or("The authoring graph is unavailable.")?; + ensure_grip(&mut working, definition, graph, handle)?; let _ = graph; *document = working; Ok(handle) } +fn ensure_grip( + document: &mut CadDocument, + definition: Handle, + graph: Handle, + table_handle: Handle, +) -> Result<(), &'static str> { + let already = document.objects.values().any(|value| matches!(value, ObjectType::DynamicBlock(object) if object.owner == graph && matches!(&object.data, DynamicBlockData::PropertiesTableGrip(_)))); + let Some(ObjectType::DynamicBlock(table_obj)) = document.objects.get(&table_handle) else { + return Err("The Block Properties Table object is unavailable."); + }; + let DynamicBlockData::PropertiesTable(table_data) = &table_obj.data else { + return Err("The selected object is not a Block Properties Table."); + }; + let location = table_data.parameter.definition_point; + let table_eval = table_data.parameter.parameter.element.eval.node_id; + if already { + let grip_eval = document.objects.values().find_map(|value| match value { + ObjectType::DynamicBlock(object) if object.owner == graph => match &object.data { + DynamicBlockData::PropertiesTableGrip(grip) => Some(grip.element.eval.node_id), + _ => None, + }, + _ => None, + }); + if let Some(grip_eval) = grip_eval { + if let Some(ObjectType::DynamicBlock(object)) = document.objects.get_mut(&table_handle) + { + if let DynamicBlockData::PropertiesTable(table) = &mut object.data { + table.parameter.property_count = grip_eval; + } + } + } + return Ok(()); + } + let (graph, slots) = authoring::prepare(document, definition) + .map_err(|_| "The block authoring graph cannot accept a table grip.")?; + authoring::register_classes( + document, + &[ + ("BLOCKPROPERTIESTABLEGRIP", "AcDbBlockGrip", 55), + ("BLOCKGRIPLOCATIONCOMPONENT", "AcDbBlockGripExpr", 20), + ], + ); + let grip = document.allocate_handle(); + let x = document.allocate_handle(); + let y = document.allocate_handle(); + let grip_data = BlockGrip { + element: BlockElement { + eval: expression(slots.eval, NO_VALUE, BlockEvalValue::None), + name: "Grip".into(), + major: MAJOR, + minor: MINOR, + eed_1071: 0, + }, + flags_91: slots.eval + 1, + flags_92: slots.eval + 2, + location, + insert_cycling: false, + insert_cycling_weight: -1, + }; + document.objects.insert( + grip, + ObjectType::DynamicBlock(dynamic( + grip, + graph, + "BLOCKPROPERTIESTABLEGRIP", + "AcDbBlockGrip", + DynamicBlockData::PropertiesTableGrip(grip_data), + )), + ); + for (handle, eval, axis) in [ + (x, slots.eval + 1, "UpdatedX"), + (y, slots.eval + 2, "UpdatedY"), + ] { + document.objects.insert( + handle, + ObjectType::DynamicBlock(dynamic( + handle, + graph, + "BLOCKGRIPLOCATIONCOMPONENT", + "AcDbBlockGripExpr", + DynamicBlockData::GripLocationComponent(BlockGripExpression { + eval: expression(eval, 40, BlockEvalValue::Real(0.0)), + grip_type: table_eval, + expression: axis.into(), + }), + )), + ); + } + authoring::append_components(document, graph, slots, &[grip, x, y], &[1, 2]); + if let Some(ObjectType::DynamicBlock(object)) = document.objects.get_mut(&table_handle) { + if let DynamicBlockData::PropertiesTable(table) = &mut object.data { + table.parameter.property_count = slots.eval; + } + } + Ok(()) +} + fn encode_fields( columns: &[Column], rows: &[Vec], old: Option<&[BlockPropertiesTableField]>, + must_match: bool, ) -> 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, ""), - ] + vec![int(90, 2), text(300, "Block Table1"), text(301, "")] }; if old.is_none() { fields.push(int(91, columns.len() as i64)); @@ -351,33 +478,55 @@ fn encode_fields( int(171, -1), text(300, ""), text(301, &column.connection_name), - int(90, column.type_code), + int(90, column.node_id as i64), int(170, -9999), int(170, -9999), boolean(290, false), boolean(291, true), boolean(292, true), - boolean(293, column.type_code == 21), + boolean( + 293, + column.connection_name.eq_ignore_ascii_case("UpdatedAngle"), + ), boolean(294, false), text(302, ""), handle_field(340, Handle::NULL), ]); } } else { - let view = parse(Handle::NULL, BlockPropertiesTable { - parameter: Default::default(), - fields: fields.clone(), - })?; + 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 + || (old.type_code != new.type_code + && old.node_id != 0 + && old.node_id != new.node_id) }) { - return Err("Changing Block Properties Table columns is not supported for an existing table."); + return Err( + "Changing Block Properties Table columns is not supported for an existing table.", + ); } fields.truncate(view.row_start - 1); + for column in columns { + if let Some(position) = fields.iter().enumerate().skip(4).find_map(|(i, f)| + (f.code == 340 && matches!(f.value, BlockPropertiesTableValue::Handle(h) if h == column.handle)).then_some(i)) { + let end = fields.iter().enumerate().skip(position + 1).find(|(_, f)| f.code == 340).map(|(i, _)| i).unwrap_or(fields.len()); + if let Some(field) = fields.iter_mut().take(end).skip(position + 1).find(|f| f.code == 90) { + field.value = BlockPropertiesTableValue::Integer(i64::from(column.node_id)); + } + if let Some(field) = fields.iter_mut().take(end).skip(position + 1).find(|f| f.code == 293) { + field.value = BlockPropertiesTableValue::Boolean(column.connection_name.eq_ignore_ascii_case("UpdatedAngle")); + } + } + } } fields.push(int(92, rows.len() as i64)); for (row_index, row) in rows.iter().enumerate() { @@ -389,7 +538,7 @@ fn encode_fields( } fields.push(int(93, rows.len() as i64)); fields.extend([ - boolean(290, false), + boolean(290, must_match), boolean(291, true), boolean(292, false), ]); diff --git a/src/ui/window/block_properties_table.rs b/src/ui/window/block_properties_table.rs index d66208540..51cdebc05 100644 --- a/src/ui/window/block_properties_table.rs +++ b/src/ui/window/block_properties_table.rs @@ -15,6 +15,7 @@ pub enum Action { AddRow, DeleteRow(usize), Apply, + MustMatch(bool), } pub(crate) struct State { @@ -22,6 +23,7 @@ pub(crate) struct State { pub definition: Handle, pub epoch: u64, pub original: Option, + pub must_match: bool, pub columns: Vec, pub properties: Vec, pub selected_property: Option, @@ -72,13 +74,16 @@ impl State { } } Action::Cell(row, col, value) - if row < self.rows.len() && col < self.columns.len() && col < self.rows[row].len() => + 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::MustMatch(value) => self.must_match = value, Action::DeleteRow(row) if row < self.rows.len() => { self.rows.remove(row); } @@ -93,7 +98,7 @@ pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Elem 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)]; + let mut header = column![text(column_binding.display_name.clone()).size(13)]; if state.original.is_none() { header = header.push( button(text(crate::t!("Remove").into_owned()).size(11)) @@ -136,7 +141,7 @@ pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Elem row![ pick_list(state.selected_property, available, |i: &usize| state .properties[*i] - .connection_name + .display_name .clone()) .on_select(move |i| send(Action::Property(i))) .placeholder(crate::t!("Choose input property").into_owned()) @@ -158,12 +163,14 @@ pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Elem .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)) - ), + 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), + iced::widget::checkbox(state.must_match) + .label(crate::t!("Block properties must match a row in the table").into_owned()) + .on_toggle(move |value| send(Action::MustMatch(value))), text(state.error.clone().unwrap_or_default()) .size(12) .style(text::danger),