feat(table): complete editing workflow

This commit is contained in:
Hakan Seven 2026-08-25 00:25:13 +03:00
commit f40b172eb0
14 changed files with 3395 additions and 378 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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 <key> <cmd> | CLEAR <key>
.usage-shortcuts-set-key-command-e-g-shortcuts = Usage: SHORTCUTS SET <key> <command> e.g. SHORTCUTS SET CTRL+D DIST
.usage-table-cell-row-col-text = Usage: TABLE CELL <row> <col> <text>
.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 <n>|CONTRAST <n>|ON|OFF|CLIP ON|OFF|MONO ON|OFF]
.usage-useri-1-5-int-userr-1-5-real = Usage: USERI <1-5> <int> | USERR <1-5> <real>

View file

@ -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 <key> <cmd> | CLEAR <key>
.usage-shortcuts-set-key-command-e-g-shortcuts = Kullanım: SHORTCUTS SET <key> <command> ör. SHORTCUTS SET CTRL+D DIST
.usage-table-cell-row-col-text = Kullanım: TABLE CELL <row> <col> <text>
.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 <n>|CONTRAST <n>|ON|OFF|CLIP ON|OFF|MONO ON|OFF]
.usage-useri-1-5-int-userr-1-5-real = Kullanım: USERI <1-5> <int> | USERR <1-5> <real>

View file

@ -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 <row> <col> <text> — 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", "<row>", "<col>", "<text>"]
let row_res = parts.get(1).and_then(|s| s.parse::<usize>().ok());
let col_res = parts.get(2).and_then(|s| s.parse::<usize>().ok());
let text = parts.get(3).copied().unwrap_or("");
match (row_res, col_res) {
(Some(row), Some(col)) => {
let selected_handles: Vec<acadrust::Handle> = 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 <row> <col> <text>").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::<usize>().ok())
};
let real = |index: usize| {
words.get(index).and_then(|value| value.parse::<f64>().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 <row> <col> <text>",
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<acadrust::Handle> = 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 <path.csv> — import a CSV file into a table placed at the
// origin (one-time import; a live re-reading link is future work).
// DATALINK <path.csv> — 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 <path-to-.csv> — imports the CSV into a table at the origin.",
"Usage: DATALINK <path-to-.csv>",
);
return Some(Task::none());
}
match std::fs::read_to_string(path) {
Ok(text) => {
let rows_data: Vec<Vec<String>> = 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(&current_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<acadrust::Handle> = 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::<Vec<_>>()
} 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 <path> — import survey points (LandXML <CgPoint>
// 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<Vec<String>> {
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::<Vec<_>>()
.join(",")
})
.collect::<Vec<_>>()
.join("\r\n")
}
#[cfg(test)]
mod tests {
use crate::app::OpenCADStudio;

View file

@ -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",

View file

@ -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<String> = 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<Handle> {
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<String> {

View file

@ -2127,6 +2127,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
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<Message> {
}
}
}
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<Message> {
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<Message> {
}
}
}
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();

View file

@ -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 };

View file

@ -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.

File diff suppressed because it is too large Load diff

View file

@ -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 <key> <cmd> | CLEAR <key>" => Some(("view", "usage-shortcuts-list-set-key-cmd-clear-key")),
"Usage: SHORTCUTS SET <key> <command> e.g. SHORTCUTS SET CTRL+D DIST" => Some(("view", "usage-shortcuts-set-key-command-e-g-shortcuts")),
"Usage: TABLE CELL <row> <col> <text>" => 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 <name>" => Some(("layers", "usage-tablestyle-new-name")),
"Usage: TABLESTYLE [LIST|NEW <name>]" => Some(("layers", "usage-tablestyle-list-new-name")),
"Usage: TCASE <Upper|Lower|Sentence|Title>" => Some(("annotate", "usage-tcase-upper-lower-sentence-title")),

View file

@ -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<WireModel> {
Some(self.preview(point))
}
}

View file

@ -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<TableDefaults> {
static DEFAULTS: OnceLock<Mutex<TableDefaults>> = 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<acadrust::Handle>,
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<CmdResult> {
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::<usize>().ok().filter(|&n| n >= 1)?
input.parse::<usize>().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::<usize>().ok().filter(|&n| n >= 1)?
input.parse::<usize>().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::<f64>().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::<f64>().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<WireModel> {
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<WireModel> {
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<CmdResult> {
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
}
}