forked from 1b-agent/opencadstudio-upstream
An image's transparency flag decides whether transparent pixels show what is behind the image or draw in their stored colour: the image pass and PDF plots now follow it (it was ignored, so every PNG alpha showed through). TRANSPARENCY selects images and asks for the mode, defaulting to the first image's current one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
2188 lines
105 KiB
Rust
2188 lines
105 KiB
Rust
use super::*;
|
|
|
|
impl OpenCADStudio {
|
|
pub(super) fn dispatch_display(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
|
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, codec::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,
|
|
}
|
|
}
|
|
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,
|
|
codec::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<codec::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(codec::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 codec::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(codec::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 codec::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 codec::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 codec::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 codec::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 codec::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 codec::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<Vec<f64>, _> =
|
|
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());
|
|
}
|
|
// Rebuild tessellation caches without modifying the document.
|
|
"REGEN" | "REGENALL" => {
|
|
self.tabs[i].scene.populate_meshes_from_document();
|
|
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 <app> <str> — append a string xdata value for <app>
|
|
// XDATA CLEAR — remove all xdata from selected entities
|
|
// XDATA CLEAR <app> — 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 <app> <value> 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 codec::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<codec::Handle> = 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 <app> <value> | CLEAR [app]").as_ref());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── EXTRUDE ────────────────────────────────────────────────────
|
|
"EXTRUDE" => {
|
|
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));
|
|
}
|
|
}
|
|
|
|
"THICKEN" => {
|
|
use crate::modules::insert::solid3d_cmds::ThickenCommand;
|
|
let selected = self.tabs[i]
|
|
.scene
|
|
.selected_entities()
|
|
.into_iter()
|
|
.map(|(handle, entity)| (handle, entity.clone()))
|
|
.collect();
|
|
let command = ThickenCommand::new(selected);
|
|
self.command_line.push_info(&command.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(command));
|
|
}
|
|
|
|
"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::<Vec<_>>();
|
|
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));
|
|
}
|
|
// PRINTERS — what the system reports about printing, on the
|
|
// command line: the default printer, every printer it lists (or
|
|
// why it could not), and how a plot will reach them. The report
|
|
// a user pastes into a bug report instead of a screenshot.
|
|
"PRINTERS" => {
|
|
for line in crate::io::print_to_printer::printer_report() {
|
|
self.command_line.push_output(&line);
|
|
}
|
|
}
|
|
// PRINTERS <name> — what that printer reports about its sheets
|
|
// and printable area (asks the driver; may take a moment for an
|
|
// offline network queue).
|
|
cmd if cmd.starts_with("PRINTERS ") => {
|
|
let name = cmd["PRINTERS ".len()..].trim();
|
|
for line in crate::io::print_to_printer::printer_media_report(name) {
|
|
self.command_line.push_output(&line);
|
|
}
|
|
}
|
|
"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());
|
|
}
|
|
}
|
|
}
|
|
// TRANSPARENCY — raster images show transparent pixels (ON) or
|
|
// draw every pixel in its colour (OFF).
|
|
"TRANSPARENCY" => {
|
|
let c = crate::modules::insert::image_transparency::TransparencyCommand::new();
|
|
self.command_line.push_info(&c.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(c));
|
|
}
|
|
"TRANSPARENCY MODE" | "TRANSPARENCY ON" | "TRANSPARENCY OFF" => {
|
|
use codec::entities::ImageDisplayFlags;
|
|
let images: Vec<(codec::Handle, bool)> = self.tabs[i]
|
|
.scene
|
|
.selected_entities()
|
|
.iter()
|
|
.filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle))
|
|
.filter_map(|(handle, _)| match self.tabs[i].scene.document.get_entity(*handle) {
|
|
Some(codec::EntityType::RasterImage(image)) => {
|
|
Some((*handle, image.flags.contains(ImageDisplayFlags::TRANSPARENCY_ON)))
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let Some(&(_, first_on)) = images.first() else {
|
|
return Some(Task::none());
|
|
};
|
|
let handles: Vec<codec::Handle> = images.iter().map(|(handle, _)| *handle).collect();
|
|
if cmd == "TRANSPARENCY MODE" {
|
|
let c = crate::modules::insert::image_transparency::TransparencyCommand::mode(
|
|
handles, first_on,
|
|
);
|
|
self.command_line.push_info(&c.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(c));
|
|
} else {
|
|
let on = cmd == "TRANSPARENCY ON";
|
|
self.push_undo_snapshot(i, "TRANSPARENCY");
|
|
for handle in &handles {
|
|
if let Some(codec::EntityType::RasterImage(image)) =
|
|
self.tabs[i].scene.document.get_entity_mut(*handle)
|
|
{
|
|
image.flags.set(ImageDisplayFlags::TRANSPARENCY_ON, on);
|
|
}
|
|
}
|
|
let changes: Vec<_> = handles
|
|
.iter()
|
|
.map(|handle| (*handle, crate::scene::ChangeKind::Modified))
|
|
.collect();
|
|
self.tabs[i].scene.bump_entities(&changes);
|
|
self.tabs[i].dirty = true;
|
|
}
|
|
}
|
|
// 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<codec::Handle> = 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(codec::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::<u8>() {
|
|
ul.set_fade(v);
|
|
changed += 1;
|
|
}
|
|
}
|
|
"CONTRAST" => {
|
|
if let Ok(v) = arg.parse::<u8>() {
|
|
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 |=
|
|
codec::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 <n>|CONTRAST <n>|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));
|
|
}
|
|
|
|
// Add or remove the current representation on the selected
|
|
// annotative objects. With no preselection, gather one through the
|
|
// regular selection engine before applying the action.
|
|
action @ ("OBJECTSCALE ADD" | "OBJECTSCALE DELETE") => {
|
|
let selected: Vec<codec::Handle> = self.tabs[i]
|
|
.scene
|
|
.selected_entities()
|
|
.iter()
|
|
.map(|(h, _)| *h)
|
|
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
|
|
.collect();
|
|
if selected.is_empty() {
|
|
let action = if action.ends_with("ADD") {
|
|
"OBJECTSCALE ADD"
|
|
} else {
|
|
"OBJECTSCALE DELETE"
|
|
};
|
|
let command = crate::modules::annotate::annotation_scale::AnnotationScaleSelectionCommand::new(
|
|
"OBJECTSCALE",
|
|
action,
|
|
);
|
|
self.command_line
|
|
.push_info(&crate::command::CadCommand::prompt(&command));
|
|
self.tabs[i].active_cmd = Some(Box::new(command));
|
|
return Some(Task::none());
|
|
}
|
|
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 adding = action.ends_with("ADD");
|
|
let handles: Vec<_> = selected
|
|
.into_iter()
|
|
.filter(|handle| {
|
|
let memberships = crate::scene::annotative::object_scale_memberships(
|
|
&self.tabs[i].scene.document,
|
|
*handle,
|
|
);
|
|
let member = memberships.iter().any(|(_, current)| *current == scale);
|
|
if adding {
|
|
!member
|
|
&& self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(*handle)
|
|
.is_some_and(crate::scene::annotative::supports_annotation_context)
|
|
} else {
|
|
member
|
|
}
|
|
})
|
|
.collect();
|
|
if handles.is_empty() {
|
|
self.command_line.push_info(
|
|
if adding {
|
|
crate::t!("OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales.")
|
|
} else {
|
|
crate::t!("OBJECTSCALE: the selected objects do not have the current scale.")
|
|
}
|
|
.as_ref(),
|
|
);
|
|
return Some(Task::none());
|
|
}
|
|
|
|
self.push_undo_snapshot(i, "OBJECTSCALE");
|
|
let mut changed = Vec::new();
|
|
for handle in handles {
|
|
let ok = if adding {
|
|
let created = crate::scene::annotative::create_annotation_context(
|
|
&mut self.tabs[i].scene.document,
|
|
handle,
|
|
scale,
|
|
);
|
|
if created {
|
|
crate::scene::annotative::set_entity_annotative(
|
|
&mut self.tabs[i].scene.document,
|
|
handle,
|
|
true,
|
|
);
|
|
}
|
|
created
|
|
} else {
|
|
crate::scene::annotative::remove_annotation_context_for_scale(
|
|
&mut self.tabs[i].scene.document,
|
|
handle,
|
|
scale,
|
|
)
|
|
};
|
|
if ok {
|
|
changed.push(handle);
|
|
}
|
|
}
|
|
let changes: Vec<_> = changed
|
|
.iter()
|
|
.copied()
|
|
.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: {} the current scale on {} object(s).",
|
|
if adding { "added" } else { "removed" },
|
|
changed.len()
|
|
).as_ref());
|
|
return Some(Task::none());
|
|
}
|
|
|
|
// HYPERLINK <url> — 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 codec::xdata::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 <url> (select objects first)").as_ref());
|
|
return Some(Task::none());
|
|
}
|
|
let handles: Vec<codec::Handle> = 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 self.tabs[i].scene.document.get_entity(*h).is_some() {
|
|
crate::scene::view::dispatch::set_entity_xdata(
|
|
&mut self.tabs[i].scene.document,
|
|
*h,
|
|
"PE_URL",
|
|
Some(vec![XDataValue::String(url.clone())]),
|
|
);
|
|
n += 1;
|
|
}
|
|
}
|
|
self.invalidate_property_targets(i, &handles);
|
|
self.tabs[i].dirty = true;
|
|
self.refresh_properties();
|
|
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<codec::Handle> = 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::<u8>() {
|
|
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(codec::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 <ratio> — 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::<i8>() {
|
|
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 <ratio> 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::<f64>(), d.trim().parse::<f64>()) {
|
|
(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 <name>").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<String> = 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));
|
|
}
|
|
|
|
// Reset every alternate scale representation to the position of
|
|
// the representation visible at the current annotation scale.
|
|
"ANNORESET" => {
|
|
let handles: Vec<codec::Handle> = 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() {
|
|
let command = crate::modules::annotate::annotation_scale::AnnotationScaleSelectionCommand::new(
|
|
"ANNORESET",
|
|
"ANNORESET",
|
|
);
|
|
self.command_line
|
|
.push_info(&crate::command::CadCommand::prompt(&command));
|
|
self.tabs[i].active_cmd = Some(Box::new(command));
|
|
return Some(Task::none());
|
|
}
|
|
let scale = self.tabs[i].scene.creation_annotation_scale_handle();
|
|
let candidates: Vec<_> = handles
|
|
.into_iter()
|
|
.filter(|handle| {
|
|
crate::scene::annotative::object_scale_memberships(
|
|
&self.tabs[i].scene.document,
|
|
*handle,
|
|
)
|
|
.len()
|
|
> 1
|
|
})
|
|
.collect();
|
|
if candidates.is_empty() {
|
|
self.command_line.push_info(
|
|
crate::t!("ANNORESET: no selected object has alternate scale representations.")
|
|
.as_ref(),
|
|
);
|
|
return Some(Task::none());
|
|
}
|
|
self.push_undo_snapshot(i, "ANNORESET");
|
|
let mut changed = Vec::new();
|
|
for handle in candidates {
|
|
if crate::scene::annotative::reset_annotation_context_positions(
|
|
&mut self.tabs[i].scene.document,
|
|
handle,
|
|
scale,
|
|
) {
|
|
changed.push(handle);
|
|
}
|
|
}
|
|
if !changed.is_empty() {
|
|
let changes: Vec<_> = changed
|
|
.iter()
|
|
.copied()
|
|
.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!(
|
|
"ANNORESET: synchronized {} object(s) from the current scale.",
|
|
changed.len()
|
|
)
|
|
.as_ref(),
|
|
);
|
|
return Some(Task::none());
|
|
}
|
|
|
|
// DATALINK <path.csv> — create a persistent linked table.
|
|
"DATALINK" => {
|
|
self.open_data_link_manager(false);
|
|
}
|
|
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 <path-to-.csv>").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 codec::entities::TableBuilder;
|
|
use codec::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 {
|
|
codec::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<codec::Handle> = self.tabs[i]
|
|
.scene
|
|
.selected_entities()
|
|
.into_iter()
|
|
.filter_map(|(handle, entity)| {
|
|
matches!(entity, codec::EntityType::Table(_)).then_some(handle)
|
|
})
|
|
.collect();
|
|
let table_handles = if selected.is_empty() {
|
|
self.tabs[i]
|
|
.scene
|
|
.document
|
|
.entities()
|
|
.filter_map(|entity| {
|
|
matches!(entity, codec::EntityType::Table(_))
|
|
.then_some(entity.common().handle)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
selected
|
|
};
|
|
let mut jobs = Vec::new();
|
|
for handle in &table_handles {
|
|
let Some(codec::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;
|
|
};
|
|
jobs.push((*handle, link_handle));
|
|
}
|
|
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;
|
|
let mut errors = Vec::new();
|
|
for (table_handle, link_handle) in &jobs {
|
|
let Some(codec::EntityType::Table(table)) =
|
|
self.tabs[i].scene.document.get_entity(*table_handle)
|
|
else {
|
|
continue;
|
|
};
|
|
let csv = table_to_csv(table);
|
|
match crate::app::annotation_data::data_link_write_path(
|
|
&self.tabs[i].scene.document,
|
|
*link_handle,
|
|
)
|
|
.and_then(|path| std::fs::write(path, csv).map_err(|error| error.to_string()))
|
|
{
|
|
Ok(()) => written += 1,
|
|
Err(error) => errors.push(error),
|
|
}
|
|
}
|
|
if written > 0 {
|
|
self.command_line.push_output(
|
|
crate::tf!("DATALINKUPDATE: wrote {} linked source(s).", written).as_ref(),
|
|
);
|
|
}
|
|
for error in errors {
|
|
self.command_line.push_error(&error);
|
|
}
|
|
return Some(Task::none());
|
|
}
|
|
let updates: Vec<_> = jobs
|
|
.into_iter()
|
|
.filter_map(|(table_handle, link_handle)| {
|
|
crate::app::annotation_data::read_data_link(
|
|
&self.tabs[i].scene.document,
|
|
link_handle,
|
|
)
|
|
.ok()
|
|
.map(|rows| (table_handle, link_handle, rows))
|
|
})
|
|
.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 codec::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(codec::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).
|
|
"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 <path-to-.xml> (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 <CgPoint> survey points found.").as_ref());
|
|
return Some(Task::none());
|
|
}
|
|
self.push_undo_snapshot(i, "LANDXMLIMPORT");
|
|
for [x, y, z] in &pts {
|
|
let mut p = codec::entities::Point::new();
|
|
p.location = codec::types::Vector3::new(*x, *y, *z);
|
|
self.tabs[i]
|
|
.scene
|
|
.add_entity_clone(codec::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()),
|
|
}
|
|
}
|
|
|
|
"UNDERLAYLAYERS" | "ULAYERS" => self.open_underlay_layers_dialog(i),
|
|
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" => {
|
|
self.command_line
|
|
.push_info(crate::tf!("{cmd}: not yet implemented.").as_ref());
|
|
}
|
|
|
|
_ => return None,
|
|
}
|
|
Some(self.finish_dispatch(cmd))
|
|
}
|
|
}
|
|
|
|
// Scan LandXML text for <CgPoint> 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("<CgPoint") {
|
|
let after = &rest[open + "<CgPoint".len()..];
|
|
// Skip the container element "<CgPoints>".
|
|
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("</CgPoint>") else {
|
|
break;
|
|
};
|
|
let text = &body[..close];
|
|
let nums: Vec<f64> = 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 + "</CgPoint>".len()..];
|
|
}
|
|
out
|
|
}
|
|
|
|
pub(crate) 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
|
|
}
|
|
|
|
pub(crate) fn table_to_csv(table: &codec::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;
|
|
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
fn regen_rebuilds_the_mesh_map_rather_than_only_bumping_the_epoch() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let i = app.active_tab;
|
|
|
|
let stale = codec::Handle::new(0xDEAD);
|
|
app.tabs[i].scene.meshes.insert(stale, stale_mesh());
|
|
let epoch_before = app.tabs[i].scene.geometry_epoch;
|
|
|
|
let _ = app.run_command_line("REGEN");
|
|
|
|
assert!(
|
|
!app.tabs[i].scene.meshes.contains_key(&stale),
|
|
"REGEN left a stale mesh",
|
|
);
|
|
assert_ne!(
|
|
app.tabs[i].scene.geometry_epoch, epoch_before,
|
|
"REGEN did not bump the geometry epoch",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_isolines_slider_rebuilds_once_on_release_and_not_while_dragging() {
|
|
use crate::app::Message;
|
|
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let i = app.active_tab;
|
|
|
|
let seed = |app: &mut OpenCADStudio, i: usize| {
|
|
let stale = codec::Handle::new(0xBEEF);
|
|
app.tabs[i].scene.meshes.insert(stale, stale_mesh());
|
|
stale
|
|
};
|
|
let stale = seed(&mut app, i);
|
|
|
|
for value in [4i16, 3, 2, 1, 0] {
|
|
let _ = app.update(Message::IsolinesChanged(value));
|
|
}
|
|
assert!(
|
|
app.tabs[i].scene.meshes.contains_key(&stale),
|
|
"the drag itself must not rebuild the meshes",
|
|
);
|
|
assert_eq!(app.tabs[i].scene.document.header.isolines, 0);
|
|
|
|
let _ = app.update(Message::IsolinesReleased);
|
|
assert!(
|
|
!app.tabs[i].scene.meshes.contains_key(&stale),
|
|
"releasing after a change rebuilds them",
|
|
);
|
|
|
|
let stale = seed(&mut app, i);
|
|
let _ = app.update(Message::IsolinesChanged(0));
|
|
let _ = app.update(Message::IsolinesReleased);
|
|
assert!(
|
|
app.tabs[i].scene.meshes.contains_key(&stale),
|
|
"releasing without a change must rebuild nothing",
|
|
);
|
|
}
|
|
|
|
fn stale_mesh() -> crate::scene::model::mesh_model::MeshLodSet {
|
|
crate::scene::model::mesh_model::MeshLodSet {
|
|
lods: Vec::new(),
|
|
material: None,
|
|
face_materials: Default::default(),
|
|
visual_style: None,
|
|
complete: true,
|
|
edge_verts: Vec::new(),
|
|
edge_verts_low: Vec::new(),
|
|
curved_gens: Vec::new(),
|
|
metrics: Default::default(),
|
|
world_aabb: [0.0; 4],
|
|
z_aabb: [0.0; 2],
|
|
instance_source: None,
|
|
instance_transform: None,
|
|
instance_handle: None,
|
|
instance_color: None,
|
|
instance_aabb: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod printers_command_tests {
|
|
use crate::app::OpenCADStudio;
|
|
|
|
#[test]
|
|
fn printers_reports_the_default_and_the_list() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let before = app.command_line.history.len();
|
|
let _ = app.run_command_line("PRINTERS");
|
|
let lines: Vec<String> = app.command_line.history[before..]
|
|
.iter()
|
|
.map(|line| line.text.clone())
|
|
.collect();
|
|
// One line for the default printer, at least one for the list (a
|
|
// count and names, "none", or the system's error).
|
|
assert!(lines.len() >= 2, "{lines:?}");
|
|
}
|
|
}
|