diff --git a/Cargo.lock b/Cargo.lock index 5f4c4930..160bc72d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=1fa0a5e#1fa0a5e22f53dd45c6b0b9d8c63f7fc7730a3a8f" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 7254993d..1b3573a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1fa0a5e", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -60,6 +60,9 @@ ashpd = { version = "0.13.13", default-features = false, features = ["async-io", iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } +[patch."https://github.com/HakanSeven12/cadcodec.git"] +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "1fa0a5e" } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } meshopt = "0.6.2" diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6fbd00ee..515e4526 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1929,8 +1929,13 @@ annotate = .scale-list = Scale List .sentence = Sentence .sync-scales = Sync Scales + .table-cell-enter-text-row-column-current = TABLE CELL Enter text for [__ocs_arg_row__,__ocs_arg_column__] <__ocs_arg_current__>: + .table-enter-column-width-width = TABLE Enter column width [__ocs_arg_width__]: .table-enter-number-of-columns-cols = TABLE Enter number of columns [__ocs_arg_cols__]: + .table-enter-number-of-data-rows-rows-cols = TABLE Enter number of data rows [__ocs_arg_rows__] (__ocs_arg_cols__ cols): .table-enter-number-of-rows-incl-header-rows-co = TABLE Enter number of rows (incl. header) [__ocs_arg_rows__] (__ocs_arg_cols__ cols): + .table-enter-row-height-height = TABLE Enter row height [__ocs_arg_height__]: + .table-insertion-behavior-point-window-mode = TABLE Insertion behavior [Point/Window] <__ocs_arg_mode__>: .table-specify-insertion-point-cols-rows = TABLE Specify insertion point [__ocs_arg_cols__×__ocs_arg_rows__]: .tcase-upper-lower-sentence-title = TCASE [Upper / Lower / Sentence / Title]: .tcase-select-text-objects-first = TCASE: select text objects first. @@ -2217,6 +2222,7 @@ view = .color-scheme-set-to-name = Color scheme set to '__ocs_fmt_0__'. .current-annotation-scale-name = Current annotation scale: __ocs_fmt_0__ .datalink-path-to-the-csv-file = DATALINK path to the .csv file: + .datalink-specify-insertion-point = DATALINK Specify insertion point: .datalink-cannot-read-path-e = DATALINK: cannot read "__ocs_fmt_0__": __ocs_fmt_1__ .datalink-imported-nrows-ncols-cells-into-a-tab = DATALINK: imported __ocs_fmt_0__×__ocs_fmt_1__ cells into a table at the origin. .datalink-the-csv-file-is-empty = DATALINK: the CSV file is empty. @@ -2291,6 +2297,11 @@ view = .shortcut-set-key-cmd-str = Shortcut set: __ocs_fmt_0__ → __ocs_fmt_1__ .table-cell-select-a-table-entity-first-or-row = TABLE CELL: select a Table entity first, or row/col out of range. .table-cell-set-row-col-text = TABLE CELL: set [__ocs_fmt_0__,__ocs_fmt_1__] = "__ocs_fmt_2__". + .table-block-definition-not-found = TABLE: block definition not found. + .table-edit-applied = TABLE: edit applied. + .table-field-handle-not-found = TABLE: field handle not found. + .table-row-column-out-of-range-or-cell-locked = TABLE: row/column is out of range or the cell is locked. + .table-select-a-table-first = TABLE: select a table first. .toolpalettes-tool-palettes-not-yet-implemented = TOOLPALETTES: Tool Palettes not yet implemented. .tile-horiz = Tile @@ -2333,6 +2344,7 @@ view = .usage-shortcuts-list-set-key-cmd-clear-key = Usage: SHORTCUTS LIST | SET | CLEAR .usage-shortcuts-set-key-command-e-g-shortcuts = Usage: SHORTCUTS SET e.g. SHORTCUTS SET CTRL+D DIST .usage-table-cell-row-col-text = Usage: TABLE CELL + .usage-table-edit-actions = Usage: TABLE CELL | INSERTROW | DELETEROW | INSERTCOL | DELETECOL | MERGE | UNMERGE | ROWHEIGHT | COLWIDTH | LOCK | BLOCK | FORMULA | FIELD .usage-ucsicon-on-off-noorigin-origin = Usage: UCSICON ON | OFF | NOORIGIN | ORIGIN .usage-underlay-fade-n-contrast-n-on-off-clip-o = Usage: UNDERLAY [FADE |CONTRAST |ON|OFF|CLIP ON|OFF|MONO ON|OFF] .usage-useri-1-5-int-userr-1-5-real = Usage: USERI <1-5> | USERR <1-5> diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 8a85cf23..06defcac 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1903,8 +1903,13 @@ annotate = .scale-list = Ölçek Listesi .sentence = Cümle Düzeni .sync-scales = Ölçekleri Eşitle + .table-cell-enter-text-row-column-current = TABLE CELL [__ocs_arg_row__,__ocs_arg_column__] hücresi için metni girin <__ocs_arg_current__>: + .table-enter-column-width-width = TABLE Sütun genişliğini girin [__ocs_arg_width__]: .table-enter-number-of-columns-cols = TABLE Sütun sayısını girin [__ocs_arg_cols__]: + .table-enter-number-of-data-rows-rows-cols = TABLE Veri satırı sayısını girin [__ocs_arg_rows__] (__ocs_arg_cols__ sütun): .table-enter-number-of-rows-incl-header-rows-co = TABLE Satır sayısını girin (başlık dahil) [__ocs_arg_rows__] (__ocs_arg_cols__ sütun): + .table-enter-row-height-height = TABLE Satır yüksekliğini girin [__ocs_arg_height__]: + .table-insertion-behavior-point-window-mode = TABLE Ekleme yöntemi [Nokta/Pencere] <__ocs_arg_mode__>: .table-specify-insertion-point-cols-rows = TABLE Ekleme noktasını belirtin [__ocs_arg_cols__×__ocs_arg_rows__]: .tcase-upper-lower-sentence-title = TCASE [Büyük Harf / Küçük Harf / Cümle Düzeni / Başlık Düzeni]: .tcase-select-text-objects-first = TCASE: Önce metin nesnelerini seçin. @@ -2177,6 +2182,7 @@ view = .color-scheme-set-to-name = Renk düzeni '__ocs_fmt_0__' olarak ayarlandı. .current-annotation-scale-name = Geçerli açıklama ölçeği: __ocs_fmt_0__ .datalink-path-to-the-csv-file = DATALINK .csv dosyasının yolu: + .datalink-specify-insertion-point = DATALINK Ekleme noktasını belirtin: .datalink-cannot-read-path-e = DATALINK: "__ocs_fmt_0__" okunamıyor: __ocs_fmt_1__ .datalink-imported-nrows-ncols-cells-into-a-tab = DATALINK: __ocs_fmt_0__×__ocs_fmt_1__ hücre, başlangıç noktasındaki tabloya aktarıldı. .datalink-the-csv-file-is-empty = DATALINK: CSV dosyası boş. @@ -2245,6 +2251,11 @@ view = .shortcut-set-key-cmd-str = Kısayol ayarlandı: __ocs_fmt_0__ → __ocs_fmt_1__ .table-cell-select-a-table-entity-first-or-row = TABLE CELL: Önce bir Tablo nesnesi seçin ya da satır/sütun aralık dışında. .table-cell-set-row-col-text = TABLE CELL: [__ocs_fmt_0__,__ocs_fmt_1__] = "__ocs_fmt_2__" olarak ayarlandı. + .table-block-definition-not-found = TABLE: Blok tanımı bulunamadı. + .table-edit-applied = TABLE: Düzenleme uygulandı. + .table-field-handle-not-found = TABLE: Alan tanıtıcısı bulunamadı. + .table-row-column-out-of-range-or-cell-locked = TABLE: Satır/sütun aralık dışında veya hücre kilitli. + .table-select-a-table-first = TABLE: Önce bir tablo seçin. .toolpalettes-tool-palettes-not-yet-implemented = TOOLPALETTES: Araç Paletleri henüz uygulanmadı. .tile-horiz = Yatay Döşe .tile-vert = Dikey Döşe @@ -2281,6 +2292,7 @@ view = .usage-shortcuts-list-set-key-cmd-clear-key = Kullanım: SHORTCUTS LIST | SET | CLEAR .usage-shortcuts-set-key-command-e-g-shortcuts = Kullanım: SHORTCUTS SET ör. SHORTCUTS SET CTRL+D DIST .usage-table-cell-row-col-text = Kullanım: TABLE CELL + .usage-table-edit-actions = Kullanım: TABLE CELL | INSERTROW | DELETEROW | INSERTCOL | DELETECOL | MERGE | UNMERGE | ROWHEIGHT | COLWIDTH | LOCK | BLOCK | FORMULA | FIELD .usage-ucsicon-on-off-noorigin-origin = Kullanım: UCSICON ON | OFF | NOORIGIN | ORIGIN .usage-underlay-fade-n-contrast-n-on-off-clip-o = Kullanım: UNDERLAY [FADE |CONTRAST |ON|OFF|CLIP ON|OFF|MONO ON|OFF] .usage-useri-1-5-int-userr-1-5-real = Kullanım: USERI <1-5> | USERR <1-5> diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index ccf235a1..0407c8e1 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -12,60 +12,327 @@ impl OpenCADStudio { .push_output(crate::t!("PAN: drag with the left mouse button. Press Esc to exit.").as_ref()); } - // ── TABLE cell editing ───────────────────────────────────────────── - // TABLE CELL — set text for a cell in the selected Table + // ── TABLE structure and cell editing ─────────────────────────────── cmd if cmd.starts_with("TABLE ") => { let rest = cmd.trim_start_matches("TABLE").trim(); - let sub_up = rest.split_whitespace().next().unwrap_or("").to_uppercase(); - if sub_up == "CELL" { - let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); - // parts: ["CELL", "", "", ""] - let row_res = parts.get(1).and_then(|s| s.parse::().ok()); - let col_res = parts.get(2).and_then(|s| s.parse::().ok()); - let text = parts.get(3).copied().unwrap_or(""); - match (row_res, col_res) { - (Some(row), Some(col)) => { - let selected_handles: Vec = self.tabs[i] - .scene - .selected_entities() - .iter() - .map(|(h, _)| *h) - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - let mut found = false; - for sh in &selected_handles { - if let Some(acadrust::EntityType::Table(tbl)) = self.tabs[i] - .scene - .document - .entities_mut() - .find(|e| e.common().handle == *sh) - { - if tbl.set_cell_text(row, col, text) { - found = true; - } - } - } - if found { - self.push_undo_snapshot(i, "TABLE CELL"); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "TABLE CELL: set [{row},{col}] = \"{text}\"." - ).as_ref()); - } else { - self.command_line.push_error( - crate::t!("TABLE CELL: select a Table entity first, or row/col out of range.").as_ref() - ); - } - } - _ => { - self.command_line - .push_info(crate::t!("Usage: TABLE CELL ").as_ref()); + #[derive(Clone)] + enum TableAction { + Cell(usize, usize, String), + InsertRow(usize), + DeleteRow(usize), + InsertColumn(usize), + DeleteColumn(usize), + Merge(usize, usize, usize, usize), + Unmerge(usize, usize), + RowHeight(usize, f64), + ColumnWidth(usize, f64), + Lock(usize, usize, bool), + Block(usize, usize, String), + Formula(usize, usize, String), + Field(usize, usize, acadrust::Handle), + } + let words: Vec<&str> = rest.split_whitespace().collect(); + let integer = |index: usize| { + words.get(index).and_then(|value| value.parse::().ok()) + }; + let real = |index: usize| { + words.get(index).and_then(|value| value.parse::().ok()) + }; + let action = match words.first().map(|word| word.to_ascii_uppercase()).as_deref() { + Some("CELL") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2)) { + (Some(row), Some(column)) => Some(TableAction::Cell( + row, + column, + parts.get(3).copied().unwrap_or("").to_string(), + )), + _ => None, } } - } else { + Some("INSERTROW") => integer(1).map(TableAction::InsertRow), + Some("DELETEROW") => integer(1).map(TableAction::DeleteRow), + Some("INSERTCOLUMN") | Some("INSERTCOL") => { + integer(1).map(TableAction::InsertColumn) + } + Some("DELETECOLUMN") | Some("DELETECOL") => { + integer(1).map(TableAction::DeleteColumn) + } + Some("MERGE") => match (integer(1), integer(2), integer(3), integer(4)) { + (Some(r1), Some(c1), Some(r2), Some(c2)) => { + Some(TableAction::Merge(r1, c1, r2, c2)) + } + _ => None, + }, + Some("UNMERGE") => match (integer(1), integer(2)) { + (Some(row), Some(column)) => Some(TableAction::Unmerge(row, column)), + _ => None, + }, + Some("ROWHEIGHT") => match (integer(1), real(2)) { + (Some(row), Some(height)) if height > 0.0 => { + Some(TableAction::RowHeight(row, height)) + } + _ => None, + }, + Some("COLUMNWIDTH") | Some("COLWIDTH") => match (integer(1), real(2)) { + (Some(column), Some(width)) if width > 0.0 => { + Some(TableAction::ColumnWidth(column, width)) + } + _ => None, + }, + Some("LOCK") => match (integer(1), integer(2), words.get(3)) { + (Some(row), Some(column), Some(value)) => Some(TableAction::Lock( + row, + column, + !value.eq_ignore_ascii_case("OFF"), + )), + _ => None, + }, + Some("BLOCK") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2), parts.get(3)) { + (Some(row), Some(column), Some(name)) if !name.trim().is_empty() => { + Some(TableAction::Block(row, column, name.trim().to_string())) + } + _ => None, + } + } + Some("FORMULA") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2), parts.get(3)) { + (Some(row), Some(column), Some(expression)) + if !expression.trim().is_empty() => + { + Some(TableAction::Formula( + row, + column, + expression.trim().to_string(), + )) + } + _ => None, + } + } + Some("FIELD") => match (integer(1), integer(2), words.get(3)) { + (Some(row), Some(column), Some(value)) => { + u64::from_str_radix(value.trim_start_matches("0x"), 16) + .ok() + .map(|handle| { + TableAction::Field( + row, + column, + acadrust::Handle::new(handle), + ) + }) + } + _ => None, + }, + _ => None, + }; + let Some(action) = action else { self.command_line.push_info( - "Usage: TABLE (creates new table) or TABLE CELL ", + crate::t!("Usage: TABLE CELL | INSERTROW | DELETEROW | INSERTCOL | DELETECOL | MERGE | UNMERGE | ROWHEIGHT | COLWIDTH | LOCK | BLOCK | FORMULA | FIELD").as_ref(), ); + return Some(Task::none()); + }; + let selected_handles: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(handle, _)| *handle) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if selected_handles.is_empty() { + self.command_line + .push_error(crate::t!("TABLE: select a table first.").as_ref()); + return Some(Task::none()); + } + let block_handle = if let TableAction::Block(_, _, name) = &action { + self.tabs[i] + .scene + .document + .block_records + .iter() + .find(|record| record.name.eq_ignore_ascii_case(name)) + .map(|record| record.handle) + } else { + None + }; + if matches!(action, TableAction::Block(_, _, _)) && block_handle.is_none() { + self.command_line.push_error( + crate::t!("TABLE: block definition not found.").as_ref(), + ); + return Some(Task::none()); + } + if let TableAction::Field(_, _, field_handle) = &action { + if !self.tabs[i].scene.document.fields.contains_key(field_handle) { + self.command_line.push_error( + crate::t!("TABLE: field handle not found.").as_ref(), + ); + return Some(Task::none()); + } + } + self.push_undo_snapshot(i, "TABLE EDIT"); + let mut changed = false; + for handle in &selected_handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(*handle) + else { + continue; + }; + match &action { + TableAction::Cell(row, column, text) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + cell.set_text(text); + changed = true; + } + } + } + TableAction::InsertRow(row) if *row <= table.row_count() => { + let height = table + .rows + .get((*row).min(table.row_count().saturating_sub(1))) + .map(|row| row.height) + .unwrap_or(0.5); + table.insert_row(*row); + table.set_row_height(*row, height); + changed = true; + } + TableAction::DeleteRow(row) + if table.row_count() > 1 && *row < table.row_count() => + { + table.remove_row(*row); + changed = true; + } + TableAction::InsertColumn(column) if *column <= table.column_count() => { + let width = table + .columns + .get((*column).min(table.column_count().saturating_sub(1))) + .map(|column| column.width) + .unwrap_or(2.0); + table.insert_column(*column, width); + changed = true; + } + TableAction::DeleteColumn(column) + if table.column_count() > 1 && *column < table.column_count() => + { + table.remove_column(*column); + changed = true; + } + TableAction::Merge(r1, c1, r2, c2) + if *r1 < table.row_count() + && *r2 < table.row_count() + && *c1 < table.column_count() + && *c2 < table.column_count() => + { + table.merge_cells(acadrust::entities::table::CellRange::new( + (*r1).min(*r2), + (*c1).min(*c2), + (*r1).max(*r2), + (*c1).max(*c2), + )); + changed = true; + } + TableAction::Unmerge(row, column) => { + table.unmerge_cell(*row, *column); + changed = true; + } + TableAction::RowHeight(row, height) if *row < table.row_count() => { + table.set_row_height(*row, *height); + changed = true; + } + TableAction::ColumnWidth(column, width) + if *column < table.column_count() => + { + table.set_column_width(*column, *width); + changed = true; + } + TableAction::Lock(row, column, locked) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + cell.state.set(CellStateFlags::CONTENT_LOCKED, *locked); + cell.state.set(CellStateFlags::FORMAT_LOCKED, *locked); + changed = true; + } + } + TableAction::Block(row, column, _) => { + if let (Some(handle), Some(cell)) = + (block_handle, table.cell_mut(*row, *column)) + { + use acadrust::entities::table::{ + CellContent, CellStateFlags, CellType, + }; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + cell.contents.clear(); + cell.contents.push(CellContent::block(handle)); + cell.cell_type = CellType::Block; + changed = true; + } + } + } + TableAction::Formula(row, column, expression) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + let formula = if expression.starts_with('=') { + expression.clone() + } else { + format!("={expression}") + }; + cell.set_text(&formula); + changed = true; + } + } + } + TableAction::Field(row, column, field_handle) => { + let mut attached = false; + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::{ + CellContent, CellStateFlags, CellType, + }; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + let mut content = CellContent::text(""); + content.field_handle = Some(*field_handle); + cell.contents.clear(); + cell.contents.push(content); + cell.cell_type = CellType::Text; + attached = true; + } + } + if attached { + if !table.field_handles.contains(field_handle) { + table.field_handles.push(*field_handle); + } + changed = true; + } + } + _ => {} + } + } + if changed { + self.invalidate_property_targets(i, &selected_handles); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line + .push_output(crate::t!("TABLE: edit applied.").as_ref()); + } else { + self.command_line + .push_error(crate::t!( + "TABLE: row/column is out of range or the cell is locked." + ).as_ref()); } } @@ -1161,8 +1428,7 @@ impl OpenCADStudio { return Some(Task::done(Message::AnnoObjectScaleOpen)); } - // DATALINK — import a CSV file into a table placed at the - // origin (one-time import; a live re-reading link is future work). + // DATALINK — create a persistent linked table. "DATALINK" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("DATALINK", "DATALINK path to the .csv file:"); @@ -1173,17 +1439,13 @@ impl OpenCADStudio { let path = cmd.trim_start_matches("DATALINK").trim(); if path.is_empty() { self.command_line.push_info( - "Usage: DATALINK — imports the CSV into a table at the origin.", + "Usage: DATALINK ", ); return Some(Task::none()); } match std::fs::read_to_string(path) { Ok(text) => { - let rows_data: Vec> = text - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|line| line.split(',').map(|s| s.trim().to_string()).collect()) - .collect(); + let rows_data = parse_csv_table(&text); let nrows = rows_data.len(); let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0); if nrows == 0 || ncols == 0 { @@ -1203,14 +1465,27 @@ impl OpenCADStudio { table.set_cell_text(r, c, cell); } } - self.push_undo_snapshot(i, "DATALINK"); - self.tabs[i] - .scene - .add_entity_clone(acadrust::EntityType::Table(table)); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "DATALINK: imported {nrows}×{ncols} cells into a table at the origin." - ).as_ref()); + let doc = &self.tabs[i].scene.document; + let current_style = doc.header.current_table_style_name.clone(); + table.table_style_handle = doc.objects.iter().find_map(|(handle, object)| { + match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(¤t_style) => + { + Some(*handle) + } + _ => None, + } + }); + let link_path = std::fs::canonicalize(path) + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string()); + let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::new( + table, &link_path, + ); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); } Err(e) => { self.command_line @@ -1219,6 +1494,151 @@ impl OpenCADStudio { } } + cmd if cmd == "DATALINKUPDATE" || cmd.starts_with("DATALINKUPDATE ") => { + let write_back = cmd + .trim_start_matches("DATALINKUPDATE") + .trim() + .eq_ignore_ascii_case("WRITE"); + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .filter_map(|(handle, entity)| { + matches!(entity, acadrust::EntityType::Table(_)).then_some(handle) + }) + .collect(); + let table_handles = if selected.is_empty() { + self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Table(_)) + .then_some(entity.common().handle) + }) + .collect::>() + } else { + selected + }; + let mut jobs = Vec::new(); + for handle in &table_handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity(*handle) + else { + continue; + }; + let link_handle = table + .rows + .iter() + .flat_map(|row| row.cells.iter()) + .find_map(|cell| cell.data_link_handle); + let Some(link_handle) = link_handle else { + continue; + }; + let path = match self.tabs[i].scene.document.objects.get(&link_handle) { + Some(acadrust::objects::ObjectType::ClassObject(object)) => { + match &object.data { + acadrust::objects::ClassObjectData::DataLink(link) => { + Some(link.connection_string.clone()) + } + _ => None, + } + } + _ => None, + }; + if let Some(path) = path { + jobs.push((*handle, link_handle, path)); + } + } + if jobs.is_empty() { + self.command_line + .push_error("DATALINKUPDATE: no linked tables found."); + return Some(Task::none()); + } + if write_back { + let mut written = 0usize; + for (table_handle, _, path) in &jobs { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity(*table_handle) + else { + continue; + }; + let csv = table_to_csv(table); + if std::fs::write(path, csv).is_ok() { + written += 1; + } + } + self.command_line.push_output( + crate::tf!("DATALINKUPDATE: wrote {} linked source(s).", written).as_ref(), + ); + return Some(Task::none()); + } + let updates: Vec<_> = jobs + .into_iter() + .filter_map(|(table_handle, link_handle, path)| { + std::fs::read_to_string(&path) + .ok() + .map(|text| (table_handle, link_handle, parse_csv_table(&text))) + }) + .filter(|(_, _, rows)| !rows.is_empty()) + .collect(); + if updates.is_empty() { + self.command_line + .push_error("DATALINKUPDATE: linked sources could not be read."); + return Some(Task::none()); + } + self.push_undo_snapshot(i, "DATALINKUPDATE"); + use acadrust::entities::table::CellStateFlags; + let mut changed_handles = Vec::new(); + for (table_handle, link_handle, rows) in updates { + let columns = rows.iter().map(Vec::len).max().unwrap_or(0); + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(table_handle) + else { + continue; + }; + while table.row_count() < rows.len() { + table.add_row(); + } + while table.row_count() > rows.len() { + table.remove_row(table.row_count() - 1); + } + while table.column_count() < columns { + let width = table.columns.last().map(|column| column.width).unwrap_or(2.0); + table.add_column(width); + } + while table.column_count() > columns { + table.remove_column(table.column_count() - 1); + } + for row in 0..rows.len() { + for column in 0..columns { + if let Some(cell) = table.cell_mut(row, column) { + cell.set_text( + rows[row].get(column).map(String::as_str).unwrap_or(""), + ); + cell.has_linked_data = true; + cell.data_link_handle = Some(link_handle); + cell.data_link_rows = rows.len() as i32; + cell.data_link_columns = columns as i32; + cell.state.insert( + CellStateFlags::LINKED + | CellStateFlags::CONTENT_LOCKED + | CellStateFlags::FORMAT_LOCKED, + ); + } + } + } + changed_handles.push(table_handle); + } + self.invalidate_property_targets(i, &changed_handles); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!("DATALINKUPDATE: updated {} linked table(s).", changed_handles.len()) + .as_ref(), + ); + } + // LANDXMLIMPORT — import survey points (LandXML // elements) as Point objects. Reads the coordinate text content // (northing easting elevation) → Point at (easting, northing, elev). @@ -1313,6 +1733,68 @@ fn parse_landxml_cgpoints(xml: &str) -> Vec<[f64; 3]> { out } +fn parse_csv_table(text: &str) -> Vec> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '"' if quoted && chars.peek() == Some(&'"') => { + field.push('"'); + chars.next(); + } + '"' => quoted = !quoted, + ',' if !quoted => row.push(std::mem::take(&mut field)), + '\n' if !quoted => { + if field.ends_with('\r') { + field.pop(); + } + row.push(std::mem::take(&mut field)); + if row.iter().any(|value| !value.is_empty()) { + rows.push(std::mem::take(&mut row)); + } else { + row.clear(); + } + } + _ => field.push(ch), + } + } + if field.ends_with('\r') { + field.pop(); + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + if row.iter().any(|value| !value.is_empty()) { + rows.push(row); + } + } + rows +} + +fn table_to_csv(table: &acadrust::entities::Table) -> String { + fn escape(value: &str) -> String { + if value.contains([',', '"', '\r', '\n']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } + } + table + .rows + .iter() + .map(|row| { + row.cells + .iter() + .map(|cell| escape(cell.text_value())) + .collect::>() + .join(",") + }) + .collect::>() + .join("\r\n") +} + #[cfg(test)] mod tests { use crate::app::OpenCADStudio; diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 63bc9f7b..5e929c3e 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -390,6 +390,7 @@ inventory::submit!(crate::command::CommandRegistration { "OBJECTSCALE", // Import CSV into a table + LandXML survey points. "DATALINK", + "DATALINKUPDATE", "LANDXMLIMPORT", // Keyboard-shortcut (CUI) export / import. "CUIEXPORT", diff --git a/src/app/properties.rs b/src/app/properties.rs index 9833701d..e4e639c2 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -128,6 +128,9 @@ impl OpenCADStudio { crate::entities::spline::control_vertex_count(spline) }) } + acadrust::EntityType::Table(table) => { + Some(table.row_count().saturating_mul(table.column_count())) + } _ => None, }); vertex_count.map_or(prop_vertex, |count| prop_vertex.min(count.saturating_sub(1))) @@ -140,6 +143,8 @@ impl OpenCADStudio { false }; crate::scene::view::dispatch::set_prop_current_vertex(prop_vertex); + crate::entities::table::set_prop_current_cell(prop_vertex); + crate::entities::table::set_prop_current_cell_active(prop_vertex_indicator_active); let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let new_panel = { @@ -1350,6 +1355,354 @@ impl OpenCADStudio { } } } + acadrust::EntityType::Table(table) => { + use acadrust::entities::table::{ + CellEdgeFlags, CellStylePropertyFlags, CellValueType, + }; + + let mut names: Vec = doc + .objects + .values() + .filter_map(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) + if !style.name.trim().is_empty() => + { + Some(style.name.clone()) + } + _ => None, + }) + .collect(); + names.sort_by_key(|name| name.to_ascii_lowercase()); + names.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + let selected_style = table + .table_style_handle + .and_then(|handle| doc.objects.get(&handle)) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style) + } + _ => None, + }); + let selected = selected_style + .map(|style| style.name.clone()) + .unwrap_or_else(|| "Standard".to_string()); + if !names.iter().any(|name| name.eq_ignore_ascii_case(&selected)) { + names.insert(0, selected.clone()); + } + set_row_value( + &mut sections, + "tbl_style_handle", + crate::scene::model::object::PropValue::Choice { + selected, + options: names, + }, + ); + + let title_suppressed = + crate::entities::table::resolved_title_suppressed( + table, + selected_style, + ); + let header_suppressed = + crate::entities::table::resolved_header_suppressed( + table, + selected_style, + ); + let flow_up = crate::entities::table::resolved_flow_up( + table, + selected_style, + ); + let (horizontal_margin, vertical_margin) = + crate::entities::table::resolved_table_margins( + table, + selected_style, + ); + update_row_toggle( + &mut sections, + "tbl_title_suppressed", + title_suppressed, + ); + update_row_toggle( + &mut sections, + "tbl_header_suppressed", + header_suppressed, + ); + update_row_text( + &mut sections, + "tbl_flow_direction", + if flow_up { "Up" } else { "Down" }.to_string(), + ); + update_row_text( + &mut sections, + "tbl_horizontal_margin", + crate::entities::common::format_length(horizontal_margin), + ); + update_row_text( + &mut sections, + "tbl_vertical_margin", + crate::entities::common::format_length(vertical_margin), + ); + + let columns = table.column_count(); + if columns > 0 { + let cell_index = prop_vertex.min( + table.row_count() + .saturating_mul(columns) + .saturating_sub(1), + ); + let row_index = cell_index / columns; + let column_index = cell_index % columns; + if let (Some(row), Some(cell)) = ( + table.rows.get(row_index), + table.cell(row_index, column_index), + ) { + let document_row_style = selected_style.map(|style| { + let kind = match ( + title_suppressed, + header_suppressed, + row_index, + ) { + (false, _, 0) => 0, + (false, false, 1) | (true, false, 0) => 1, + _ => 2, + }; + match kind { + 0 => &style.title_row_style, + 1 => &style.header_row_style, + _ => &style.data_row_style, + } + }); + let local = |property| { + crate::entities::table::style_for_property( + table, + row, + column_index, + cell, + property, + ) + }; + let alignment = local(CellStylePropertyFlags::ALIGNMENT) + .map(|style| style.alignment) + .or_else(|| { + document_row_style.map(|style| style.alignment as i32) + }) + .unwrap_or(5); + let alignment = match alignment { + 1 => "Top Left", + 2 => "Top Center", + 3 => "Top Right", + 4 => "Middle Left", + 6 => "Middle Right", + 7 => "Bottom Left", + 8 => "Bottom Center", + 9 => "Bottom Right", + _ => "Middle Center", + }; + update_row_text( + &mut sections, + "tbl_cell_alignment", + alignment.to_string(), + ); + + let text_style_name = local(CellStylePropertyFlags::TEXT_STYLE) + .map(|style| style.text_style_name.clone()) + .filter(|name| !name.is_empty()) + .or_else(|| { + document_row_style + .map(|style| style.text_style_name.clone()) + .filter(|name| !name.is_empty()) + }) + .unwrap_or_else(|| "Standard".to_string()); + update_row_text( + &mut sections, + "tbl_cell_text_style", + text_style_name, + ); + let text_height = local(CellStylePropertyFlags::TEXT_HEIGHT) + .map(|style| style.text_height) + .or_else(|| { + document_row_style.map(|style| style.text_height) + }) + .unwrap_or(0.18); + update_row_text( + &mut sections, + "tbl_cell_text_height", + crate::entities::common::format_length(text_height), + ); + let content_color = local( + CellStylePropertyFlags::CONTENT_COLOR, + ) + .map(|style| style.content_color) + .or_else(|| { + document_row_style.map(|style| style.text_color) + }) + .unwrap_or(acadrust::types::Color::ByBlock); + update_row_color( + &mut sections, + "tbl_cell_content_color", + content_color, + ); + let background_style = + local(CellStylePropertyFlags::BACKGROUND_COLOR); + let background_color = background_style + .map(|style| style.background_color) + .or_else(|| { + document_row_style.map(|style| style.fill_color) + }) + .unwrap_or(acadrust::types::Color::ByBlock); + update_row_color( + &mut sections, + "tbl_cell_background_color", + background_color, + ); + update_row_toggle( + &mut sections, + "tbl_cell_fill", + background_style + .map(|style| style.fill_enabled) + .or_else(|| { + document_row_style + .map(|style| style.fill_enabled) + }) + .unwrap_or(false), + ); + let format = local(CellStylePropertyFlags::DATA_FORMAT) + .map(|style| style.value_format.clone()) + .or_else(|| { + document_row_style + .map(|style| style.format_string.clone()) + }) + .unwrap_or_default(); + update_row_text( + &mut sections, + "tbl_cell_format", + format, + ); + let data_type = cell + .contents + .first() + .map(|content| content.value.value_type) + .filter(|kind| *kind != CellValueType::Unknown) + .or_else(|| { + local(CellStylePropertyFlags::DATA_TYPE) + .map(|style| { + CellValueType::from( + style.value_data_type.max(0) as u32, + ) + }) + }) + .or_else(|| { + document_row_style.map(|style| { + CellValueType::from(style.data_type.max(0) as u32) + }) + }) + .unwrap_or(CellValueType::String); + let data_type = match data_type { + CellValueType::Long => "Integer", + CellValueType::Double => "Decimal", + CellValueType::Date => "Date", + CellValueType::Point2D => "Point 2D", + CellValueType::Point3D => "Point 3D", + CellValueType::Handle => "Handle", + _ => "Text", + }; + update_row_text( + &mut sections, + "tbl_cell_data_type", + data_type.to_string(), + ); + for (field, property, fallback) in [ + ( + "tbl_cell_margin_left", + CellStylePropertyFlags::MARGIN_LEFT, + horizontal_margin, + ), + ( + "tbl_cell_margin_top", + CellStylePropertyFlags::MARGIN_TOP, + vertical_margin, + ), + ( + "tbl_cell_margin_right", + CellStylePropertyFlags::MARGIN_RIGHT, + horizontal_margin, + ), + ( + "tbl_cell_margin_bottom", + CellStylePropertyFlags::MARGIN_BOTTOM, + vertical_margin, + ), + ] { + let value = local(property) + .map(|style| match field { + "tbl_cell_margin_left" => style.margin_left, + "tbl_cell_margin_top" => style.margin_top, + "tbl_cell_margin_right" => style.margin_right, + _ => style.margin_bottom, + }) + .unwrap_or(fallback); + update_row_text( + &mut sections, + field, + crate::entities::common::format_length(value), + ); + } + + let border_visible = |edge: CellEdgeFlags| { + let local_style = [ + cell.style.as_ref(), + row.style.as_ref(), + table + .columns + .get(column_index) + .and_then(|column| column.style.as_ref()), + table.base_style.as_ref(), + ] + .into_iter() + .flatten() + .find(|style| { + style.applied_border_edges.contains(edge) + }); + if let Some(style) = local_style { + return if edge == CellEdgeFlags::TOP { + !style.top_border.invisible + } else if edge == CellEdgeFlags::RIGHT { + !style.right_border.invisible + } else if edge == CellEdgeFlags::BOTTOM { + !style.bottom_border.invisible + } else { + !style.left_border.invisible + }; + } + document_row_style + .map(|style| { + if edge == CellEdgeFlags::TOP { + !style.top_border.is_invisible + } else if edge == CellEdgeFlags::RIGHT { + !style.right_border.is_invisible + } else if edge == CellEdgeFlags::BOTTOM { + !style.bottom_border.is_invisible + } else { + !style.left_border.is_invisible + } + }) + .unwrap_or(true) + }; + for (field, edge) in [ + ("tbl_cell_border_top", CellEdgeFlags::TOP), + ("tbl_cell_border_right", CellEdgeFlags::RIGHT), + ("tbl_cell_border_bottom", CellEdgeFlags::BOTTOM), + ("tbl_cell_border_left", CellEdgeFlags::LEFT), + ] { + update_row_toggle( + &mut sections, + field, + border_visible(edge), + ); + } + } + } + } _ => {} } @@ -1939,6 +2292,51 @@ impl OpenCADStudio { mut entity: acadrust::EntityType, ) -> Option { let i = self.active_tab; + if let acadrust::EntityType::Table(table) = &mut entity { + const PREFIX: &str = "__OPENCAD_LINK_PENDING__"; + if let Some(path) = table.name.strip_prefix(PREFIX).map(str::to_string) { + use acadrust::entities::table::CellStateFlags; + use acadrust::objects::{ + ClassObject, ClassObjectData, DataLink, ObjectType, + }; + let document = &mut self.tabs[i].scene.document; + let link_handle = document.allocate_handle(); + let mut object = ClassObject::new(ClassObjectData::DataLink(DataLink { + data_adapter: "CSV".to_string(), + description: path.clone(), + tooltip: path.clone(), + connection_string: path.clone(), + status_flags: 1, + update_status: "Linked".to_string(), + ..DataLink::default() + })); + object.handle = link_handle; + document + .objects + .insert(link_handle, ObjectType::ClassObject(object)); + let rows = table.row_count() as i32; + let columns = table.column_count() as i32; + for row in &mut table.rows { + for cell in &mut row.cells { + cell.has_linked_data = true; + cell.data_link_handle = Some(link_handle); + cell.data_link_rows = rows; + cell.data_link_columns = columns; + cell.state.insert( + CellStateFlags::LINKED + | CellStateFlags::CONTENT_LOCKED + | CellStateFlags::FORMAT_LOCKED, + ); + } + } + table.name = std::path::Path::new(&path) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("Linked table") + .to_string(); + table.description = path; + } + } let tracks_draw_anchor = self.tabs[i].active_cmd.is_some() && matches!( &entity, @@ -2448,6 +2846,79 @@ fn set_row_value( } } +fn update_row_text( + sections: &mut [crate::scene::model::object::PropSection], + field: &str, + value: String, +) { + use crate::scene::model::object::PropValue; + for section in sections.iter_mut() { + let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + continue; + }; + match &mut row.value { + PropValue::ReadOnly(current) + | PropValue::EditText(current) + | PropValue::PlainText(current) => *current = value, + PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = value, + PropValue::Choice { selected, .. } => *selected = value, + _ => {} + } + return; + } +} + +fn update_row_toggle( + sections: &mut [crate::scene::model::object::PropSection], + field: &str, + value: bool, +) { + use crate::scene::model::object::PropValue; + for section in sections.iter_mut() { + let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + continue; + }; + match &mut row.value { + PropValue::BoolToggle { value: current, .. } => *current = value, + PropValue::ReadOnly(current) => { + *current = if value { t!("Yes") } else { t!("No") }.into_owned() + } + PropValue::ReadOnlyWithTooltip { value: current, .. } => { + *current = if value { t!("Yes") } else { t!("No") }.into_owned() + } + _ => {} + } + return; + } +} + +fn update_row_color( + sections: &mut [crate::scene::model::object::PropSection], + field: &str, + color: acadrust::types::Color, +) { + use crate::scene::model::object::PropValue; + let label = match color { + acadrust::types::Color::None => "None".to_string(), + acadrust::types::Color::ByLayer => "ByLayer".to_string(), + acadrust::types::Color::ByBlock => "ByBlock".to_string(), + acadrust::types::Color::Index(index) => index.to_string(), + acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), + }; + for section in sections.iter_mut() { + let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + continue; + }; + match &mut row.value { + PropValue::ColorChoice(current) => *current = color, + PropValue::ReadOnly(current) => *current = label, + PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = label, + _ => {} + } + return; + } +} + /// The lineweight dropdown options (named defaults + the standard millimetre /// steps), matching the labels `dim_lineweight_label` produces. pub(crate) fn lineweight_options() -> Vec { diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 4ec4c44f..7354f8d9 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2127,6 +2127,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].scene.bump_entities(&changes); } } + } else if field == "tbl_style_handle" { + let style_handle = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(value.trim()) => + { + Some(*handle) + } + _ => None, + }); + if let Some(style_handle) = style_handle { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.table_style_handle = Some(style_handle); + } + } + } } else if field == "transparency" { for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { @@ -2266,6 +2289,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + if field.starts_with("tbl_") { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.block_record_handle = None; + } + } + } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; self.tabs[i].properties.edit_choice_open = false; @@ -2382,7 +2414,30 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return Task::none(); } self.push_undo_snapshot(i, "CHPROP"); - if field == "block" { + if field == "tbl_style_handle" { + let style_handle = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(val.trim()) => + { + Some(*handle) + } + _ => None, + }); + if let Some(style_handle) = style_handle { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.table_style_handle = Some(style_handle); + } + } + } + } else if field == "block" { // Name row on a block reference: an existing name // re-points the selected inserts; a new one renames // the definition they share. Commit closes the list. @@ -2563,6 +2618,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + if field.starts_with("tbl_") { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.block_record_handle = None; + } + } + } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; self.refresh_properties(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 24bb99c5..6fbd26db 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4651,6 +4651,47 @@ impl OpenCADStudio { } } } + "tbl_title_suppressed" | "tbl_header_suppressed" => { + let next = { + let document = &self.tabs[i].scene.document; + let Some(acadrust::EntityType::Table(table)) = + document.get_entity(handle) + else { + continue; + }; + let table_style = table.table_style_handle.and_then(|style_handle| { + document.objects.get(&style_handle).and_then(|object| { + match object { + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style) + } + _ => None, + } + }) + }); + let current = if field == "tbl_title_suppressed" { + crate::entities::table::resolved_title_suppressed( + table, + table_style, + ) + } else { + crate::entities::table::resolved_header_suppressed( + table, + table_style, + ) + }; + !current + }; + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + crate::scene::view::dispatch::apply_geom_prop( + entity, + field, + if next { "true" } else { "false" }, + ); + } + } _ => { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) @@ -4662,6 +4703,15 @@ impl OpenCADStudio { } } } + if field.starts_with("tbl_") { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.block_record_handle = None; + } + } + } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; self.refresh_properties(); @@ -4677,6 +4727,9 @@ impl OpenCADStudio { match self.tabs[i].scene.document.get_entity(handles[0]) { Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(), Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), + Some(acadrust::EntityType::Table(table)) => { + table.row_count().saturating_mul(table.column_count()) + } _ => 0, } } else { @@ -4688,6 +4741,7 @@ impl OpenCADStudio { let next = (cur + delta as i64).rem_euclid(n as i64) as usize; self.tabs[i].properties.prop_vertex = next; self.tabs[i].properties.prop_vertex_indicator_active = next != cur as usize; + crate::entities::table::set_prop_current_cell(next); self.refresh_properties(); } Task::none() @@ -4894,6 +4948,57 @@ impl OpenCADStudio { } return Task::none(); } + if matches!( + field.as_str(), + "tbl_cell_content_color" | "tbl_cell_background_color" + ) { + let cell_index = self.tabs[i].properties.prop_vertex; + if !handles.is_empty() { + self.push_undo_snapshot(i, "TABLE CELL COLOR"); + for &handle in &handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + else { + continue; + }; + let columns = table.column_count(); + if columns == 0 { + continue; + } + if let Some(cell) = table.cell_mut( + cell_index / columns, + cell_index % columns, + ) { + use acadrust::entities::table::CellStateFlags; + if cell.state.intersects( + CellStateFlags::FORMAT_LOCKED + | CellStateFlags::FORMAT_READ_ONLY, + ) { + continue; + } + let style = cell.style.get_or_insert_with(Default::default); + if field == "tbl_cell_content_color" { + style.content_color = color; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::CONTENT_COLOR, + ); + } else { + style.background_color = color; + style.fill_enabled = true; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR, + ); + } + } + table.block_record_handle = None; + } + self.invalidate_property_targets(i, &handles); + self.tabs[i].properties.open_color_field = None; + self.tabs[i].dirty = true; + self.refresh_properties(); + } + return Task::none(); + } if !handles.is_empty() { self.push_undo_snapshot(i, "CHPROP"); let idx = if field == "gradient_color_2" { 1 } else { 0 }; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 338b0557..da0615fe 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4392,6 +4392,104 @@ impl OpenCADStudio { ).as_ref()); return Task::none(); } + let table_editor = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + AcadEntityType::Table(table) => { + let horizontal = glam::DVec3::new( + table.horizontal_direction.x, + table.horizontal_direction.y, + table.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new( + table.normal.x, + table.normal.y, + table.normal.z, + ) + .normalize_or(glam::DVec3::Z); + let mut down = horizontal + .cross(normal) + .normalize_or(glam::DVec3::NEG_Y); + let table_style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style) + } + _ => None, + }) + }); + let flows_up = crate::entities::table::resolved_flow_up( + table, + table_style, + ); + if flows_up { + down = -down; + } + let origin = glam::DVec3::new( + table.insertion_point.x, + table.insertion_point.y, + table.insertion_point.z, + ); + let relative = click_world - origin; + let x = relative.dot(horizontal); + let y = relative.dot(down); + if x < 0.0 || y < 0.0 { + return None; + } + let column = table + .columns + .iter() + .scan(0.0, |offset, column| { + *offset += column.width; + Some(*offset) + }) + .position(|end| x <= end)?; + let row = table + .rows + .iter() + .scan(0.0, |offset, row| { + *offset += row.height; + Some(*offset) + }) + .position(|end| y <= end)?; + let locked = table.cell(row, column).is_some_and(|cell| { + use acadrust::entities::table::CellStateFlags; + cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) + }); + Some(( + (!locked).then(|| { + crate::modules::annotate::table_cmd::TableCellEditCommand::new( + handle, table, row, column, + ) + }), + row * table.column_count() + column, + )) + } + _ => None, + }); + if let Some((command, cell_index)) = table_editor { + self.tabs[i].properties.prop_vertex = cell_index; + self.tabs[i].properties.prop_vertex_indicator_active = true; + crate::entities::table::set_prop_current_cell(cell_index); + self.refresh_properties(); + if let Some(command) = command { + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + return Task::none(); + } // Any text-bearing entity opens its in-place editor // (plain box or rich MText editor, per type). A // Leader resolves to the entity it annotates. diff --git a/src/entities/table.rs b/src/entities/table.rs index 498f428b..6660c8c6 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -13,6 +13,27 @@ use crate::scene::view::transform; use crate::scene::model::wire_model::SnapHint; use crate::t; +thread_local! { + static PROPERTY_CELL: std::cell::Cell = const { std::cell::Cell::new(0) }; + static PROPERTY_CELL_ACTIVE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +pub fn set_prop_current_cell(index: usize) { + PROPERTY_CELL.with(|cell| cell.set(index)); +} + +pub fn set_prop_current_cell_active(active: bool) { + PROPERTY_CELL_ACTIVE.with(|cell| cell.set(active)); +} + +fn prop_current_cell() -> usize { + PROPERTY_CELL.with(std::cell::Cell::get) +} + +fn prop_current_cell_active() -> bool { + PROPERTY_CELL_ACTIVE.with(std::cell::Cell::get) +} + fn v3(v: &acadrust::types::Vector3) -> Vec3 { Vec3::new(v.x as f32, v.y as f32, v.z as f32) } @@ -66,7 +87,7 @@ fn merged_owner_and_span( Some((row, column, row, column)) } -fn style_for_property<'a>( +pub(crate) fn style_for_property<'a>( table: &'a Table, row: &'a acadrust::entities::table::TableRow, column: usize, @@ -77,7 +98,12 @@ fn style_for_property<'a>( .columns .get(column) .and_then(|column| column.style.as_ref()); - for style in [cell.style.as_ref(), row.style.as_ref(), column_style] + for style in [ + cell.style.as_ref(), + row.style.as_ref(), + column_style, + table.base_style.as_ref(), + ] .into_iter() .flatten() { @@ -85,12 +111,99 @@ fn style_for_property<'a>( return Some(style); } } - table.base_style.as_ref().or_else(|| { - [cell.style.as_ref(), row.style.as_ref(), column_style] - .into_iter() - .flatten() - .next() - }) + None +} + +fn style_for_border<'a>( + table: &'a Table, + row: &'a acadrust::entities::table::TableRow, + column: usize, + cell: &'a acadrust::entities::table::TableCell, + edge: acadrust::entities::table::CellEdgeFlags, +) -> Option<&'a acadrust::entities::table::CellStyle> { + let column_style = table + .columns + .get(column) + .and_then(|column| column.style.as_ref()); + [ + cell.style.as_ref(), + row.style.as_ref(), + column_style, + table.base_style.as_ref(), + ] + .into_iter() + .flatten() + .find(|style| style.applied_border_edges.contains(edge)) +} + +pub(crate) fn resolved_title_suppressed( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> bool { + table + .legacy_style_override + .as_ref() + .and_then(|style| style.title_suppressed) + .or_else(|| table_style.map(|style| style.title_suppressed)) + .unwrap_or(false) +} + +pub(crate) fn resolved_header_suppressed( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> bool { + table + .legacy_style_override + .as_ref() + .and_then(|style| style.header_suppressed) + .or_else(|| table_style.map(|style| style.header_suppressed)) + .unwrap_or(false) +} + +pub(crate) fn resolved_flow_up( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> bool { + use acadrust::entities::table::CellStylePropertyFlags; + + table + .legacy_style_override + .as_ref() + .and_then(|style| style.flow_direction) + .map(|flow| flow != 0) + .or_else(|| { + table.base_style.as_ref().and_then(|style| { + style + .property_flags + .contains(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP) + .then_some(true) + }) + }) + .unwrap_or_else(|| { + matches!( + table_style.map(|style| style.flow_direction), + Some(acadrust::objects::TableFlowDirection::Up) + ) + }) +} + +pub(crate) fn resolved_table_margins( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> (f64, f64) { + let horizontal = table + .legacy_style_override + .as_ref() + .and_then(|style| style.horizontal_cell_margin) + .or_else(|| table_style.map(|style| style.horizontal_margin)) + .unwrap_or(0.0); + let vertical = table + .legacy_style_override + .as_ref() + .and_then(|style| style.vertical_cell_margin) + .or_else(|| table_style.map(|style| style.vertical_margin)) + .unwrap_or(0.0); + (horizontal, vertical) } fn table_offsets(table: &Table, scale: f32) -> (Vec, Vec) { @@ -108,40 +221,68 @@ fn table_offsets(table: &Table, scale: f32) -> (Vec, Vec) { (columns, rows) } -fn break_frame_for_row( +#[derive(Clone, Copy)] +struct TableBreakSegment { + start_row: usize, + end_row: usize, + origin: Vec3, +} + +fn table_break_segments( table: &Table, - row: usize, h: Vec3, down: Vec3, row_offsets: &[f32], scale: f32, -) -> (Vec3, f32) { +) -> Vec { use acadrust::entities::table::BreakOptionFlags; let insertion = v3(&table.insertion_point); - let offset_to_world = - |offset: &acadrust::types::Vector3| insertion + v3(offset); + if table.rows.is_empty() { + return Vec::new(); + } if !table.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) { - return (insertion, row_offsets.get(row).copied().unwrap_or(0.0)); + return vec![TableBreakSegment { + start_row: 0, + end_row: table.rows.len() - 1, + origin: insertion, + }]; } - if let Some(range) = table + let offset_to_world = |offset: &acadrust::types::Vector3| insertion + v3(offset); + let mut cached: Vec<_> = table .break_ranges .iter() - .find(|range| row as i32 >= range.start_row && row as i32 <= range.end_row) - { - let start = range.start_row.max(0) as usize; - let position = offset_to_world(&range.position); - let origin = if position.is_finite() { - position - } else { - insertion - }; - let top = row_offsets.get(row).copied().unwrap_or(0.0) - - row_offsets.get(start).copied().unwrap_or(0.0); - return (origin, top); + .filter_map(|range| { + let start = range.start_row.max(0) as usize; + let end = range.end_row.max(0) as usize; + (start <= end && end < table.rows.len()).then(|| TableBreakSegment { + start_row: start, + end_row: end, + origin: { + let position = offset_to_world(&range.position); + if position.is_finite() { + position + } else { + insertion + } + }, + }) + }) + .collect(); + cached.sort_by_key(|segment| segment.start_row); + let cached_complete = cached.first().is_some_and(|segment| segment.start_row == 0) + && cached + .last() + .is_some_and(|segment| segment.end_row + 1 == table.rows.len()) + && cached + .windows(2) + .all(|pair| pair[0].end_row + 1 == pair[1].start_row); + if cached_complete { + return cached; } + let mut segments = Vec::new(); let mut start_row = 0usize; let mut segment = 0usize; while start_row < table.rows.len() { @@ -161,48 +302,154 @@ fn break_frame_for_row( { end_row += 1; } - if row <= end_row { - let manual_positions = table - .break_options - .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS); - let manual_origin = manual_positions - .then(|| table.break_data.get(segment)) - .flatten() - .map(|data| offset_to_world(&data.position)) - .filter(|position| position.is_finite()); - let origin = manual_origin.unwrap_or_else(|| { - let spacing = table.break_spacing as f32 * scale; - let horizontal_step = - table.total_width() as f32 * scale + spacing; - let vertical_step = if max_height.is_finite() { - max_height + spacing - } else { - table.total_height() as f32 * scale + spacing - }; - match table.break_flow_direction { - acadrust::entities::table::BreakFlowDirection::Left => { - insertion - h * segment as f32 * horizontal_step - } - acadrust::entities::table::BreakFlowDirection::Vertical => { - insertion + down * segment as f32 * vertical_step - } - acadrust::entities::table::BreakFlowDirection::Right => { - insertion + h * segment as f32 * horizontal_step - } + let manual_positions = table + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS); + let manual_origin = manual_positions + .then(|| table.break_data.get(segment)) + .flatten() + .map(|data| offset_to_world(&data.position)) + .filter(|position| position.is_finite()); + let origin = manual_origin.unwrap_or_else(|| { + let spacing = table.break_spacing as f32 * scale; + let horizontal_step = table.total_width() as f32 * scale + spacing; + let vertical_step = if max_height.is_finite() { + max_height + spacing + } else { + table.total_height() as f32 * scale + spacing + }; + match table.break_flow_direction { + acadrust::entities::table::BreakFlowDirection::Left => { + insertion - h * segment as f32 * horizontal_step } - }); - return ( - origin, - row_offsets.get(row).copied().unwrap_or(0.0) - start_offset, - ); - } + acadrust::entities::table::BreakFlowDirection::Vertical => { + insertion + down * segment as f32 * vertical_step + } + acadrust::entities::table::BreakFlowDirection::Right => { + insertion + h * segment as f32 * horizontal_step + } + } + }); + segments.push(TableBreakSegment { + start_row, + end_row, + origin, + }); start_row = end_row.saturating_add(1); segment = segment.saturating_add(1); } + segments +} + +fn break_frame_for_row( + table: &Table, + row: usize, + h: Vec3, + down: Vec3, + row_offsets: &[f32], + scale: f32, +) -> (Vec3, f32) { + let insertion = v3(&table.insertion_point); + if let Some(segment) = table_break_segments(table, h, down, row_offsets, scale) + .into_iter() + .find(|segment| row >= segment.start_row && row <= segment.end_row) + { + return ( + segment.origin, + row_offsets.get(row).copied().unwrap_or(0.0) + - row_offsets + .get(segment.start_row) + .copied() + .unwrap_or(0.0), + ); + } + (insertion, row_offsets.get(row).copied().unwrap_or(0.0)) } +fn break_frames_for_row( + table: &Table, + row: usize, + h: Vec3, + down: Vec3, + row_offsets: &[f32], + scale: f32, + top_label_rows: usize, + bottom_label_rows: usize, +) -> Vec<(Vec3, f32)> { + use acadrust::entities::table::BreakOptionFlags; + + let segments = table_break_segments(table, h, down, row_offsets, scale); + let Some((segment_index, segment)) = segments + .iter() + .enumerate() + .find(|(_, segment)| row >= segment.start_row && row <= segment.end_row) + else { + return vec![break_frame_for_row(table, row, h, down, row_offsets, scale)]; + }; + let repeat_top = table + .break_options + .contains(BreakOptionFlags::REPEAT_TOP_LABELS) + && top_label_rows > 0; + let repeat_bottom = table + .break_options + .contains(BreakOptionFlags::REPEAT_BOTTOM_LABELS) + && bottom_label_rows > 0; + let top_label_rows = top_label_rows.min(table.rows.len()); + let bottom_label_rows = bottom_label_rows.min(table.rows.len()); + let bottom_start = table.rows.len().saturating_sub(bottom_label_rows); + let top_height = row_offsets + .get(top_label_rows) + .copied() + .unwrap_or(0.0); + let mut primary_top = row_offsets.get(row).copied().unwrap_or(0.0) + - row_offsets + .get(segment.start_row) + .copied() + .unwrap_or(0.0); + if repeat_top && segment_index > 0 { + primary_top += top_height; + } + let mut frames = vec![(segment.origin, primary_top)]; + + if repeat_top && row < top_label_rows { + for repeated_segment in segments.iter().skip(1) { + frames.push(( + repeated_segment.origin, + row_offsets.get(row).copied().unwrap_or(0.0), + )); + } + } + if repeat_bottom && row >= bottom_start { + let label_offset = row_offsets.get(row).copied().unwrap_or(0.0) + - row_offsets.get(bottom_start).copied().unwrap_or(0.0); + for (index, repeated_segment) in segments + .iter() + .enumerate() + .take(segments.len().saturating_sub(1)) + { + let content_height = row_offsets + .get(repeated_segment.end_row + 1) + .copied() + .unwrap_or(0.0) + - row_offsets + .get(repeated_segment.start_row) + .copied() + .unwrap_or(0.0); + let repeated_top = content_height + + if repeat_top && index > 0 { + top_height + } else { + 0.0 + } + + label_offset; + frames.push((repeated_segment.origin, repeated_top)); + } + } + frames +} + fn format_cell_value(value: &acadrust::entities::table::CellValue) -> String { let display = value.display(); if !display.is_empty() { @@ -227,6 +474,71 @@ fn format_cell_value(value: &acadrust::entities::table::CellValue) -> String { } } +fn table_cell_reference(reference: &str) -> Option<(usize, usize)> { + let reference = reference.trim(); + let split = reference + .char_indices() + .find(|(_, ch)| ch.is_ascii_digit())? + .0; + let (letters, digits) = reference.split_at(split); + if letters.is_empty() || digits.is_empty() { + return None; + } + let mut column = 0usize; + for ch in letters.chars() { + if !ch.is_ascii_alphabetic() { + return None; + } + column = column + .checked_mul(26)? + .checked_add(ch.to_ascii_uppercase() as usize - 'A' as usize + 1)?; + } + let row = digits.parse::().ok()?.checked_sub(1)?; + Some((row, column.checked_sub(1)?)) +} + +fn evaluate_table_formula(table: &Table, expression: &str) -> Option { + let body = expression.trim().strip_prefix('=')?.trim(); + if let Some((row, column)) = table_cell_reference(body) { + return table.cell(row, column).map(|cell| cell.text_value().to_string()); + } + let open = body.find('(')?; + let close = body.rfind(')')?; + let function = body[..open].trim().to_ascii_uppercase(); + let range = &body[open + 1..close]; + let (first, last) = range.split_once(':').unwrap_or((range, range)); + let (r1, c1) = table_cell_reference(first)?; + let (r2, c2) = table_cell_reference(last)?; + let mut values = Vec::new(); + for row in r1.min(r2)..=r1.max(r2) { + for column in c1.min(c2)..=c1.max(c2) { + if let Some(value) = table + .cell(row, column) + .and_then(|cell| cell.text_value().trim().parse::().ok()) + { + values.push(value); + } + } + } + match function.as_str() { + "SUM" => Some(values.iter().sum::().to_string()), + "AVERAGE" | "AVG" if !values.is_empty() => { + Some((values.iter().sum::() / values.len() as f64).to_string()) + } + "COUNT" => Some(values.len().to_string()), + "MIN" if !values.is_empty() => { + Some(values.into_iter().fold(f64::INFINITY, f64::min).to_string()) + } + "MAX" if !values.is_empty() => Some( + values + .into_iter() + .fold(f64::NEG_INFINITY, f64::max) + .to_string(), + ), + _ => None, + } +} + fn fallback_content_centers( bounds: [f32; 4], sizes: &[(f32, f32)], @@ -312,12 +624,12 @@ fn fallback_content_centers( fn content_display_value( document: &acadrust::CadDocument, - table_handle: acadrust::Handle, + table: &Table, content: &acadrust::entities::table::CellContent, ) -> String { if let Some(handle) = content.field_handle { if let Some(value) = - crate::entities::field::resolve_handle(document, handle, table_handle) + crate::entities::field::resolve_handle(document, handle, table.common.handle) { return value; } @@ -331,7 +643,8 @@ fn content_display_value( } } } - format_cell_value(&content.value) + let value = format_cell_value(&content.value); + evaluate_table_formula(table, &value).unwrap_or(value) } fn resolved_content_geometry( @@ -406,10 +719,7 @@ pub(crate) fn block_cell_inserts( _ => None, }) }); - let flow = if matches!( - table_style.map(|style| style.flow_direction), - Some(acadrust::objects::TableFlowDirection::Up) - ) { + let flow = if resolved_flow_up(table, table_style) { -down } else { down @@ -837,8 +1147,8 @@ impl RenderConvertible for Table { _ => None, }) }); - let title_suppressed = table_style.map(|t| t.title_suppressed).unwrap_or(false); - let header_suppressed = table_style.map(|t| t.header_suppressed).unwrap_or(false); + let title_suppressed = resolved_title_suppressed(self, table_style); + let header_suppressed = resolved_header_suppressed(self, table_style); let font_for_handle = |handle: Option| -> Option { handle.and_then(|h| lookup_style(h)).and_then(|s| { @@ -1037,13 +1347,7 @@ impl RenderConvertible for Table { } } -/// Coloured synthesis render for tables WITHOUT a baked block (e.g. tables -/// created in-app). Emits one `WireModel` per distinct colour for cell fills, -/// per-cell text, and grid borders, honouring every `TableStyle` / -/// `RowCellStyle` / per-cell `CellStyle` field: fill colour + enable, text -/// colour, border type/weight/colour/visibility (incl. inside borders), -/// margins, and flow direction. Imported tables keep using AutoCAD's baked -/// block (see scene/mod.rs). +/// Builds colored geometry for tables without a stored display block. pub fn tessellate_table( tab: &Table, document: &acadrust::CadDocument, @@ -1101,20 +1405,19 @@ pub fn tessellate_table( _ => None, }) }); - let flow_up = matches!( - table_style.map(|t| t.flow_direction), - Some(acadrust::objects::TableFlowDirection::Up) - ); + let flow_up = resolved_flow_up(tab, table_style); let v_flow = if flow_up { -v_down } else { v_down }; let (col_offsets, row_offsets) = table_offsets(tab, anno_scale); - let title_suppressed = table_style.map(|t| t.title_suppressed).unwrap_or(false); - let header_suppressed = table_style.map(|t| t.header_suppressed).unwrap_or(false); - let h_margin = table_style - .map(|t| t.horizontal_margin as f32) - .unwrap_or(0.0) * anno_scale; - let v_margin = table_style.map(|t| t.vertical_margin as f32).unwrap_or(0.0) * anno_scale; + let title_suppressed = resolved_title_suppressed(tab, table_style); + let header_suppressed = resolved_header_suppressed(tab, table_style); + let top_label_rows = (usize::from(!title_suppressed) + usize::from(!header_suppressed)) + .min(tab.rows.len()); + let bottom_label_rows = usize::from(!tab.rows.is_empty()); + let (horizontal_margin, vertical_margin) = resolved_table_margins(tab, table_style); + let h_margin = horizontal_margin as f32 * anno_scale; + let v_margin = vertical_margin as f32 * anno_scale; let lookup_style = |hh: acadrust::Handle| -> Option<&acadrust::tables::TextStyle> { document.text_styles.iter().find(|s| s.handle == hh) @@ -1209,8 +1512,17 @@ pub fn tessellate_table( else { continue; }; - let (origin, row_top) = - break_frame_for_row(tab, ri, h, v_flow, &row_offsets, anno_scale); + let frames = break_frames_for_row( + tab, + ri, + h, + v_flow, + &row_offsets, + anno_scale, + top_label_rows, + bottom_label_rows, + ); + for (origin, row_top) in frames { let merged_height = row_offsets .get(row_end + 1) .copied() @@ -1228,13 +1540,6 @@ pub fn tessellate_table( let tr = origin + h * col_right + v_flow * row_top; let br_ = origin + h * col_right + v_flow * row_bot; let bl = origin + h * col_left + v_flow * row_bot; - let cell_style = cell - .style - .as_ref() - .or(row.style.as_ref()) - .or_else(|| tab.columns.get(ci).and_then(|column| column.style.as_ref())) - .or(tab.base_style.as_ref()); - // ── Fill ────────────────────────────────────────────────────── let fill_style = style_for_property( tab, @@ -1264,7 +1569,13 @@ pub fn tessellate_table( // ── Borders (per edge: cell override → row style → default) ─── // (top, right, bottom, left) let edge = |which: u8| -> (bool, [f32; 4], f32) { - if let Some(cs) = cell_style { + let edge_flag = match which { + 0 => acadrust::entities::table::CellEdgeFlags::TOP, + 1 => acadrust::entities::table::CellEdgeFlags::RIGHT, + 2 => acadrust::entities::table::CellEdgeFlags::BOTTOM, + _ => acadrust::entities::table::CellEdgeFlags::LEFT, + }; + if let Some(cs) = style_for_border(tab, row, ci, cell, edge_flag) { let b = match which { 0 => &cs.top_border, 1 => &cs.right_border, @@ -1326,8 +1637,7 @@ pub fn tessellate_table( .iter() .enumerate() .filter_map(|(index, content)| { - let text = - content_display_value(document, tab.common.handle, content); + let text = content_display_value(document, tab, content); (!text.is_empty()).then_some((index, content, text)) }) .collect(); @@ -1660,6 +1970,7 @@ pub fn tessellate_table( } } } + } } } @@ -1741,173 +2052,756 @@ pub fn tessellate_table( impl Grippable for Table { fn grips(&self) -> Vec { - vec![square_grip( + let origin = glam::DVec3::new( + self.insertion_point.x, + self.insertion_point.y, + self.insertion_point.z, + ); + let horizontal = glam::DVec3::new( + self.horizontal_direction.x, + self.horizontal_direction.y, + self.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z) + .normalize_or(glam::DVec3::Z); + let down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y); + let width = self.total_width(); + let height = self.total_height(); + let mut grips = vec![square_grip( 0, - glam::DVec3::new( - self.insertion_point.x, - self.insertion_point.y, - self.insertion_point.z, - ), - )] + origin, + )]; + grips.push(square_grip(1, origin + horizontal * width)); + grips.push(square_grip(2, origin + down * height)); + let mut offset = 0.0; + for (column, definition) in self.columns.iter().enumerate() { + offset += definition.width; + if column + 1 < self.columns.len() { + grips.push(square_grip( + 100 + column, + origin + horizontal * offset + down * (height * 0.5), + )); + } + } + offset = 0.0; + for (row, definition) in self.rows.iter().enumerate() { + offset += definition.height; + if row + 1 < self.rows.len() { + grips.push(square_grip( + 1000 + row, + origin + horizontal * (width * 0.5) + down * offset, + )); + } + } + for (index, data) in self.break_data.iter().enumerate() { + grips.push(square_grip( + 2000 + index, + origin + glam::DVec3::new(data.position.x, data.position.y, data.position.z), + )); + } + grips } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + if let GripApply::Translate(delta) = apply { + if let Some(grip) = self.grips().into_iter().find(|grip| grip.id == grip_id) { + self.apply_grip(grip_id, GripApply::Absolute(grip.world + delta)); + } + return; + } + let GripApply::Absolute(point) = apply else { + return; + }; if grip_id == 0 { - match apply { - GripApply::Translate(d) => { - self.insertion_point.x += d.x as f64; - self.insertion_point.y += d.y as f64; - self.insertion_point.z += d.z as f64; + self.insertion_point.x = point.x; + self.insertion_point.y = point.y; + self.insertion_point.z = point.z; + return; + } + let origin = glam::DVec3::new( + self.insertion_point.x, + self.insertion_point.y, + self.insertion_point.z, + ); + let horizontal = glam::DVec3::new( + self.horizontal_direction.x, + self.horizontal_direction.y, + self.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z) + .normalize_or(glam::DVec3::Z); + let down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y); + if grip_id == 1 { + let width = (point - origin).dot(horizontal).max(1.0e-6); + let current = self.total_width(); + if current > 1.0e-12 { + for column in &mut self.columns { + column.width *= width / current; } - GripApply::Absolute(p) => { - self.insertion_point.x = p.x as f64; - self.insertion_point.y = p.y as f64; - self.insertion_point.z = p.z as f64; + } + } else if grip_id == 2 { + let height = (point - origin).dot(down).max(1.0e-6); + let current = self.total_height(); + if current > 1.0e-12 { + for row in &mut self.rows { + row.height *= height / current; } } + } else if (100..1000).contains(&grip_id) { + let column = grip_id - 100; + let before: f64 = self.columns.iter().take(column).map(|item| item.width).sum(); + if let Some(definition) = self.columns.get_mut(column) { + definition.width = ((point - origin).dot(horizontal) - before).max(1.0e-6); + } + } else if (1000..2000).contains(&grip_id) { + let row = grip_id - 1000; + let before: f64 = self.rows.iter().take(row).map(|item| item.height).sum(); + if let Some(definition) = self.rows.get_mut(row) { + definition.height = ((point - origin).dot(down) - before).max(1.0e-6); + } + } else if grip_id >= 2000 { + if let Some(data) = self.break_data.get_mut(grip_id - 2000) { + let offset = point - origin; + data.position = acadrust::types::Vector3::new(offset.x, offset.y, offset.z); + } } } } impl PropertyEditable for Table { - fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { + fn geometry_properties(&self, text_style_names: &[String]) -> Vec { use crate::entities::common::edit_prop as edit; - use acadrust::entities::table::BreakOptionFlags; - - let fmt_h = |oh: &Option| -> String { - match oh { - Some(h) if !h.is_null() => format!("{:X}", h.value()), - _ => "(none)".to_string(), - } - }; - let toggle = |label: &str, field: &'static str, b: bool| -> Property { + use acadrust::entities::table::{BreakOptionFlags, CellStateFlags, CellStylePropertyFlags}; + let bool_text = |value: bool| if value { t!("Yes") } else { t!("No") }.into_owned(); + let toggle = |label: &str, field: &'static str, value: bool, enabled: bool| -> Property { Property { label: label.into(), field, - value: PropValue::BoolToggle { field, value: b }, + value: if enabled { + PropValue::BoolToggle { field, value } + } else { + PropValue::ReadOnly(bool_text(value)) + }, + } + }; + let choice = |label: &str, + field: &'static str, + selected: String, + options: Vec, + enabled: bool| + -> Property { + Property { + label: label.into(), + field, + value: if enabled { + PropValue::Choice { selected, options } + } else { + PropValue::ReadOnly(selected) + }, + } + }; + let number = |label: &str, field: &'static str, value: f64, enabled: bool| -> Property { + if enabled { + edit(label, field, value) + } else { + ro(label, field, crate::entities::common::format_length(value)) + } + }; + let text = |label: &str, field: &'static str, value: String, enabled: bool| -> Property { + Property { + label: label.into(), + field, + value: if enabled { + PropValue::PlainText(value) + } else { + PropValue::ReadOnly(value) + }, + } + }; + let color_text = |color: acadrust::types::Color| match color { + acadrust::types::Color::None => "None".to_string(), + acadrust::types::Color::ByLayer => "ByLayer".to_string(), + acadrust::types::Color::ByBlock => "ByBlock".to_string(), + acadrust::types::Color::Index(index) => index.to_string(), + acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), + }; + let color = |label: &str, + field: &'static str, + value: acadrust::types::Color, + enabled: bool| + -> Property { + Property { + label: label.into(), + field, + value: if enabled { + PropValue::ColorChoice(value) + } else { + PropValue::ReadOnly(color_text(value)) + }, } }; // Direction = angle of the horizontal direction vector in the XY plane. let direction_deg = (self.horizontal_direction.y.atan2(self.horizontal_direction.x)).to_degrees(); - let mut content_count = 0usize; - let mut block_content_count = 0usize; - let mut field_content_count = 0usize; - let mut linked_cell_count = 0usize; - for row in &self.rows { - for cell in &row.cells { - content_count += cell.contents.len(); - linked_cell_count += usize::from(cell.has_linked_data); - for content in &cell.contents { - block_content_count += usize::from(content.block_handle.is_some()); - field_content_count += usize::from(content.field_handle.is_some()); + let break_height = self.break_data.first().map(|data| data.height).unwrap_or(0.0); + let breaks_enabled = self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS); + let manual_positions = self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS); + let manual_heights = self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS); + let cell_count = self.rows.len().saturating_mul(self.columns.len()); + let cell_index = prop_current_cell().min(cell_count.saturating_sub(1)); + let cell_row = if self.columns.is_empty() { + 0 + } else { + cell_index / self.columns.len() + }; + let cell_column = if self.columns.is_empty() { + 0 + } else { + cell_index % self.columns.len() + }; + let table_row = self.rows.get(cell_row); + let cell = self.cell(cell_row, cell_column); + let row_style = table_row.and_then(|row| row.style.as_ref()); + let column_style = self + .columns + .get(cell_column) + .and_then(|column| column.style.as_ref()); + let style_for = |property| { + table_row.and_then(|row| { + cell.and_then(|cell| { + style_for_property(self, row, cell_column, cell, property) + }) + }) + }; + let alignment_style = style_for(CellStylePropertyFlags::ALIGNMENT); + let alignment = match alignment_style.map(|style| style.alignment).unwrap_or(5) { + 1 => "Top Left", + 2 => "Top Center", + 3 => "Top Right", + 4 => "Middle Left", + 6 => "Middle Right", + 7 => "Bottom Left", + 8 => "Bottom Center", + 9 => "Bottom Right", + _ => "Middle Center", + }; + let state = cell.map(|cell| cell.state).unwrap_or_default(); + let content_editable = !state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY, + ); + let format_editable = !state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ); + let immutable_lock = state.intersects( + CellStateFlags::CONTENT_READ_ONLY | CellStateFlags::FORMAT_READ_ONLY, + ); + let cell_locked = state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY + | CellStateFlags::FORMAT_LOCKED + | CellStateFlags::FORMAT_READ_ONLY, + ); + let table_flow_up = self + .legacy_style_override + .as_ref() + .and_then(|style| style.flow_direction) + .map(|flow| flow != 0) + .unwrap_or_else(|| { + self.base_style.as_ref().is_some_and(|style| { + style + .property_flags + .contains(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP) + }) + }); + let legacy = self.legacy_style_override.as_ref(); + let title_suppressed = legacy + .and_then(|style| style.title_suppressed) + .unwrap_or(false); + let header_suppressed = legacy + .and_then(|style| style.header_suppressed) + .unwrap_or(false); + let horizontal_margin = legacy + .and_then(|style| style.horizontal_cell_margin) + .or_else(|| self.base_style.as_ref().map(|style| style.margin_left)) + .unwrap_or(0.06); + let vertical_margin = legacy + .and_then(|style| style.vertical_cell_margin) + .or_else(|| self.base_style.as_ref().map(|style| style.margin_top)) + .unwrap_or(0.06); + let uniform_column_width = self.columns.first().map(|column| column.width).unwrap_or(0.0); + let columns_uniform = self + .columns + .iter() + .all(|column| (column.width - uniform_column_width).abs() <= 1.0e-9); + let uniform_row_height = self.rows.first().map(|row| row.height).unwrap_or(0.0); + let rows_uniform = self + .rows + .iter() + .all(|row| (row.height - uniform_row_height).abs() <= 1.0e-9); + let uniform_number = |label: &str, + field: &'static str, + value: f64, + uniform: bool| + -> Property { + if uniform { + edit(label, field, value) + } else { + Property { + label: label.into(), + field, + value: PropValue::PlainText(t!("Varies").into_owned()), } } - } - let break_heights = self - .break_data + }; + let override_count = self + .rows .iter() - .map(|data| format!("{:.4}", data.height)) - .collect::>() - .join(", "); + .filter(|row| row.style.is_some()) + .count() + + self + .columns + .iter() + .filter(|column| column.style.is_some()) + .count() + + self + .rows + .iter() + .flat_map(|row| row.cells.iter()) + .filter(|cell| cell.style.is_some()) + .count() + + usize::from(self.base_style.is_some() || self.legacy_style_override.is_some()); - vec![ + let mut sections = vec![ PropSection { title: t!("Table").into_owned(), props: vec![ - ro( - t!("Table style").as_ref(), - "tbl_style_handle", - fmt_h(&self.table_style_handle), + ro(t!("Table style").as_ref(), "tbl_style_handle", "Standard"), + toggle( + t!("Title suppressed").as_ref(), + "tbl_title_suppressed", + title_suppressed, + true, ), - ro(t!("Rows").as_ref(), "tbl_rows", self.rows.len().to_string()), - ro(t!("Columns").as_ref(), "tbl_cols", self.columns.len().to_string()), - ro(t!("Contents").as_ref(), "tbl_contents", content_count.to_string()), - ro( - t!("Block contents").as_ref(), - "tbl_block_contents", - block_content_count.to_string(), + toggle( + t!("Header suppressed").as_ref(), + "tbl_header_suppressed", + header_suppressed, + true, + ), + choice( + t!("Flow direction").as_ref(), + "tbl_flow_direction", + if table_flow_up { "Up" } else { "Down" }.to_string(), + vec!["Down".into(), "Up".into()], + true, + ), + edit(t!("Direction").as_ref(), "tbl_direction", direction_deg), + edit(t!("Rows").as_ref(), "tbl_rows", self.rows.len() as f64), + edit(t!("Columns").as_ref(), "tbl_cols", self.columns.len() as f64), + uniform_number( + t!("Column width").as_ref(), + "tbl_column_width", + uniform_column_width, + columns_uniform, + ), + uniform_number( + t!("Row height").as_ref(), + "tbl_row_height", + uniform_row_height, + rows_uniform, + ), + edit(t!("Table width").as_ref(), "tbl_width", self.total_width()), + edit(t!("Table height").as_ref(), "tbl_height", self.total_height()), + edit( + t!("Horizontal cell margin").as_ref(), + "tbl_horizontal_margin", + horizontal_margin, + ), + edit( + t!("Vertical cell margin").as_ref(), + "tbl_vertical_margin", + vertical_margin, ), ro( - t!("Field contents").as_ref(), - "tbl_field_contents", - field_content_count.to_string(), - ), - ro( - t!("Merged ranges").as_ref(), - "tbl_merged_ranges", - self.merged_ranges.len().to_string(), - ), - ro( - t!("Linked cells").as_ref(), - "tbl_linked_cells", - linked_cell_count.to_string(), - ), - ro(t!("Direction").as_ref(), "tbl_direction", format!("{:.4}", direction_deg)), - ro( - t!("Table width").as_ref(), - "tbl_width", - format!("{:.4}", self.total_width()), - ), - ro( - t!("Table height").as_ref(), - "tbl_height", - format!("{:.4}", self.total_height()), + t!("Table overrides").as_ref(), + "tbl_overrides", + if override_count == 0 { + t!("None").into_owned() + } else { + override_count.to_string() + }, ), ], }, + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + edit(t!("Insertion X").as_ref(), "tbl_insert_x", self.insertion_point.x), + edit(t!("Insertion Y").as_ref(), "tbl_insert_y", self.insertion_point.y), + edit(t!("Insertion Z").as_ref(), "tbl_insert_z", self.insertion_point.z), + ro(t!("Normal X").as_ref(), "tbl_normal_x", format!("{:.4}", self.normal.x)), + ro(t!("Normal Y").as_ref(), "tbl_normal_y", format!("{:.4}", self.normal.y)), + ro(t!("Normal Z").as_ref(), "tbl_normal_z", format!("{:.4}", self.normal.z)), + ], + }, + ]; + + if prop_current_cell_active() { + let cell_type = match cell.map(|cell| cell.cell_type) { + Some(acadrust::entities::table::CellType::Block) => "Block", + _ => "Text", + }; + let data_type = cell + .and_then(|cell| cell.contents.first()) + .map(|content| match content.value.value_type { + acadrust::entities::table::CellValueType::Long => "Integer", + acadrust::entities::table::CellValueType::Double => "Decimal", + acadrust::entities::table::CellValueType::Date => "Date", + acadrust::entities::table::CellValueType::Point2D => "Point 2D", + acadrust::entities::table::CellValueType::Point3D => "Point 3D", + acadrust::entities::table::CellValueType::Handle => "Handle", + _ => "Text", + }) + .unwrap_or("Text"); + let style_source = if cell.and_then(|cell| cell.style.as_ref()).is_some() { + "Cell override" + } else if row_style.is_some() { + "Row override" + } else if column_style.is_some() { + "Column override" + } else { + "Table style" + }; + let text_style = style_for(CellStylePropertyFlags::TEXT_STYLE) + .map(|style| style.text_style_name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Standard".to_string()); + let mut available_text_styles = text_style_names.to_vec(); + if !available_text_styles + .iter() + .any(|name| name.eq_ignore_ascii_case(&text_style)) + { + available_text_styles.insert(0, text_style.clone()); + } + let rotation = style_for(CellStylePropertyFlags::ROTATION) + .map(|style| style.rotation) + .or_else(|| cell.map(|cell| cell.rotation)) + .unwrap_or(0.0) + .to_degrees(); + sections.push(PropSection { + title: t!("Cell").into_owned(), + props: vec![ + Property { + label: t!("Current cell").into_owned(), + field: "tbl_current_cell", + value: PropValue::Stepper { + field: "tbl_current_cell", + display: format!( + "{},{} ({} / {})", + cell_row, + cell_column, + cell_index.saturating_add(1), + cell_count + ), + }, + }, + ro(t!("Row").as_ref(), "tbl_cell_row", cell_row.to_string()), + ro( + t!("Column").as_ref(), + "tbl_cell_column", + cell_column.to_string(), + ), + ro(t!("Cell style").as_ref(), "tbl_cell_style", style_source), + choice( + t!("Cell type").as_ref(), + "tbl_cell_type", + cell_type.to_string(), + vec!["Text".into(), "Block".into()], + content_editable, + ), + text( + t!("Contents").as_ref(), + "tbl_cell_text", + cell.map(|cell| cell.text_value().to_string()).unwrap_or_default(), + content_editable, + ), + choice( + t!("Alignment").as_ref(), + "tbl_cell_alignment", + alignment.to_string(), + vec![ + "Top Left".into(), + "Top Center".into(), + "Top Right".into(), + "Middle Left".into(), + "Middle Center".into(), + "Middle Right".into(), + "Bottom Left".into(), + "Bottom Center".into(), + "Bottom Right".into(), + ], + format_editable, + ), + choice( + t!("Text style").as_ref(), + "tbl_cell_text_style", + text_style, + available_text_styles, + format_editable, + ), + choice( + t!("Text rotation").as_ref(), + "tbl_cell_rotation", + format!("{rotation:.0}"), + vec!["0".into(), "90".into(), "180".into(), "270".into()], + format_editable, + ), + number( + t!("Text height").as_ref(), + "tbl_cell_text_height", + style_for(CellStylePropertyFlags::TEXT_HEIGHT) + .map(|style| style.text_height) + .unwrap_or(0.18), + format_editable, + ), + color( + t!("Content color").as_ref(), + "tbl_cell_content_color", + style_for(CellStylePropertyFlags::CONTENT_COLOR) + .map(|style| style.content_color) + .unwrap_or(acadrust::types::Color::ByBlock), + format_editable, + ), + color( + t!("Background color").as_ref(), + "tbl_cell_background_color", + style_for(CellStylePropertyFlags::BACKGROUND_COLOR) + .map(|style| style.background_color) + .unwrap_or(acadrust::types::Color::ByBlock), + format_editable, + ), + choice( + t!("Data type").as_ref(), + "tbl_cell_data_type", + data_type.to_string(), + vec![ + "Text".into(), + "Integer".into(), + "Decimal".into(), + "Date".into(), + "Point 2D".into(), + "Point 3D".into(), + "Handle".into(), + ], + format_editable, + ), + text( + t!("Data format").as_ref(), + "tbl_cell_format", + style_for(CellStylePropertyFlags::DATA_FORMAT) + .map(|style| style.value_format.clone()) + .unwrap_or_default(), + format_editable, + ), + toggle( + t!("Background fill").as_ref(), + "tbl_cell_fill", + style_for(CellStylePropertyFlags::BACKGROUND_COLOR) + .is_some_and(|style| style.fill_enabled), + format_editable, + ), + number( + t!("Left margin").as_ref(), + "tbl_cell_margin_left", + style_for(CellStylePropertyFlags::MARGIN_LEFT) + .map(|style| style.margin_left) + .unwrap_or(horizontal_margin), + format_editable, + ), + number( + t!("Top margin").as_ref(), + "tbl_cell_margin_top", + style_for(CellStylePropertyFlags::MARGIN_TOP) + .map(|style| style.margin_top) + .unwrap_or(vertical_margin), + format_editable, + ), + number( + t!("Right margin").as_ref(), + "tbl_cell_margin_right", + style_for(CellStylePropertyFlags::MARGIN_RIGHT) + .map(|style| style.margin_right) + .unwrap_or(horizontal_margin), + format_editable, + ), + number( + t!("Bottom margin").as_ref(), + "tbl_cell_margin_bottom", + style_for(CellStylePropertyFlags::MARGIN_BOTTOM) + .map(|style| style.margin_bottom) + .unwrap_or(vertical_margin), + format_editable, + ), + toggle( + t!("Top border").as_ref(), + "tbl_cell_border_top", + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::TOP, + ) + }) + }) + .is_none_or(|style| !style.top_border.invisible), + format_editable, + ), + toggle( + t!("Right border").as_ref(), + "tbl_cell_border_right", + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::RIGHT, + ) + }) + }) + .is_none_or(|style| !style.right_border.invisible), + format_editable, + ), + toggle( + t!("Bottom border").as_ref(), + "tbl_cell_border_bottom", + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::BOTTOM, + ) + }) + }) + .is_none_or(|style| !style.bottom_border.invisible), + format_editable, + ), + toggle( + t!("Left border").as_ref(), + "tbl_cell_border_left", + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::LEFT, + ) + }) + }) + .is_none_or(|style| !style.left_border.invisible), + format_editable, + ), + toggle( + t!("Locked").as_ref(), + "tbl_cell_locked", + cell_locked, + !immutable_lock, + ), + ], + }); + } + + sections.push( PropSection { title: t!("Table Breaks").into_owned(), props: vec![ toggle( t!("Enabled").as_ref(), "tbl_break_enabled", - self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS), + breaks_enabled, + true, ), - ro( + choice( t!("Direction").as_ref(), "tbl_break_direction", - format!("{:?}", self.break_flow_direction), + match self.break_flow_direction { + acadrust::entities::table::BreakFlowDirection::Right => "Right", + acadrust::entities::table::BreakFlowDirection::Left => "Left", + acadrust::entities::table::BreakFlowDirection::Vertical => "Down", + } + .to_string(), + vec!["Right".into(), "Left".into(), "Down".into()], + breaks_enabled && !manual_positions, ), toggle( t!("Repeat top labels").as_ref(), "tbl_break_repeat_top", self.break_options .contains(BreakOptionFlags::REPEAT_TOP_LABELS), + breaks_enabled, ), toggle( t!("Repeat bottom labels").as_ref(), "tbl_break_repeat_bottom", self.break_options .contains(BreakOptionFlags::REPEAT_BOTTOM_LABELS), + breaks_enabled, ), toggle( t!("Manual positions").as_ref(), "tbl_break_manual_positions", - self.break_options - .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS), + manual_positions, + breaks_enabled, ), toggle( t!("Manual heights").as_ref(), "tbl_break_manual_heights", - self.break_options - .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS), + manual_heights, + breaks_enabled, ), - ro( - t!("Segments").as_ref(), - "tbl_break_segments", - self.break_ranges.len().max(self.break_data.len()).to_string(), + number( + t!("Maximum height").as_ref(), + "tbl_break_height", + break_height, + breaks_enabled && !manual_heights, + ), + number( + t!("Spacing").as_ref(), + "tbl_break_spacing", + self.break_spacing, + breaks_enabled && !manual_positions, ), - ro(t!("Break heights").as_ref(), "tbl_break_height", break_heights), - edit(t!("Spacing").as_ref(), "tbl_break_spacing", self.break_spacing), ], }, - ] + ); + + sections } fn apply_geom_prop(&mut self, field: &str, value: &str) { use crate::entities::common::parse_f64; - use acadrust::entities::table::BreakOptionFlags; + use acadrust::entities::table::{ + BreakOptionFlags, CellStateFlags, CellStyle, CellStylePropertyFlags, + }; let flag = match field { "tbl_break_enabled" => Some(BreakOptionFlags::ENABLE_BREAKS), "tbl_break_repeat_top" => Some(BreakOptionFlags::REPEAT_TOP_LABELS), @@ -1917,18 +2811,460 @@ impl PropertyEditable for Table { _ => None, }; if let Some(flag) = flag { + if flag != BreakOptionFlags::ENABLE_BREAKS + && !self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + { + return; + } let on = if value == "toggle" { !self.break_options.contains(flag) } else { value == "true" }; self.break_options.set(flag, on); + self.break_ranges.clear(); return; } - if field == "tbl_break_spacing" { - if let Some(v) = parse_f64(value) { - self.break_spacing = v; + match field { + "tbl_title_suppressed" | "tbl_header_suppressed" => { + let enabled = if value == "toggle" { + let current = if field == "tbl_title_suppressed" { + self.legacy_style_override + .as_ref() + .and_then(|style| style.title_suppressed) + .unwrap_or(false) + } else { + self.legacy_style_override + .as_ref() + .and_then(|style| style.header_suppressed) + .unwrap_or(false) + }; + !current + } else { + value == "true" + }; + let style = self.legacy_style_override.get_or_insert_with(Default::default); + if field == "tbl_title_suppressed" { + style.flags |= 0x0001; + style.title_suppressed = Some(enabled); + } else { + style.flags |= 0x0002; + style.header_suppressed = Some(enabled); + } + self.override_flag = true; + return; } + "tbl_flow_direction" => { + let up = value.trim().eq_ignore_ascii_case("up"); + let style = self.base_style.get_or_insert_with(Default::default); + style + .property_flags + .set(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP, up); + let legacy = self.legacy_style_override.get_or_insert_with(Default::default); + legacy.flags |= 0x0004; + legacy.flow_direction = Some(if up { 1 } else { 0 }); + self.override_flag = true; + return; + } + _ => {} + } + let cell_index = prop_current_cell(); + let columns = self.columns.len(); + let cell_position = (columns > 0).then_some((cell_index / columns, cell_index % columns)); + if let Some((row, column)) = cell_position { + let state = self + .cell(row, column) + .map(|cell| cell.state) + .unwrap_or_default(); + let content_editable = !state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY, + ); + let format_editable = !state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ); + match field { + "tbl_cell_text" => { + if !content_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + cell.set_text(value); + } + return; + } + "tbl_cell_type" => { + if !content_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + cell.cell_type = if value.trim().eq_ignore_ascii_case("block") { + acadrust::entities::table::CellType::Block + } else { + acadrust::entities::table::CellType::Text + }; + } + return; + } + "tbl_cell_alignment" => { + if !format_editable { + return; + } + let alignment = match value.trim().to_ascii_uppercase().as_str() { + "TOP LEFT" => 1, + "TOP CENTER" => 2, + "TOP RIGHT" => 3, + "MIDDLE LEFT" => 4, + "MIDDLE RIGHT" => 6, + "BOTTOM LEFT" => 7, + "BOTTOM CENTER" => 8, + "BOTTOM RIGHT" => 9, + _ => 5, + }; + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.alignment = alignment; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::ALIGNMENT, + ); + } + return; + } + "tbl_cell_text_style" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.text_style_name = value.trim().to_string(); + style + .property_flags + .insert(CellStylePropertyFlags::TEXT_STYLE); + } + return; + } + "tbl_cell_rotation" => { + if !format_editable { + return; + } + if let Some(rotation) = parse_f64(value) { + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.rotation = rotation.to_radians(); + style + .property_flags + .insert(CellStylePropertyFlags::ROTATION); + } + } + return; + } + "tbl_cell_data_type" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + if cell.contents.is_empty() { + cell.set_text(""); + } + if let Some(content) = cell.contents.first_mut() { + content.value.value_type = match value.trim().to_ascii_uppercase().as_str() { + "INTEGER" => acadrust::entities::table::CellValueType::Long, + "DECIMAL" => acadrust::entities::table::CellValueType::Double, + "DATE" => acadrust::entities::table::CellValueType::Date, + "POINT 2D" => acadrust::entities::table::CellValueType::Point2D, + "POINT 3D" => acadrust::entities::table::CellValueType::Point3D, + "HANDLE" => acadrust::entities::table::CellValueType::Handle, + _ => acadrust::entities::table::CellValueType::String, + }; + content.value.raw_type_code = content.value.value_type as i32; + } + let style = cell.style.get_or_insert_with(CellStyle::new); + style + .property_flags + .insert(CellStylePropertyFlags::DATA_TYPE); + } + return; + } + "tbl_cell_format" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.value_format = value.to_string(); + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::DATA_FORMAT, + ); + } + return; + } + "tbl_cell_fill" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.fill_enabled = if value == "toggle" { + !style.fill_enabled + } else { + value == "true" + }; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR, + ); + } + return; + } + "tbl_cell_border_top" + | "tbl_cell_border_right" + | "tbl_cell_border_bottom" + | "tbl_cell_border_left" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + use acadrust::entities::table::{ + BorderPropertyFlags, CellEdgeFlags, + }; + let style = cell.style.get_or_insert_with(CellStyle::new); + let (border, edge) = match field { + "tbl_cell_border_top" => (&mut style.top_border, CellEdgeFlags::TOP), + "tbl_cell_border_right" => { + (&mut style.right_border, CellEdgeFlags::RIGHT) + } + "tbl_cell_border_bottom" => { + (&mut style.bottom_border, CellEdgeFlags::BOTTOM) + } + _ => (&mut style.left_border, CellEdgeFlags::LEFT), + }; + let visible = if value == "toggle" { + border.invisible + } else { + value == "true" + }; + border.invisible = !visible; + border.override_flags.insert(BorderPropertyFlags::INVISIBILITY); + style.applied_border_edges.insert(edge); + } + return; + } + "tbl_cell_locked" => { + if state.intersects( + CellStateFlags::CONTENT_READ_ONLY | CellStateFlags::FORMAT_READ_ONLY, + ) { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let locked = if value == "toggle" { + !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::FORMAT_LOCKED, + ) + } else { + value == "true" + }; + cell.state.set(CellStateFlags::CONTENT_LOCKED, locked); + cell.state.set(CellStateFlags::FORMAT_LOCKED, locked); + } + return; + } + _ => {} + } + } + let Some(number) = parse_f64(value) else { + if field == "tbl_break_direction" { + if !self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + || self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS) + { + return; + } + self.break_flow_direction = match value.trim().to_ascii_uppercase().as_str() { + "LEFT" => acadrust::entities::table::BreakFlowDirection::Left, + "DOWN" | "VERTICAL" => { + acadrust::entities::table::BreakFlowDirection::Vertical + } + _ => acadrust::entities::table::BreakFlowDirection::Right, + }; + self.break_ranges.clear(); + } + return; + }; + match field { + "tbl_insert_x" => self.insertion_point.x = number, + "tbl_insert_y" => self.insertion_point.y = number, + "tbl_insert_z" => self.insertion_point.z = number, + "tbl_direction" => { + let radians = number.to_radians(); + self.horizontal_direction.x = radians.cos(); + self.horizontal_direction.y = radians.sin(); + self.horizontal_direction.z = 0.0; + } + "tbl_rows" => { + let requested = number.round().max(1.0) as usize; + while self.rows.len() < requested { + self.add_row(); + } + while self.rows.len() > requested { + self.remove_row(self.rows.len() - 1); + } + self.break_ranges.clear(); + } + "tbl_cols" => { + let requested = number.round().max(1.0) as usize; + let width = self.columns.last().map(|column| column.width).unwrap_or(2.0); + while self.columns.len() < requested { + self.add_column(width); + } + while self.columns.len() > requested { + self.remove_column(self.columns.len() - 1); + } + self.break_ranges.clear(); + } + "tbl_column_width" if number > 0.0 => { + for column in &mut self.columns { + column.width = number; + } + self.break_ranges.clear(); + } + "tbl_row_height" if number > 0.0 => { + for row in &mut self.rows { + row.height = number; + } + self.break_ranges.clear(); + } + "tbl_width" if number > 0.0 => { + let current = self.total_width(); + if current > 1.0e-12 { + for column in &mut self.columns { + column.width *= number / current; + } + } + self.break_ranges.clear(); + } + "tbl_height" if number > 0.0 => { + let current = self.total_height(); + if current > 1.0e-12 { + for row in &mut self.rows { + row.height *= number / current; + } + } + self.break_ranges.clear(); + } + "tbl_horizontal_margin" if number >= 0.0 => { + let style = self.base_style.get_or_insert_with(Default::default); + style.margin_left = number; + style.margin_right = number; + style.property_flags.insert( + CellStylePropertyFlags::MARGIN_LEFT | CellStylePropertyFlags::MARGIN_RIGHT, + ); + let legacy = self.legacy_style_override.get_or_insert_with(Default::default); + legacy.flags |= 0x0008; + legacy.horizontal_cell_margin = Some(number); + self.override_flag = true; + } + "tbl_vertical_margin" if number >= 0.0 => { + let style = self.base_style.get_or_insert_with(Default::default); + style.margin_top = number; + style.margin_bottom = number; + style.property_flags.insert( + CellStylePropertyFlags::MARGIN_TOP | CellStylePropertyFlags::MARGIN_BOTTOM, + ); + let legacy = self.legacy_style_override.get_or_insert_with(Default::default); + legacy.flags |= 0x0010; + legacy.vertical_cell_margin = Some(number); + self.override_flag = true; + } + "tbl_cell_text_height" if number > 0.0 => { + if let Some((row, column)) = cell_position { + let state = self + .cell(row, column) + .map(|cell| cell.state) + .unwrap_or_default(); + if state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ) { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.text_height = number; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::TEXT_HEIGHT, + ); + } + } + } + "tbl_cell_margin_left" + | "tbl_cell_margin_top" + | "tbl_cell_margin_right" + | "tbl_cell_margin_bottom" + if number >= 0.0 => + { + if let Some((row, column)) = cell_position { + let state = self + .cell(row, column) + .map(|cell| cell.state) + .unwrap_or_default(); + if state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ) { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + let flag = match field { + "tbl_cell_margin_left" => { + style.margin_left = number; + CellStylePropertyFlags::MARGIN_LEFT + } + "tbl_cell_margin_top" => { + style.margin_top = number; + CellStylePropertyFlags::MARGIN_TOP + } + "tbl_cell_margin_right" => { + style.margin_right = number; + CellStylePropertyFlags::MARGIN_RIGHT + } + _ => { + style.margin_bottom = number; + CellStylePropertyFlags::MARGIN_BOTTOM + } + }; + style.property_flags.insert(flag); + } + } + } + "tbl_break_height" + if number >= 0.0 + && self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + && !self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS) => + { + if self.break_data.is_empty() { + self.break_data.push(acadrust::entities::table::TableBreakData { + position: acadrust::types::Vector3::ZERO, + height: number, + flags: 0, + }); + } else { + for data in &mut self.break_data { + data.height = number; + } + } + self.break_ranges.clear(); + } + "tbl_break_spacing" + if number >= 0.0 + && self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + && !self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS) => + { + self.break_spacing = number; + self.break_ranges.clear(); + } + _ => {} } } } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index e5f04731..7333869f 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -715,6 +715,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DATAEXTRACTION {rows} rows → \"{fname}\"" => Some(("common", "dataextraction-rows-rows-fname")), "DATAEXTRACTION: write failed: {e}" => Some(("common", "dataextraction-write-failed-e")), "DATALINK path to the .csv file:" => Some(("view", "datalink-path-to-the-csv-file")), + "DATALINK Specify insertion point:" => Some(("view", "datalink-specify-insertion-point")), "DATALINK: cannot read \"{path}\": {e}" => Some(("view", "datalink-cannot-read-path-e")), "DATALINK: imported {nrows}×{ncols} cells into a table at the origin." => Some(("view", "datalink-imported-nrows-ncols-cells-into-a-tab")), "DATALINK: the CSV file is empty." => Some(("view", "datalink-the-csv-file-is-empty")), @@ -2555,9 +2556,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Sync Scales" => Some(("annotate", "sync-scales")), "Synchronize" => Some(("insert", "synchronize")), "System default printer" => Some(("plot", "system-default-printer")), + "TABLE CELL Enter text for [%{row},%{column}] <%{current}>:" => Some(("annotate", "table-cell-enter-text-row-column-current")), + "TABLE Enter column width [%{width}]:" => Some(("annotate", "table-enter-column-width-width")), "TABLE Enter number of columns [%{cols}]:" => Some(("annotate", "table-enter-number-of-columns-cols")), + "TABLE Enter number of data rows [%{rows}] (%{cols} cols):" => Some(("annotate", "table-enter-number-of-data-rows-rows-cols")), "TABLE Enter number of rows (incl. header) [%{rows}] (%{cols} cols):" => Some(("annotate", "table-enter-number-of-rows-incl-header-rows-co")), + "TABLE Enter row height [%{height}]:" => Some(("annotate", "table-enter-row-height-height")), + "TABLE Insertion behavior [Point/Window] <%{mode}>:" => Some(("annotate", "table-insertion-behavior-point-window-mode")), "TABLE Specify insertion point [%{cols}×%{rows}]:" => Some(("annotate", "table-specify-insertion-point-cols-rows")), + "TABLE: block definition not found." => Some(("view", "table-block-definition-not-found")), + "TABLE: edit applied." => Some(("view", "table-edit-applied")), + "TABLE: field handle not found." => Some(("view", "table-field-handle-not-found")), + "TABLE: row/column is out of range or the cell is locked." => Some(("view", "table-row-column-out-of-range-or-cell-locked")), + "TABLE: select a table first." => Some(("view", "table-select-a-table-first")), "TABLE CELL: select a Table entity first, or row/col out of range." => Some(("view", "table-cell-select-a-table-entity-first-or-row")), "TABLE CELL: set [{row},{col}] = \"{text}\"." => Some(("view", "table-cell-set-row-col-text")), "TABLESTYLE: '{}' already exists." => Some(("layers", "tablestyle-already-exists")), @@ -2867,6 +2878,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Usage: SHORTCUTS LIST | SET | CLEAR " => Some(("view", "usage-shortcuts-list-set-key-cmd-clear-key")), "Usage: SHORTCUTS SET e.g. SHORTCUTS SET CTRL+D DIST" => Some(("view", "usage-shortcuts-set-key-command-e-g-shortcuts")), "Usage: TABLE CELL " => Some(("view", "usage-table-cell-row-col-text")), + "Usage: TABLE CELL | INSERTROW | DELETEROW | INSERTCOL | DELETECOL | MERGE | UNMERGE | ROWHEIGHT | COLWIDTH | LOCK | BLOCK | FORMULA | FIELD" => Some(("view", "usage-table-edit-actions")), "Usage: TABLESTYLE NEW " => Some(("layers", "usage-tablestyle-new-name")), "Usage: TABLESTYLE [LIST|NEW ]" => Some(("layers", "usage-tablestyle-list-new-name")), "Usage: TCASE " => Some(("annotate", "usage-tcase-upper-lower-sentence-title")), diff --git a/src/modules/annotate/data_link.rs b/src/modules/annotate/data_link.rs index a2e20e5b..80032620 100644 --- a/src/modules/annotate/data_link.rs +++ b/src/modules/annotate/data_link.rs @@ -1,6 +1,12 @@ -// DATALINK command — manages data links to external spreadsheets. +// DATALINK command — manages persistent links to external tabular data. +use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use crate::t; +use acadrust::entities::Table; +use acadrust::EntityType; +use glam::DVec3; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/data_link.svg")); @@ -12,3 +18,113 @@ pub fn tool() -> ToolDef { event: ModuleEvent::Command("DATALINK".to_string()), } } + +pub struct DataLinkPlaceCommand { + table: Table, + plane: WorkingPlane, +} + +impl DataLinkPlaceCommand { + pub fn new(mut table: Table, path: &str) -> Self { + table.name = format!("__OPENCAD_LINK_PENDING__{path}"); + Self { + table, + plane: WorkingPlane::default(), + } + } + + fn preview(&self, point: DVec3) -> WireModel { + let point = self.plane.to_local(point); + let mut points = Vec::new(); + let mut x = 0.0; + for column in 0..=self.table.column_count() { + if column > 0 { + x += self.table.columns[column - 1].width; + } + points.push(self.plane.to_world(point + DVec3::X * x).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(x, -self.table.total_height(), 0.0)) + .as_vec3() + .to_array(), + ); + } + let mut y = 0.0; + for row in 0..=self.table.row_count() { + if row > 0 { + y -= self.table.rows[row - 1].height; + } + points.push(self.plane.to_world(point + DVec3::Y * y).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(self.table.total_width(), y, 0.0)) + .as_vec3() + .to_array(), + ); + } + WireModel { + point_marker: None, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: "data_link_preview".into(), + points, + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } + } +} + +impl CadCommand for DataLinkPlaceCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + + fn name(&self) -> &'static str { + "DATALINK" + } + + fn prompt(&self) -> String { + t!("DATALINK Specify insertion point:").into_owned() + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let point = self.plane.to_local(point); + self.table.insertion_point = acadrust::types::Vector3::new(point.x, point.y, point.z); + CmdResult::CommitAndExit( + self.plane + .place_entity(EntityType::Table(self.table.clone())), + ) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + Some(self.preview(point)) + } +} diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index 458f1ee9..0e597cc9 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -1,16 +1,14 @@ -// TABLE command — create an empty table entity. +// TABLE command — create a styled empty table. // -// Workflow: -// 1. Text: Enter number of columns (default 3) -// 2. Text: Enter number of rows (default 4, includes header row) -// 3. Point: Click insertion point -// -// Creates a Table entity with uniform row height (0.5) and column width (2.0). +// Title/header rows come from the selected table style and are not counted as +// data rows. The command remembers its previous settings and supports either a +// fixed insertion point or a two-corner sizing window. use acadrust::entities::TableBuilder; use acadrust::types::Vector3; use acadrust::EntityType; use glam::DVec3; +use std::sync::{Mutex, OnceLock}; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -29,22 +27,80 @@ pub fn tool() -> ToolDef { } const DEFAULT_COLS: usize = 3; -const DEFAULT_ROWS: usize = 4; -const COL_WIDTH: f64 = 2.0; -const ROW_HEIGHT: f64 = 0.5; +const DEFAULT_DATA_ROWS: usize = 4; +const DEFAULT_COL_WIDTH: f64 = 2.0; +const DEFAULT_ROW_HEIGHT: f64 = 0.5; +#[derive(Clone, Copy)] +enum InsertionMode { + Point, + Window, +} + +#[derive(Clone, Copy)] +struct TableDefaults { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + insertion_mode: InsertionMode, +} + +impl Default for TableDefaults { + fn default() -> Self { + Self { + columns: DEFAULT_COLS, + data_rows: DEFAULT_DATA_ROWS, + column_width: DEFAULT_COL_WIDTH, + row_height: DEFAULT_ROW_HEIGHT, + insertion_mode: InsertionMode::Point, + } + } +} + +fn saved_defaults() -> &'static Mutex { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| Mutex::new(TableDefaults::default())) +} + +#[derive(Clone, Copy)] enum Step { Columns, - Rows { cols: usize }, - Insertion { cols: usize, rows: usize }, + DataRows { columns: usize }, + ColumnWidth { columns: usize, data_rows: usize }, + RowHeight { + columns: usize, + data_rows: usize, + column_width: f64, + }, + InsertionMode { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + }, + Insertion { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + }, + WindowFirst { columns: usize, data_rows: usize }, + WindowSecond { + columns: usize, + data_rows: usize, + first: DVec3, + }, } pub struct TableCommand { step: Step, style_handle: Option, - column_width: f64, - row_height: f64, + suggested_column_width: f64, + suggested_row_height: f64, preview_scale: f64, + title_row: bool, + header_row: bool, plane: WorkingPlane, } @@ -53,9 +109,11 @@ impl TableCommand { Self { step: Step::Columns, style_handle: None, - column_width: COL_WIDTH, - row_height: ROW_HEIGHT, + suggested_column_width: DEFAULT_COL_WIDTH, + suggested_row_height: DEFAULT_ROW_HEIGHT, preview_scale: 1.0, + title_row: true, + header_row: true, plane: WorkingPlane::default(), } } @@ -71,21 +129,122 @@ impl TableCommand { .max(style.header_row_style.text_height) .max(style.title_row_style.text_height) .max(1.0e-6); - let row_height = text_height * 1.5 + style.vertical_margin * 2.0; - let column_width = text_height * 8.0 + style.horizontal_margin * 2.0; Self { step: Step::Columns, style_handle: Some(style_handle), - column_width, - row_height, + suggested_column_width: text_height * 8.0 + style.horizontal_margin * 2.0, + suggested_row_height: text_height * 1.5 + style.vertical_margin * 2.0, preview_scale: if style.annotative { annotation_multiplier } else { 1.0 }, + title_row: !style.title_suppressed, + header_row: !style.header_suppressed, plane: WorkingPlane::default(), } } + + fn defaults(&self) -> TableDefaults { + let mut defaults = saved_defaults().lock().map(|value| *value).unwrap_or_default(); + if (defaults.column_width - DEFAULT_COL_WIDTH).abs() < 1.0e-9 { + defaults.column_width = self.suggested_column_width; + } + if (defaults.row_height - DEFAULT_ROW_HEIGHT).abs() < 1.0e-9 { + defaults.row_height = self.suggested_row_height; + } + defaults + } + + fn total_rows(&self, data_rows: usize) -> usize { + data_rows + usize::from(self.title_row) + usize::from(self.header_row) + } + + fn build_table( + &self, + point: DVec3, + rows: usize, + columns: usize, + column_width: f64, + row_height: f64, + ) -> EntityType { + let point = self.plane.to_local(point); + let mut table = TableBuilder::new(rows, columns) + .at(Vector3::new(point.x, point.y, point.z)) + .row_height(row_height) + .column_width(column_width) + .build(); + table.table_style_handle = self.style_handle; + self.plane.place_entity(EntityType::Table(table)) + } + + fn preview_grid( + &self, + point: DVec3, + rows: usize, + columns: usize, + column_width: f64, + row_height: f64, + scale: f64, + ) -> WireModel { + let point = self.plane.to_local(point); + let width = columns as f64 * column_width * scale; + let height = rows as f64 * row_height * scale; + let mut points = Vec::with_capacity((rows + columns + 2) * 2); + for column in 0..=columns { + let x = column as f64 * column_width * scale; + points.push(self.plane.to_world(point + DVec3::X * x).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(x, -height, 0.0)) + .as_vec3() + .to_array(), + ); + } + for row in 0..=rows { + let y = -(row as f64 * row_height * scale); + points.push(self.plane.to_world(point + DVec3::Y * y).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(width, y, 0.0)) + .as_vec3() + .to_array(), + ); + } + WireModel { + point_marker: None, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: "table_preview".into(), + points, + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } + } } impl CadCommand for TableCommand { @@ -98,147 +257,293 @@ impl CadCommand for TableCommand { } fn prompt(&self) -> String { + let defaults = self.defaults(); match &self.step { Step::Columns => t!( "TABLE Enter number of columns [%{cols}]:", - cols = DEFAULT_COLS + cols = defaults.columns ) .into_owned(), - Step::Rows { cols } => t!( - "TABLE Enter number of rows (incl. header) [%{rows}] (%{cols} cols):", - rows = DEFAULT_ROWS, - cols = cols + Step::DataRows { columns } => t!( + "TABLE Enter number of data rows [%{rows}] (%{cols} cols):", + rows = defaults.data_rows, + cols = columns ) .into_owned(), - Step::Insertion { cols, rows } => t!( + Step::ColumnWidth { .. } => t!( + "TABLE Enter column width [%{width}]:", + width = format!("{:.4}", defaults.column_width) + ) + .into_owned(), + Step::RowHeight { .. } => t!( + "TABLE Enter row height [%{height}]:", + height = format!("{:.4}", defaults.row_height) + ) + .into_owned(), + Step::InsertionMode { .. } => t!( + "TABLE Insertion behavior [Point/Window] <%{mode}>:", + mode = match defaults.insertion_mode { + InsertionMode::Point => t!("Point"), + InsertionMode::Window => t!("Window"), + } + ) + .into_owned(), + Step::Insertion { columns, data_rows, .. } => t!( "TABLE Specify insertion point [%{cols}×%{rows}]:", - cols = cols, - rows = rows + cols = columns, + rows = self.total_rows(*data_rows) ) .into_owned(), + Step::WindowFirst { .. } => { + t!("%{n} Specify first corner:", n = "TABLE").into_owned() + } + Step::WindowSecond { .. } => { + t!("%{n} Specify opposite corner:", n = "TABLE").into_owned() + } } } fn wants_text_input(&self) -> bool { - !matches!(self.step, Step::Insertion { .. }) + matches!( + self.step, + Step::Columns + | Step::DataRows { .. } + | Step::ColumnWidth { .. } + | Step::RowHeight { .. } + | Step::InsertionMode { .. } + ) } fn on_text_input(&mut self, text: &str) -> Option { - let t = text.trim(); - match &self.step { + let input = text.trim(); + let defaults = self.defaults(); + match self.step { Step::Columns => { - let cols = if t.is_empty() { - DEFAULT_COLS + let columns = if input.is_empty() { + defaults.columns } else { - t.parse::().ok().filter(|&n| n >= 1)? + input.parse::().ok().filter(|value| *value > 0)? }; - self.step = Step::Rows { cols }; - Some(CmdResult::NeedPoint) + self.step = Step::DataRows { columns }; } - Step::Rows { cols } => { - let cols = *cols; - let rows = if t.is_empty() { - DEFAULT_ROWS + Step::DataRows { columns } => { + let data_rows = if input.is_empty() { + defaults.data_rows } else { - t.parse::().ok().filter(|&n| n >= 1)? + input.parse::().ok().filter(|value| *value > 0)? }; - self.step = Step::Insertion { cols, rows }; - Some(CmdResult::NeedPoint) + self.step = Step::ColumnWidth { columns, data_rows }; + } + Step::ColumnWidth { columns, data_rows } => { + let column_width = if input.is_empty() { + defaults.column_width + } else { + input.parse::().ok().filter(|value| *value > 0.0)? + }; + self.step = Step::RowHeight { + columns, + data_rows, + column_width, + }; + } + Step::RowHeight { columns, data_rows, column_width } => { + let row_height = if input.is_empty() { + defaults.row_height + } else { + input.parse::().ok().filter(|value| *value > 0.0)? + }; + self.step = Step::InsertionMode { + columns, + data_rows, + column_width, + row_height, + }; + } + Step::InsertionMode { columns, data_rows, column_width, row_height } => { + let mode = if input.is_empty() { + defaults.insertion_mode + } else if "POINT".starts_with(&input.to_ascii_uppercase()) { + InsertionMode::Point + } else if "WINDOW".starts_with(&input.to_ascii_uppercase()) { + InsertionMode::Window + } else { + return None; + }; + if let Ok(mut saved) = saved_defaults().lock() { + *saved = TableDefaults { + columns, + data_rows, + column_width, + row_height, + insertion_mode: mode, + }; + } + self.step = match mode { + InsertionMode::Point => Step::Insertion { + columns, + data_rows, + column_width, + row_height, + }, + InsertionMode::Window => Step::WindowFirst { columns, data_rows }, + }; + } + Step::Insertion { .. } | Step::WindowFirst { .. } | Step::WindowSecond { .. } => { + return None; } - Step::Insertion { .. } => None, } + Some(CmdResult::NeedPoint) } fn on_enter(&mut self) -> CmdResult { - match &self.step { - Step::Columns => { - // Accept default. - self.step = Step::Rows { cols: DEFAULT_COLS }; - CmdResult::NeedPoint - } - Step::Rows { cols } => { - let cols = *cols; - self.step = Step::Insertion { - cols, - rows: DEFAULT_ROWS, + self.on_text_input("") + .map_or(CmdResult::Cancel, |_| CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Insertion { + columns, + data_rows, + column_width, + row_height, + } => CmdResult::CommitAndExit(self.build_table( + point, + self.total_rows(data_rows), + columns, + column_width, + row_height, + )), + Step::WindowFirst { columns, data_rows } => { + self.step = Step::WindowSecond { + columns, + data_rows, + first: point, }; CmdResult::NeedPoint } - Step::Insertion { .. } => CmdResult::Cancel, + Step::WindowSecond { columns, data_rows, first } => { + let first = self.plane.to_local(first); + let second = self.plane.to_local(point); + let rows = self.total_rows(data_rows); + let width = (second.x - first.x).abs().max(1.0e-6); + let height = (second.y - first.y).abs().max(1.0e-6); + let top_left = DVec3::new(first.x.min(second.x), first.y.max(second.y), first.z); + CmdResult::CommitAndExit(self.build_table( + self.plane.to_world(top_left), + rows, + columns, + width / columns as f64, + height / rows as f64, + )) + } + _ => CmdResult::NeedPoint, } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Step::Insertion { cols, rows } = self.step { - let point = self.plane.to_local(pt); - let ins = Vector3::new(point.x, point.y, point.z); - let mut table = TableBuilder::new(rows, cols) - .at(ins) - .row_height(self.row_height) - .column_width(self.column_width) - .build(); - table.table_style_handle = self.style_handle; - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Table(table))) - } else { - CmdResult::NeedPoint - } - } - - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if let Step::Insertion { cols, rows } = self.step { - // Preview: outline of the table bounding box. - let w = (cols as f32) * (self.column_width * self.preview_scale) as f32; - let h = (rows as f32) * (self.row_height * self.preview_scale) as f32; - let point = self.plane.to_local(pt); - let corners = [ + fn on_mouse_move(&mut self, point: DVec3) -> Option { + match self.step { + Step::Insertion { + columns, + data_rows, + column_width, + row_height, + } => Some(self.preview_grid( point, - point + DVec3::X * w as f64, - point + DVec3::new(w as f64, -(h as f64), 0.0), - point - DVec3::Y * h as f64, - ] - .map(|corner| self.plane.to_world(corner).as_vec3().to_array()); - let [p0, p1, p2, p3] = corners; - Some(WireModel { - point_marker: None, - taper_widths: Vec::new(), - pattern_stations: Vec::new(), - world_width: 0.0, - depth_override: None, - display_visible: true, - plot_visible: true, - fill_is_3d: false, - fill_is_2d_solid: false, - render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: "table_preview".into(), - points: vec![ - p0, p1, p1, p2, p2, p3, p3, p0, - ], - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) - } else { - None + self.total_rows(data_rows), + columns, + column_width, + row_height, + self.preview_scale, + )), + Step::WindowSecond { columns, data_rows, first } => { + let first = self.plane.to_local(first); + let second = self.plane.to_local(point); + let rows = self.total_rows(data_rows); + let width = (second.x - first.x).abs().max(1.0e-6); + let height = (second.y - first.y).abs().max(1.0e-6); + let top_left = DVec3::new(first.x.min(second.x), first.y.max(second.y), first.z); + Some(self.preview_grid( + self.plane.to_world(top_left), + rows, + columns, + width / columns as f64, + height / rows as f64, + 1.0, + )) + } + _ => None, } } } +inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] }); -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] }); // TableCommand +pub struct TableCellEditCommand { + handle: acadrust::Handle, + table: acadrust::entities::Table, + row: usize, + column: usize, +} + +impl TableCellEditCommand { + pub fn new( + handle: acadrust::Handle, + table: &acadrust::entities::Table, + row: usize, + column: usize, + ) -> Self { + Self { + handle, + table: table.clone(), + row, + column, + } + } +} + +impl CadCommand for TableCellEditCommand { + fn name(&self) -> &'static str { + "TABLE CELL" + } + + fn prompt(&self) -> String { + let current = self.table.cell_text(self.row, self.column).unwrap_or(""); + t!( + "TABLE CELL Enter text for [%{row},%{column}] <%{current}>:", + row = self.row, + column = self.column, + current = current + ) + .into_owned() + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + use acadrust::entities::table::CellStateFlags; + let cell = self.table.cell_mut(self.row, self.column)?; + if cell + .state + .intersects(CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY) + { + return Some(CmdResult::Cancel); + } + cell.set_text(text); + self.table.block_record_handle = None; + Some(CmdResult::ReplaceMany( + vec![(self.handle, vec![EntityType::Table(self.table.clone())])], + Vec::new(), + )) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +}