forked from 1b-agent/opencadstudio-upstream
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.
This commit is contained in:
1 parent
2b0c1a6b58
commit
a1de7e4ecd
13 files changed
+865
-4
No files matched your search
@@ -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<Message> {
|
||||
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<Message> {
|
||||
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::<f64>() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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<crate::ui::window::block_save_as::State>,
|
||||
block_visibility: Option<crate::ui::window::block_visibility::State>,
|
||||
block_lookup: Option<crate::ui::window::block_lookup::State>,
|
||||
block_properties_table: Option<crate::ui::window::block_properties_table::State>,
|
||||
block_visibility_mode: bool,
|
||||
/// PDF dialogs' working copies; `None` while closed.
|
||||
pdf_attach: Option<crate::ui::window::pdf_dialogs::PdfAttachState>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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<Column>,
|
||||
pub rows: Vec<Vec<f64>>,
|
||||
pub row_start: usize,
|
||||
pub trailer_start: usize,
|
||||
}
|
||||
|
||||
pub(crate) type Grid = (Handle, Vec<Column>, Vec<Vec<String>>);
|
||||
|
||||
fn graph_handle(document: &CadDocument, definition: Handle) -> Option<Handle> {
|
||||
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<Option<TableView>, &'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<i64> {
|
||||
match field.value {
|
||||
BlockPropertiesTableValue::Integer(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn real(field: &BlockPropertiesTableField) -> Option<f64> {
|
||||
match field.value {
|
||||
BlockPropertiesTableValue::Real(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(field: &BlockPropertiesTableField) -> Option<Handle> {
|
||||
match field.value {
|
||||
BlockPropertiesTableValue::Handle(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(table_handle: Handle, table: BlockPropertiesTable) -> Result<TableView, &'static str> {
|
||||
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<Column> {
|
||||
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<Option<Grid>, &'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<f64>],
|
||||
) -> Result<Handle, &'static str> {
|
||||
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<f64>],
|
||||
old: Option<&[BlockPropertiesTableField]>,
|
||||
) -> Result<Vec<BlockPropertiesTableField>, &'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),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TableView>,
|
||||
pub columns: Vec<Column>,
|
||||
pub properties: Vec<Column>,
|
||||
pub selected_property: Option<usize>,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
pub error: Option<String>,
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user