use super::*; impl OpenCADStudio { pub(super) fn dispatch_display(&mut self, cmd: &str, i: usize) -> Option> { match cmd { // Interactive pan: left-drag pans the view until Esc. The only pan // path when there is no middle mouse button (trackpad / web). "PAN" => { self.tabs[i].pan_mode = true; self.clear_navigation_hover(i); self.command_line .push_output(crate::t!("PAN: drag with the left mouse button. Press Esc to exit.").as_ref()); } // ── TABLE structure and cell editing ─────────────────────────────── cmd if cmd.starts_with("TABLE ") => { let rest = cmd.trim_start_matches("TABLE").trim(); #[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, } } 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( 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()); } } // ── UCSICON — toggle UCS icon visibility on all viewports ──────────── // UCSICON ON — show UCS icon in all viewports // UCSICON OFF — hide UCS icon in all viewports // UCSICON NOORIGIN — show icon but not at origin (show at corner) // UCSICON ORIGIN — show icon at UCS origin "UCSICON" => { use crate::command::KeywordCommand; let c = KeywordCommand::new( "UCSICON", "UCSICON [On / Off / NoOrigin / Origin]:", vec![ ("On", "ON", None), ("Off", "OFF", None), ("NoOrigin", "NOORIGIN", None), ("Origin", "ORIGIN", None), ], ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("UCSICON ") => { let sub = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase(); match sub.as_str() { "ON" | "OFF" | "NOORIGIN" | "ORIGIN" => { self.push_undo_snapshot(i, "UCSICON"); let visible = sub != "OFF"; let at_origin = sub == "ORIGIN"; // Update model-space icon flags. self.show_ucs_icon = visible; self.ribbon.set_ucs_icon(visible); if sub == "NOORIGIN" || sub == "ORIGIN" { self.ucs_icon_at_origin = at_origin; } let mut count = 0usize; for entity in self.tabs[i].scene.document.entities_mut() { if let acadrust::EntityType::Viewport(vp) = entity { vp.status.ucs_icon_visible = visible; if sub == "NOORIGIN" || sub == "ORIGIN" { vp.status.ucs_icon_at_origin = at_origin; } count += 1; } } self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!( "UCSICON {sub}: updated {count} viewport(s) + model space." ).as_ref()); } "" => { // Bare UCSICON toggles visibility. self.push_undo_snapshot(i, "UCSICON"); let visible = !self.show_ucs_icon; self.show_ucs_icon = visible; self.ribbon.set_ucs_icon(visible); for entity in self.tabs[i].scene.document.entities_mut() { if let acadrust::EntityType::Viewport(vp) = entity { vp.status.ucs_icon_visible = visible; } } self.tabs[i].dirty = true; let state = if visible { "ON" } else { "OFF" }; self.command_line.push_output(crate::tf!("UCSICON {state}").as_ref()); } _ => { self.command_line .push_info(crate::t!("Usage: UCSICON ON | OFF | NOORIGIN | ORIGIN").as_ref()); } } } // ── NAVVCUBE — toggle ViewCube visibility ──────────────────────────── "NAVVCUBE" => { return Some(Task::done(Message::ToggleViewCube)); } // ── LIMITS — drawing/grid boundary for the active space ───────────── "LIMITS" => { use crate::modules::view::limits::LimitsCommand; let (min, max) = self.tabs[i] .scene .current_drawing_limits() .unwrap_or((glam::DVec2::ZERO, glam::DVec2::new(12.0, 9.0))); let command = LimitsCommand::new(min, max); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } "LIMITS ON" | "LIMITS OFF" => { let enabled = cmd.ends_with("ON"); if self.tabs[i].scene.drawing_limit_check_enabled() != enabled { self.push_undo_snapshot(i, "LIMITS"); self.tabs[i].scene.set_drawing_limit_check(enabled); self.tabs[i].dirty = true; } self.command_line.push_output(crate::t!(if enabled { "Limits checking ON." } else { "Limits checking OFF." }).as_ref()); } cmd if cmd.starts_with("LIMITS SET ") => { let tokens: Vec<&str> = cmd["LIMITS SET ".len()..].split_whitespace().collect(); let values: Result, _> = tokens.iter().map(|value| value.parse()).collect(); let Ok(values) = values else { self.command_line .push_error(crate::t!("LIMITS: four numeric coordinates required.").as_ref()); return Some(Task::none()); }; if tokens.len() != 4 || !values.iter().all(|value| value.is_finite()) { self.command_line .push_error(crate::t!("LIMITS: four finite numeric coordinates required.").as_ref()); } else { let first = glam::DVec2::new(values[0], values[1]); let opposite = glam::DVec2::new(values[2], values[3]); let min = first.min(opposite); let max = first.max(opposite); if min.x == max.x || min.y == max.y { self.command_line .push_error(crate::t!("LIMITS: corners must define a non-zero area.").as_ref()); } else { self.push_undo_snapshot(i, "LIMITS"); self.tabs[i].scene.set_current_drawing_limits(min, max); self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!( "Drawing limits: {:.4},{:.4} to {:.4},{:.4}.", min.x, min.y, max.x, max.y ).as_ref()); } } } // ── PROPERTIES — toggle Properties panel visibility ────────────────── "PROPERTIES" | "PROPS" => { return Some(Task::done(Message::ToggleProperties)); } // ── FILETAB — toggle file/document tabs ────────────────────────────── "FILETAB" => { return Some(Task::done(Message::ToggleFileTabs)); } // ── LAYOUTTAB — toggle layout/paper-space tabs ─────────────────────── "LAYOUTTAB" => { return Some(Task::done(Message::ToggleLayoutTabs)); } // ── REDRAW / REGEN ────────────────────────────────────────────── // REDRAW — force a full re-rasterize of the current viewport next // frame, WITHOUT touching the DB (never bumps geometry_epoch / // block_epoch, never pushes undo). Scope: Active. This arm does not // itself clear previews or cancel commands (the normal non-transparent // dispatch teardown, commands/mod.rs:90-96, governs that separately); // it only queues a per-viewport cache invalidation. "REDRAW" => { use crate::scene::ViewportRefreshScope; self.tabs[i].scene.request_refresh(ViewportRefreshScope::Active); self.command_line.push_output(crate::t!("REDRAW: viewport refreshed.").as_ref()); return Some(Task::none()); } // REDRAWALL — force re-rasterize of every generated viewport. "REDRAWALL" => { use crate::scene::ViewportRefreshScope; self.tabs[i].scene.request_refresh(ViewportRefreshScope::All); self.command_line.push_output(crate::t!("REDRAWALL: viewports refreshed.").as_ref()); return Some(Task::none()); } // REGEN — full model regeneration (bump_geometry: geometry_epoch AND // block_epoch; C4). No undo, no DB mutation, so do NOT touch // self.tabs[i].dirty — a newly opened drawing must not become // "modified" merely because tessellation caches were invalidated (C7). // REGENALL is functionally identical (C5). "REGEN" | "REGENALL" => { self.tabs[i].scene.bump_geometry(); self.command_line.push_output(crate::t!("REGEN: regenerated model.").as_ref()); return Some(Task::none()); } // ── Drafting aids — same toggles the status-bar pills drive, also // reachable by name from the command line. ───────────────────────── // GRID — show / hide the reference grid. "GRID" => { return Some(Task::done(Message::ToggleGrid)); } // SNAP — toggle cursor snapping to the grid. "SNAP" => { return Some(Task::done(Message::ToggleGridSnap)); } // ISOPLANE — cycle the isometric drafting axis pair (F5). "ISOPLANE" => { return Some(Task::done(Message::CycleIsoPlane)); } cmd if cmd.starts_with("ISOPLANE ") => { let plane = match cmd.trim_start_matches("ISOPLANE").trim() { "LEFT" | "L" => Some(crate::app::settings::IsoPlane::Left), "TOP" | "T" => Some(crate::app::settings::IsoPlane::Top), "RIGHT" | "R" => Some(crate::app::settings::IsoPlane::Right), _ => None, }; if let Some(plane) = plane { return Some(Task::done(Message::SetIsoPlane(plane))); } self.command_line .push_error(crate::t!("ISOPLANE: expected Left, Top, or Right.").as_ref()); } // ISODRAFT — enable or disable isometric drafting. "ISODRAFT" => { return Some(Task::done(Message::ToggleIsometricDrafting)); } cmd if cmd.starts_with("ISODRAFT ") => { let requested = match cmd.trim_start_matches("ISODRAFT").trim() { "1" | "ON" => Some(true), "0" | "OFF" => Some(false), _ => None, }; match requested { Some(value) if value != self.isometric_drafting => { return Some(Task::done(Message::ToggleIsometricDrafting)); } Some(_) => {} None => self .command_line .push_error(crate::t!("ISODRAFT: expected On or Off.").as_ref()), } } // POLAR — toggle polar tracking. "POLAR" => { return Some(Task::done(Message::TogglePolar)); } // DSETTINGS / OSNAP — open the drafting-settings popup, which is OCS's // settings surface (the persisted DYN/ORTHO/POLAR/OSNAP prefs). "DSETTINGS" | "OSNAP" => { return Some(Task::done(Message::ToggleSnapPopup)); } // UNITS — length and angle formats, plus the insertion unit. The // status-bar button covers the length format alone; everything else // about how this drawing writes numbers is here. "UNITS" | "DDUNITS" => { return Some(Task::done(Message::OpenDrawingUnits)); } // ── CLEANSCREEN — collapse the surrounding panels for a full canvas ── "CLEANSCREEN" => { return Some(Task::done(Message::ToggleCleanScreen)); } // ── QUICKPROPERTIES — toggle the floating quick-properties readout ─── "QUICKPROPERTIES" => { return Some(Task::done(Message::ToggleQuickProperties)); } // ── TOOLPALETTES — not yet implemented ─────────────────────────────── "TOOLPALETTES" => { self.command_line .push_info(crate::t!("TOOLPALETTES: Tool Palettes not yet implemented.").as_ref()); } // ── SHEETSET — not yet implemented ─────────────────────────────────── "SHEETSET" => { self.command_line .push_info(crate::t!("SHEETSET: Sheet Set Manager not yet implemented.").as_ref()); } // ── XDATA — read/write extended entity data ────────────────────────── // XDATA LIST — show all xdata records on selected entities // XDATA SET — append a string xdata value for // XDATA CLEAR — remove all xdata from selected entities // XDATA CLEAR — remove xdata for a specific application "XDATA" => { use crate::command::SelectThenKeywordCommand; let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); let c = SelectThenKeywordCommand::new( "XDATA", "XDATA [List / Clear] (SET by typing):", vec![("List", "LIST", None), ("Clear", "CLEAR", None)], has_sel, ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("XDATA ") => { use acadrust::xdata::{ExtendedDataRecord, XDataValue}; let rest = cmd.trim_start_matches("XDATA").trim(); let parts: Vec<&str> = rest.splitn(3, char::is_whitespace).collect(); let sub = parts.first().map(|s| s.to_uppercase()).unwrap_or_default(); let selected_handles: Vec = self.tabs[i] .scene .selected_entities() .iter() .map(|(h, _)| *h) .collect(); if selected_handles.is_empty() { self.command_line .push_error(crate::t!("XDATA: select entities first.").as_ref()); } else { match sub.as_str() { "LIST" | "" => { for sh in &selected_handles { if let Some(entity) = self.tabs[i].scene.document.get_entity(*sh) { let xd = &entity.common().extended_data; if xd.is_empty() { self.command_line .push_output(crate::tf!(" {:x}: no xdata.", sh.value()).as_ref()); } else { for rec in xd.records() { self.command_line.push_output(crate::tf!( " {:x} [{}]: {} value(s)", sh.value(), rec.application_name, rec.values.len() ).as_ref()); for v in &rec.values { self.command_line .push_output(crate::tf!(" {:?}", v).as_ref()); } } } } } } "SET" => { let app = parts.get(1).copied().unwrap_or("OpenCADStudio"); let val = parts.get(2).copied().unwrap_or(""); let editable: Vec<_> = selected_handles .iter() .copied() .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if editable.is_empty() { self.command_line.push_error( crate::t!("XDATA: selected entities are on locked layers.") .as_ref(), ); return Some(Task::none()); } self.push_undo_snapshot(i, "XDATA SET"); for sh in &editable { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*sh) { let mut rec = ExtendedDataRecord::new(app); rec.add_value(XDataValue::String(val.to_string())); entity.common_mut().extended_data.add_record(rec); } } self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!( "XDATA: set [{app}] = \"{val}\" on {} entity/entities.", editable.len() ).as_ref()); } "CLEAR" => { let app_filter = parts.get(1).copied(); let editable: Vec<_> = selected_handles .iter() .copied() .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if editable.is_empty() { self.command_line.push_error( crate::t!("XDATA: selected entities are on locked layers.") .as_ref(), ); return Some(Task::none()); } self.push_undo_snapshot(i, "XDATA CLEAR"); for sh in &editable { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*sh) { let xd = &mut entity.common_mut().extended_data; if let Some(app) = app_filter { // Rebuild without the matching app. let kept: Vec<_> = xd .records() .iter() .filter(|r| r.application_name != app) .cloned() .collect(); xd.clear(); for r in kept { xd.add_record(r); } } else { xd.clear(); } } } self.tabs[i].dirty = true; self.command_line.push_output(crate::t!("XDATA: cleared.").as_ref()); } _ => { self.command_line .push_info(crate::t!("Usage: XDATA LIST | SET | CLEAR [app]").as_ref()); } } } } // ── EXTRUDE ──────────────────────────────────────────────────── "EXTRUDE" | "THICKEN" => { use crate::modules::insert::solid3d_cmds::ExtrudeCommand; // A preselection becomes the complete source set; otherwise // the interactive command gathers any number of profiles. let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); if !selected.is_empty() { let mut cmd = ExtrudeCommand::new_named(cmd, color); let first_curve = selected .iter() .find_map(|(_, entity)| crate::entities::curve::entity_curve(entity)); let anchor = first_curve .as_ref() .map(|curve| glam::DVec3::from_array(curve.plane.origin)) .unwrap_or(glam::DVec3::ZERO); let direction = first_curve .and_then(|curve| curve.plane.normal()) .map(glam::DVec3::from_array); cmd.set_preselection( selected .iter() .map(|(handle, entity)| (*handle, (*entity).clone())) .collect(), anchor, direction, ); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { let cmd = ExtrudeCommand::new_named(cmd, color); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } } "PRESSPULL" => { use crate::modules::insert::solid3d_cmds::PresspullCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); let mut command = PresspullCommand::new(color); command.set_isolines(self.tabs[i].scene.document.header.isolines.max(0) as usize); command.set_preselection(self.presspull_preselection()); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } // ── REVOLVE ──────────────────────────────────────────────────── "REVOLVE" => { use crate::modules::insert::solid3d_cmds::RevolveCommand; let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize; let mut cmd = RevolveCommand::new(color, isolines); if !selected.is_empty() { cmd.set_preselection( selected .iter() .map(|(handle, entity)| (*handle, (*entity).clone())) .collect(), ); } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } // ── SWEEP ────────────────────────────────────────────────────── "SWEEP" => { use crate::modules::insert::solid3d_cmds::SweepCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize; let mut cmd = SweepCommand::new(color, isolines); let selected = self.tabs[i].scene.selected_handles_in_order() .into_iter() .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) .cloned().map(|entity| (handle, entity))) .collect::>(); if !selected.is_empty() { cmd.set_preselection(selected); } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } // ── LOFT ─────────────────────────────────────────────────────── "LOFT" => { use crate::modules::insert::solid3d_cmds::LoftCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize; let selected = self.tabs[i].scene.selected_handles_in_order().into_iter() .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) .cloned().map(|entity| (handle, entity))).collect(); let available = self.tabs[i].scene.document.entities() .map(|entity| (entity.common().handle, entity.clone())).collect(); let cmd = LoftCommand::new(color, isolines, crate::command::ExtrudeMode::Solid, selected, available); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } // ── OBJ import ─────────────────────────────────────────────── "IMPORTOBJ" | "OBJIMPORT" => { return Some(Task::done(Message::ObjImport)); } // ── STL export ──────────────────────────────────────────────── "STLOUT" | "EXPORTSTL" => { return Some(Task::done(Message::StlExport)); } // STEPOUT — export 3D meshes to STEP AP203 format "STEPOUT" | "EXPORTSTEP" | "STPOUT" => { return Some(Task::done(Message::StepExport)); } // ── Plot Style Editor GUI ───────────────────────────────────── "PLOTSTYLEPANEL" | "PLOTSTYLEEDITOR" | "STYLESMANAGER" => { return Some(Task::done(Message::PlotStylePanelOpen)); } // ── Plot / Page Setup ────────────────────────────────────────── // PLOT / PRINT open the full plot dialog (printer, paper, scale, // options); EXPORT / EXPORTPDF stay a direct PDF export. "PLOT" | "PRINT" => { return Some(Task::done(Message::PlotDialogOpen)); } "PRINTALL" => { return Some(Task::done(Message::PrintAllOpen)); } "EXPORT" | "EXPORTPDF" => { return Some(Task::done(Message::PlotExport)); } // PLOTSTYLE — load or clear CTB/STB plot style table cmd if cmd == "PLOTSTYLE" || cmd.starts_with("PLOTSTYLE ") => { let sub = cmd .split_once(' ') .map(|(_, r)| r.trim().to_uppercase()) .unwrap_or_default(); match sub.as_str() { "CLEAR" | "NONE" => { return Some(Task::done(Message::PlotStyleClear)); } "" | "LOAD" => { let active = self .active_plot_style .as_ref() .map(|t| crate::tf!("Active: {}", t.name).into_owned()) .unwrap_or_else(|| crate::t!("No plot style loaded.").into_owned()); self.command_line.push_info(&active); return Some(Task::done(Message::PlotStyleLoad)); } "?" | "STATUS" => { let msg = self .active_plot_style .as_ref() .map(|t| { crate::tf!( "Plot style: {} ({} color overrides)", t.name, t.aci_entries.iter().filter(|e| e.color.is_some()).count() ).into_owned() }) .unwrap_or_else(|| crate::t!("No plot style table loaded.").into_owned()); self.command_line.push_output(&msg); } _ => { self.command_line .push_error(crate::t!("Usage: PLOTSTYLE [LOAD | CLEAR | STATUS]").as_ref()); } } } // UNDERLAY — edit properties of selected PDF/DWF/DGN underlay entities. // Usage: // UNDERLAY FADE <0-80> // UNDERLAY CONTRAST <0-100> // UNDERLAY ON | OFF // UNDERLAY CLIP ON | OFF // UNDERLAY MONO ON | OFF "UNDERLAY" => { use crate::command::SelectThenKeywordCommand; let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); let c = SelectThenKeywordCommand::new( "UNDERLAY", "UNDERLAY [Fade / Contrast / On / Off / Mono / Clip]:", vec![ ("Fade", "FADE", Some("UNDERLAY fade 0-100:")), ("Contrast", "CONTRAST", Some("UNDERLAY contrast 0-100:")), ("On", "ON", None), ("Off", "OFF", None), ("Mono", "MONO", Some("UNDERLAY MONO [On / Off]:")), ("Clip", "CLIP", Some("UNDERLAY CLIP [On / Off]:")), ], has_sel, ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("UNDERLAY ") => { let sub = cmd .split_once(' ') .map(|(_, r)| r.trim().to_uppercase()) .unwrap_or_default(); let handles: Vec = self.tabs[i] .scene .selected_entities() .iter() .map(|(h, _)| *h) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line .push_error(crate::t!("UNDERLAY: select underlay entities first.").as_ref()); } else { let parts: Vec<&str> = sub.splitn(2, char::is_whitespace).collect(); let action = parts.first().copied().unwrap_or(""); let arg = parts.get(1).copied().unwrap_or("").trim(); let mut changed = 0usize; self.push_undo_snapshot(i, "UNDERLAY"); for h in &handles { if let Some(acadrust::EntityType::Underlay(ul)) = self.tabs[i] .scene .document .entities_mut() .find(|e| e.common().handle == *h) { match action { "FADE" => { if let Ok(v) = arg.parse::() { ul.set_fade(v); changed += 1; } } "CONTRAST" => { if let Ok(v) = arg.parse::() { ul.set_contrast(v); changed += 1; } } "ON" => { ul.set_on(true); changed += 1; } "OFF" => { ul.set_on(false); changed += 1; } "CLIP" => match arg { "ON" => { ul.flags |= acadrust::entities::UnderlayDisplayFlags::CLIPPING; changed += 1; } "OFF" => { ul.clear_clip(); changed += 1; } _ => {} }, "MONO" => match arg { "ON" => { ul.set_monochrome(true); changed += 1; } "OFF" => { ul.set_monochrome(false); changed += 1; } _ => {} }, _ => { // No sub-command: print status. self.command_line.push_output(crate::tf!( "Underlay {:x}: fade={}, contrast={}, on={}, clip={}, mono={}", h.value(), ul.fade, ul.contrast, ul.is_on(), ul.is_clipping(), ul.is_monochrome(), ).as_ref()); } } } } if changed > 0 { self.tabs[i].dirty = true; self.command_line .push_info(crate::tf!("Updated {changed} underlay(s).").as_ref()); } else if !action.is_empty() { self.command_line.push_error( crate::t!("Usage: UNDERLAY [FADE |CONTRAST |ON|OFF|CLIP ON|OFF|MONO ON|OFF]").as_ref() ); } } } // PAGESETUP is folded into the unified plot dialog. "PAGESETUP" => { return Some(Task::done(Message::PlotDialogOpen)); } // ── Recognized commands whose full implementation is pending ───────── // These verbs are surfaced by the ribbon / menus but their feature is // still being built. Acknowledge them with an honest status so the // button responds instead of reporting an unknown command; each is // replaced by its real handler as the feature lands. // OBJECTSCALE ADD — add the active scale representation to every // selected object that supports per-scale context data. "OBJECTSCALE ADD" => { let handles: Vec = self.tabs[i] .scene .selected_entities() .iter() .map(|(h, _)| *h) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line .push_error(crate::t!("OBJECTSCALE: select objects first.").as_ref()); return Some(Task::none()); } self.push_undo_snapshot(i, "OBJECTSCALE"); let Some(scale) = self.tabs[i].scene.creation_annotation_scale_handle() else { self.command_line .push_error(crate::t!("OBJECTSCALE: the active annotation scale is unavailable.").as_ref()); return Some(Task::none()); }; let mut n = 0usize; for h in &handles { if crate::scene::annotative::create_annotation_context( &mut self.tabs[i].scene.document, *h, scale, ) { crate::scene::annotative::set_entity_annotative( &mut self.tabs[i].scene.document, *h, true, ); n += 1; } } let changes: Vec<_> = handles .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!( "OBJECTSCALE: added the active scale to {n} object(s)." ).as_ref()); return Some(Task::none()); } // HYPERLINK — attach a hyperlink to the selected objects, stored // in the standard PE_URL XData record so it round-trips in the file. "HYPERLINK" => { use crate::command::SelectThenValueCommand; let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); let c = SelectThenValueCommand::new( "HYPERLINK", "HYPERLINK URL to attach:", has_sel, ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("HYPERLINK ") => { use acadrust::xdata::{ExtendedDataRecord, XDataValue}; let url = cmd.strip_prefix("HYPERLINK").unwrap_or("").trim().to_string(); if url.is_empty() { self.command_line.push_info(crate::t!("Usage: HYPERLINK (select objects first)").as_ref()); return Some(Task::none()); } let handles: Vec = self.tabs[i] .scene .selected_entities() .iter() .map(|(h, _)| *h) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line.push_error(crate::t!("HYPERLINK: select objects first.").as_ref()); return Some(Task::none()); } self.push_undo_snapshot(i, "HYPERLINK"); let mut n = 0usize; for h in &handles { if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) { let xd = &mut e.common_mut().extended_data; let mut rec = ExtendedDataRecord::new("PE_URL"); rec.add_value(XDataValue::String(url.clone())); xd.add_record(rec); n += 1; } } self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("HYPERLINK: attached to {n} object(s).").as_ref()); return Some(Task::none()); } // ADJUST — set brightness / contrast / fade on selected raster images // ADJUST BRIGHTNESS|CONTRAST|FADE <0-100> "ADJUST" => { use crate::command::SelectThenKeywordCommand; let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); let c = SelectThenKeywordCommand::new( "ADJUST", "ADJUST [Brightness / Contrast / Fade]:", vec![ ("Brightness", "BRIGHTNESS", Some("ADJUST brightness 0-100:")), ("Contrast", "CONTRAST", Some("ADJUST contrast 0-100:")), ("Fade", "FADE", Some("ADJUST fade 0-100:")), ], has_sel, ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("ADJUST ") => { let rest = cmd.trim_start_matches("ADJUST").trim(); let parts: Vec<&str> = rest.splitn(2, char::is_whitespace).collect(); let action = parts.first().map(|s| s.to_uppercase()).unwrap_or_default(); let arg = parts.get(1).copied().unwrap_or("").trim(); let handles: Vec = self.tabs[i] .scene .selected_entities() .iter() .map(|(h, _)| *h) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line .push_error(crate::t!("ADJUST: select raster image(s) first.").as_ref()); } else if action.is_empty() { self.command_line .push_info(crate::t!("Usage: ADJUST BRIGHTNESS|CONTRAST|FADE <0-100>").as_ref()); } else if let Ok(v) = arg.parse::() { let v = v.min(100); self.push_undo_snapshot(i, "ADJUST"); let mut changed = 0usize; let mut changed_handles = Vec::new(); for h in &handles { if let Some(acadrust::EntityType::RasterImage(img)) = self.tabs[i] .scene .document .entities_mut() .find(|e| e.common().handle == *h) { match action.as_str() { "BRIGHTNESS" => { img.brightness = v; changed += 1; changed_handles.push(*h); } "CONTRAST" => { img.contrast = v; changed += 1; changed_handles.push(*h); } "FADE" => { img.fade = v; changed += 1; changed_handles.push(*h); } _ => {} } } } if changed > 0 { self.tabs[i].dirty = true; for &handle in &changed_handles { self.tabs[i].scene.reseed_derived_caches(handle); } let changes: Vec<_> = changed_handles .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); self.command_line .push_output(crate::tf!("ADJUST: {action} = {v} on {changed} image(s).").as_ref()); } else { self.command_line.push_error( crate::t!("ADJUST: no raster images selected, or unknown property (use BRIGHTNESS|CONTRAST|FADE).").as_ref(), ); } } else { self.command_line.push_error(crate::t!("ADJUST: value must be 0-100.").as_ref()); } } // ANNOSCALE / CANNOSCALE — set the current annotation scale // (e.g. 1:50, 2:1, or a plain factor). Drives annotative-object size // in model space and is written to the drawing header. "ANNOSCALE" | "CANNOSCALE" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new( "ANNOSCALE", "ANNOSCALE new annotation scale (e.g. 1:50, 2:1, or a factor):", ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } "ANNOALLVISIBLE" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new( "ANNOALLVISIBLE", "ANNOALLVISIBLE new value [0/1]:", ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("ANNOALLVISIBLE ") => { let value = cmd.split_whitespace().nth(1).unwrap_or(""); match value { "0" | "OFF" | "FALSE" => { self.tabs[i].scene.set_annotation_all_visible(false); self.tabs[i].dirty = true; } "1" | "ON" | "TRUE" => { self.tabs[i].scene.set_annotation_all_visible(true); self.tabs[i].dirty = true; } _ => self .command_line .push_error(crate::t!("ANNOALLVISIBLE: enter 0 or 1.").as_ref()), } } "ANNOAUTOSCALE" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new( "ANNOAUTOSCALE", "ANNOAUTOSCALE new value [-4..4]:", ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("ANNOAUTOSCALE ") => { let value = cmd.split_whitespace().nth(1).unwrap_or(""); match value.parse::() { Ok(mode @ -4..=4) => self.annotation_auto_scale = mode, _ => self.command_line.push_error( crate::t!("ANNOAUTOSCALE: enter an integer from -4 through 4.").as_ref(), ), } } "ANNOUPDATE" => { let handles: Vec<_> = self.tabs[i] .scene .selected_entities() .iter() .map(|(handle, _)| *handle) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line .push_error(crate::t!("ANNOUPDATE: select annotation objects first.").as_ref()); return Some(Task::none()); } self.push_undo_snapshot(i, "ANNOUPDATE"); let scale = self.tabs[i].scene.creation_annotation_scale_handle(); let mut updated = 0usize; for handle in &handles { if crate::scene::annotative::update_entity_from_annotation_style( &mut self.tabs[i].scene.document, *handle, scale, ) { updated += 1; } } if updated > 0 { let changes: Vec<_> = handles .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); self.tabs[i].dirty = true; } self.command_line .push_output(crate::tf!("ANNOUPDATE: updated {updated} object(s).").as_ref()); return Some(Task::none()); } cmd if cmd.starts_with("ANNOSCALE ") || cmd.starts_with("CANNOSCALE ") => { let arg = cmd .split_whitespace() .nth(1) .unwrap_or("") .trim() .to_string(); if arg.is_empty() { let name = self.tabs[i] .scene .document .header .current_annotation_scale .clone(); self.command_line .push_output(crate::tf!("Current annotation scale: {name}").as_ref()); return Some(Task::none()); } let previous = self.tabs[i].scene.displayed_annotation_scale_handle(); match self.tabs[i].scene.set_annotation_scale_named(&arg) { Some(handle) => { if self.annotation_auto_scale > 0 { self.tabs[i].scene.add_annotation_scale_to_objects( handle, previous, self.annotation_auto_scale as u8, ); } self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("Annotation scale: {arg}").as_ref()); } None => self .command_line .push_error(crate::t!("Usage: ANNOSCALE e.g. 1:50, 2:1, or a factor").as_ref()), } } // SCALELISTEDIT — list / add / delete the drawing's annotation scales. // SCALELISTEDIT list // SCALELISTEDIT ADD 1:50 add (name is a paper:drawing ratio) // SCALELISTEDIT DELETE 1:50 remove (not the current scale) "SCALELISTEDIT" => { use crate::command::KeywordCommand; let c = KeywordCommand::new( "SCALELISTEDIT", "SCALELISTEDIT [Add / Delete]:", vec![ ("Add", "ADD", Some("SCALELISTEDIT ADD new scale ratio (e.g. 1:50):")), ("Delete", "DELETE", Some("SCALELISTEDIT DELETE scale ratio to remove:")), ], ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("SCALELISTEDIT ") => { let rest = cmd.trim_start_matches("SCALELISTEDIT").trim(); let mut parts = rest.splitn(2, char::is_whitespace); let sub = parts.next().unwrap_or("").to_uppercase(); let arg = parts.next().unwrap_or("").trim(); match sub.as_str() { "ADD" => match arg.split_once(':') { Some((p, d)) => match (p.trim().parse::(), d.trim().parse::()) { (Ok(paper), Ok(drawing)) if paper > 0.0 && drawing > 0.0 => { self.push_undo_snapshot(i, "SCALELISTEDIT"); if self.tabs[i].scene.add_scale(arg, paper, drawing) { self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("Added annotation scale {arg}.").as_ref()); } else { self.command_line .push_info(crate::tf!("Scale {arg} already exists.").as_ref()); } } _ => self .command_line .push_error(crate::t!("SCALELISTEDIT ADD: use a ratio like 1:50.").as_ref()), }, None => self .command_line .push_error(crate::t!("SCALELISTEDIT ADD: use a ratio like 1:50.").as_ref()), }, "DELETE" | "REMOVE" => { let current = self.tabs[i] .scene .document .header .current_annotation_scale .clone(); if arg.is_empty() { self.command_line.push_info(crate::t!("Usage: SCALELISTEDIT DELETE ").as_ref()); } else if arg.eq_ignore_ascii_case(¤t) { self.command_line.push_error(crate::tf!( "Cannot delete the current annotation scale ({arg})." ).as_ref()); } else { self.push_undo_snapshot(i, "SCALELISTEDIT"); if self.tabs[i].scene.remove_scale(arg) { self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("Removed annotation scale {arg}.").as_ref()); } else { self.command_line .push_info(crate::tf!("No annotation scale named {arg}.").as_ref()); } } } "" => { let names: Vec = self.tabs[i] .scene .scale_list() .into_iter() .map(|(n, _, _)| n) .collect(); if names.is_empty() { self.command_line.push_info(crate::t!("No annotation scales defined.").as_ref()); } else { self.command_line .push_output(crate::tf!("Annotation scales: {}", names.join(", ")).as_ref()); } } _ => self .command_line .push_info(crate::t!("Usage: SCALELISTEDIT [ADD 1:50 | DELETE 1:50]").as_ref()), } } // OBJECTSCALE — open the Annotation Object Scale dialog for the // selected object (add / remove its per-object scale representations). // Reachable now that the immediate "add current scale" quick action // moved to the explicit `OBJECTSCALE ADD` keyword above. "OBJECTSCALE" => { return Some(Task::done(Message::AnnoObjectScaleOpen)); } // DATALINK — create a persistent linked table. "DATALINK" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("DATALINK", "DATALINK path to the .csv file:"); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("DATALINK ") => { let path = cmd.trim_start_matches("DATALINK").trim(); if path.is_empty() { self.command_line.push_info( crate::t!("Usage: DATALINK ").as_ref(), ); return Some(Task::none()); } match std::fs::read_to_string(path) { Ok(text) => { 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 { self.command_line .push_error(crate::t!("DATALINK: the CSV file is empty.").as_ref()); return Some(Task::none()); } use acadrust::entities::TableBuilder; use acadrust::types::Vector3; let mut table = TableBuilder::new(nrows, ncols) .at(Vector3::new(0.0, 0.0, 0.0)) .row_height(0.5) .column_width(2.0) .build(); for (r, row) in rows_data.iter().enumerate() { for (c, cell) in row.iter().enumerate() { table.set_cell_text(r, c, cell); } } 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 .push_error(crate::tf!("DATALINK: cannot read \"{path}\": {e}").as_ref()); } } } 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(crate::t!("DATALINKUPDATE: no linked tables found.").as_ref()); 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(crate::t!("DATALINKUPDATE: linked sources could not be read.").as_ref()); 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). "LANDXMLIMPORT" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("LANDXMLIMPORT", "LANDXMLIMPORT path to the .xml file:"); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("LANDXMLIMPORT ") => { let path = cmd.trim_start_matches("LANDXMLIMPORT").trim(); if path.is_empty() { self.command_line.push_info( crate::t!("Usage: LANDXMLIMPORT (imports CgPoint survey points)").as_ref(), ); return Some(Task::none()); } match std::fs::read_to_string(path) { Ok(xml) => { let pts = parse_landxml_cgpoints(&xml); if pts.is_empty() { self.command_line .push_info(crate::t!("LANDXMLIMPORT: no survey points found.").as_ref()); return Some(Task::none()); } self.push_undo_snapshot(i, "LANDXMLIMPORT"); for [x, y, z] in &pts { let mut p = acadrust::entities::Point::new(); p.location = acadrust::types::Vector3::new(*x, *y, *z); self.tabs[i] .scene .add_entity_clone(acadrust::EntityType::Point(p)); } self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!( "LANDXMLIMPORT: imported {} survey point(s). Use ZOOM EXTENTS to view.", pts.len() ).as_ref()); } Err(e) => self .command_line .push_error(crate::tf!("LANDXMLIMPORT: cannot read \"{path}\": {e}").as_ref()), } } "POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS" | "UOSNAP" => { self.command_line .push_info(crate::tf!("{cmd}: not yet implemented.").as_ref()); } _ => return None, } Some(self.finish_dispatch(cmd)) } } // Scan LandXML text for survey points. Each element's text content is // "northing easting elevation"; returned as [easting, northing, elevation] so it // maps to a Point at (X=easting, Y=northing, Z=elevation). Tolerant manual scan // (no XML dependency); handles the standard text-content form. // (landxml cgpoint scan) fn parse_landxml_cgpoints(xml: &str) -> Vec<[f64; 3]> { let mut out = Vec::new(); let mut rest = xml; while let Some(open) = rest.find("". if !matches!( after.chars().next(), Some(' ') | Some('>') | Some('\t') | Some('\n') | Some('\r') ) { rest = after; continue; } let Some(gt) = after.find('>') else { break }; let body = &after[gt + 1..]; let Some(close) = body.find("") else { break; }; let text = &body[..close]; let nums: Vec = text .split_whitespace() .filter_map(|s| s.parse().ok()) .collect(); if nums.len() >= 3 { out.push([nums[1], nums[0], nums[2]]); } rest = &body[close + "".len()..]; } 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; fn fresh_app() -> OpenCADStudio { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app } #[test] fn redraw_requests_and_leaves_geometry_untouched() { let mut app = fresh_app(); let i = app.active_tab; let geom_before = app.tabs[i].scene.geometry_epoch; let block_before = app.tabs[i].scene.block_epoch; let _ = app.run_command_line("REDRAW"); assert!( app.tabs[i].scene.refresh_pending_any(), "REDRAW must leave a pending force request" ); assert_eq!(app.tabs[i].scene.geometry_epoch, geom_before, "REDRAW must not regen"); assert_eq!(app.tabs[i].scene.block_epoch, block_before, "REDRAW must not regen blocks"); } #[test] fn aliases_route_like_full_verbs() { let mut full = fresh_app(); let mut short = fresh_app(); let _ = full.run_command_line("REDRAW"); let _ = short.run_command_line("R"); let i = full.active_tab; assert!(short.tabs[i].scene.refresh_pending_any(), "'R' must trigger REDRAW"); assert_eq!( short.tabs[i].scene.refresh_pending_any(), full.tabs[i].scene.refresh_pending_any(), "'R' and 'REDRAW' must leave the same refresh state" ); } #[test] fn redrawall_marks_all_tiles() { let mut app = fresh_app(); let i = app.active_tab; let _ = app.run_command_line("REDRAWALL"); assert!(app.tabs[i].scene.refresh_pending_any(), "REDRAWALL must leave a force request"); } #[test] fn regen_rebuilds_but_does_not_dirty_document() { let mut app = fresh_app(); let i = app.active_tab; let geom_before = app.tabs[i].scene.geometry_epoch; let block_before = app.tabs[i].scene.block_epoch; app.tabs[i].dirty = false; let _ = app.run_command_line("REGEN"); assert_ne!(app.tabs[i].scene.geometry_epoch, geom_before, "REGEN must regenerate geometry"); assert_ne!(app.tabs[i].scene.block_epoch, block_before, "REGEN must regenerate block epoch"); assert!(!app.tabs[i].dirty, "REGEN must NOT mark the document as modified (no DB change)"); let _ = app.run_command_line("REGENALL"); assert!(!app.tabs[i].dirty, "REGENALL must not dirty the document either"); } }