cad-editor/src/app/commands/display.rs
Hakan Seven c0844ad55c feat(annotative): synthesize per-object contexts + editable UI
Make objects genuinely annotative — carrying real per-scale
representations that interoperate with other CAD apps — rather than just
a native flag. `create_annotation_context` synthesizes the extension-dict
chain (AcDbContextDataManager -> ACDB_ANNOTATIONSCALES -> per-scale leaf)
from the entity's placement; the editable Annotative toggle now covers
Text and block references as well as MText/MLeader; and a new
"Annotation Object Scale" dialog (OBJECTSCALE) adds/removes an object's
per-scale memberships. Requires the acadrust ObjectContextData encoder
(Cargo.lock bump).

Also fixes a render bug where objects carrying an *empty*
ACDB_ANNOTATIONSCALES (a single-representation marker with no per-scale
reps) were treated as annotative and (mis)scaled by the annotation factor
in scaled paper viewports, ballooning the text: an object is now
annotative-by-context only when its scale collection is non-empty, so
such objects render at their base geometry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:38:29 +03:00

928 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.command_line
.push_output("PAN: drag with the left mouse button. Press Esc to exit.");
}
// ── TABLE cell editing ─────────────────────────────────────────────
// TABLE CELL <row> <col> <text> — set text for a cell in the selected Table
cmd if cmd.starts_with("TABLE ") => {
let rest = cmd.trim_start_matches("TABLE").trim();
let sub_up = rest.split_whitespace().next().unwrap_or("").to_uppercase();
if sub_up == "CELL" {
let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect();
// parts: ["CELL", "<row>", "<col>", "<text>"]
let row_res = parts.get(1).and_then(|s| s.parse::<usize>().ok());
let col_res = parts.get(2).and_then(|s| s.parse::<usize>().ok());
let text = parts.get(3).copied().unwrap_or("");
match (row_res, col_res) {
(Some(row), Some(col)) => {
let selected_handles: Vec<acadrust::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
let mut found = false;
for sh in &selected_handles {
if let Some(acadrust::EntityType::Table(tbl)) = self.tabs[i]
.scene
.document
.entities_mut()
.find(|e| e.common().handle == *sh)
{
if tbl.set_cell_text(row, col, text) {
found = true;
}
}
}
if found {
self.push_undo_snapshot(i, "TABLE CELL");
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"TABLE CELL: set [{row},{col}] = \"{text}\"."
));
} else {
self.command_line.push_error(
"TABLE CELL: select a Table entity first, or row/col out of range."
);
}
}
_ => {
self.command_line
.push_info("Usage: TABLE CELL <row> <col> <text>");
}
}
} else {
self.command_line.push_info(
"Usage: TABLE (creates new table) or TABLE CELL <row> <col> <text>",
);
}
}
// ── 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
cmd if cmd == "UCSICON" || 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(&format!(
"UCSICON {sub}: updated {count} viewport(s) + model space."
));
}
"" => {
// 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(&format!("UCSICON {state}"));
}
_ => {
self.command_line
.push_info("Usage: UCSICON ON | OFF | NOORIGIN | ORIGIN");
}
}
}
// ── NAVVCUBE — toggle ViewCube visibility ────────────────────────────
"NAVVCUBE" => {
return Some(Task::done(Message::ToggleViewCube));
}
// ── 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));
}
// ── 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));
}
// 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 — open the drawing-units picker (linear / angular format).
"UNITS" | "DDUNITS" => {
return Some(Task::done(Message::ToggleUnitsPopup));
}
// ── 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("TOOLPALETTES: Tool Palettes not yet implemented.");
}
// ── SHEETSET — not yet implemented ───────────────────────────────────
"SHEETSET" => {
self.command_line
.push_info("SHEETSET: Sheet Set Manager not yet implemented.");
}
// ── 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
cmd if cmd == "XDATA" || 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<acadrust::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
if selected_handles.is_empty() {
self.command_line
.push_error("XDATA: select entities first.");
} 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(&format!(" {:x}: no xdata.", sh.value()));
} else {
for rec in xd.records() {
self.command_line.push_output(&format!(
" {:x} [{}]: {} value(s)",
sh.value(),
rec.application_name,
rec.values.len()
));
for v in &rec.values {
self.command_line
.push_output(&format!(" {:?}", v));
}
}
}
}
}
}
"SET" => {
let app = parts.get(1).copied().unwrap_or("OpenCADStudio");
let val = parts.get(2).copied().unwrap_or("");
self.push_undo_snapshot(i, "XDATA SET");
for sh in &selected_handles {
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(&format!(
"XDATA: set [{app}] = \"{val}\" on {} entity/entities.",
selected_handles.len()
));
}
"CLEAR" => {
let app_filter = parts.get(1).copied();
self.push_undo_snapshot(i, "XDATA CLEAR");
for sh in &selected_handles {
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("XDATA: cleared.");
}
_ => {
self.command_line
.push_info("Usage: XDATA LIST | SET <app> <value> | CLEAR [app]");
}
}
}
}
// BOX / SPHERE / CYLINDER / CONE / WEDGE / TORUS are handled by the
// Model-tab primitive command above (with truck boolean caching).
// ── EXTRUDE ────────────────────────────────────────────────────
// PRESSPULL on a closed boundary creates a solid by extruding it to a
// height — the same operation as EXTRUDE. THICKEN turns a closed planar
// profile into a solid of the given thickness, which is also an extrude.
"EXTRUDE" | "PRESSPULL" | "THICKEN" => {
use crate::modules::insert::solid3d_cmds::ExtrudeCommand;
// If a single entity is already selected, skip the pick step.
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.len() == 1 {
let handle = selected[0].0;
let mut cmd = ExtrudeCommand::new(color);
cmd.on_entity_pick(handle, glam::DVec3::ZERO);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
} else {
let cmd = ExtrudeCommand::new(color);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
}
// ── REVOLVE ────────────────────────────────────────────────────
"REVOLVE" => {
use crate::modules::insert::solid3d_cmds::RevolveCommand;
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
let cmd = RevolveCommand::new(color);
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 cmd = SweepCommand::new(color);
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 cmd = LoftCommand::new(color);
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));
}
"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| format!("Active: {}", t.name))
.unwrap_or_else(|| "No plot style loaded.".into());
self.command_line.push_info(&active);
return Some(Task::done(Message::PlotStyleLoad));
}
"?" | "STATUS" => {
let msg = self
.active_plot_style
.as_ref()
.map(|t| {
format!(
"Plot style: {} ({} color overrides)",
t.name,
t.aci_entries.iter().filter(|e| e.color.is_some()).count()
)
})
.unwrap_or_else(|| "No plot style table loaded.".into());
self.command_line.push_output(&msg);
}
_ => {
self.command_line
.push_error("Usage: PLOTSTYLE [LOAD | CLEAR | STATUS]");
}
}
}
// 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
cmd if cmd == "UNDERLAY" || cmd.starts_with("UNDERLAY ") => {
let sub = cmd
.split_once(' ')
.map(|(_, r)| r.trim().to_uppercase())
.unwrap_or_default();
let handles: Vec<acadrust::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
if handles.is_empty() {
self.command_line
.push_error("UNDERLAY: select underlay entities first.");
} 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::<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 |=
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(&format!(
"Underlay {:x}: fade={}, contrast={}, on={}, clip={}, mono={}",
h.value(),
ul.fade,
ul.contrast,
ul.is_on(),
ul.is_clipping(),
ul.is_monochrome(),
));
}
}
}
}
if changed > 0 {
self.tabs[i].dirty = true;
self.command_line
.push_info(&format!("Updated {changed} underlay(s)."));
} else if !action.is_empty() {
self.command_line.push_error(
"Usage: UNDERLAY [FADE <n>|CONTRAST <n>|ON|OFF|CLIP ON|OFF|MONO ON|OFF]"
);
}
}
}
// 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 — mark the selected objects annotative by attaching the
// AcAnnotativeData XData record the tessellator already honours, so
// they scale with the current annotation scale.
cmd if cmd == "OBJECTSCALE" || cmd.starts_with("OBJECTSCALE ") => {
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
let handles: Vec<acadrust::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
if handles.is_empty() {
self.command_line
.push_error("OBJECTSCALE: select objects first.");
return Some(Task::none());
}
self.push_undo_snapshot(i, "OBJECTSCALE");
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;
if xd.get_record("AcAnnotativeData").is_none() {
let mut rec = ExtendedDataRecord::new("AcAnnotativeData");
rec.add_value(XDataValue::String("1".to_string()));
xd.add_record(rec);
}
n += 1;
}
}
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"OBJECTSCALE: marked {n} object(s) annotative (they scale with the annotation scale)."
));
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.
cmd if cmd == "HYPERLINK" || 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("Usage: HYPERLINK <url> (select objects first)");
return Some(Task::none());
}
let handles: Vec<acadrust::Handle> =
self.tabs[i].scene.selected_entities().iter().map(|(h, _)| *h).collect();
if handles.is_empty() {
self.command_line.push_error("HYPERLINK: select objects first.");
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(&format!("HYPERLINK: attached to {n} object(s)."));
return Some(Task::none());
}
// ADJUST — set brightness / contrast / fade on selected raster images
// ADJUST BRIGHTNESS|CONTRAST|FADE <0-100>
cmd if cmd == "ADJUST" || 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<acadrust::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
if handles.is_empty() {
self.command_line
.push_error("ADJUST: select raster image(s) first.");
} else if action.is_empty() {
self.command_line
.push_info("Usage: ADJUST BRIGHTNESS|CONTRAST|FADE <0-100>");
} else if let Ok(v) = arg.parse::<u8>() {
let v = v.min(100);
self.push_undo_snapshot(i, "ADJUST");
let mut changed = 0usize;
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;
}
"CONTRAST" => {
img.contrast = v;
changed += 1;
}
"FADE" => {
img.fade = v;
changed += 1;
}
_ => {}
}
}
}
if changed > 0 {
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
self.command_line
.push_output(&format!("ADJUST: {action} = {v} on {changed} image(s)."));
} else {
self.command_line.push_error(
"ADJUST: no raster images selected, or unknown property (use BRIGHTNESS|CONTRAST|FADE).",
);
}
} else {
self.command_line.push_error("ADJUST: value must be 0-100.");
}
}
// 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.
cmd if cmd == "ANNOSCALE"
|| cmd == "CANNOSCALE"
|| 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(&format!("Current annotation scale: {name}"));
return Some(Task::none());
}
// anno multiplier = denominator / numerator: 1:50 → 50, 2:1 → 0.5.
let anno = if let Some((a, b)) = arg.split_once(':') {
match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
(Ok(a), Ok(b)) if a != 0.0 => Some((b / a) as f32),
_ => None,
}
} else {
arg.parse::<f32>().ok()
};
match anno {
Some(v) if v > 0.0 => {
self.tabs[i].scene.annotation_scale = v;
let hdr = &mut self.tabs[i].scene.document.header;
hdr.current_annotation_scale = arg.clone();
hdr.annotation_scale_value = 1.0 / v as f64;
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("Annotation scale: {arg}"));
}
_ => self
.command_line
.push_error("Usage: ANNOSCALE <ratio> e.g. 1:50, 2:1, or a factor"),
}
}
// 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)
cmd if cmd == "SCALELISTEDIT" || 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(&format!("Added annotation scale {arg}."));
} else {
self.command_line
.push_info(&format!("Scale {arg} already exists."));
}
}
_ => self
.command_line
.push_error("SCALELISTEDIT ADD: use a ratio like 1:50."),
},
None => self
.command_line
.push_error("SCALELISTEDIT ADD: use a ratio like 1:50."),
},
"DELETE" | "REMOVE" => {
let current = self.tabs[i]
.scene
.document
.header
.current_annotation_scale
.clone();
if arg.is_empty() {
self.command_line.push_info("Usage: SCALELISTEDIT DELETE <name>");
} else if arg.eq_ignore_ascii_case(&current) {
self.command_line.push_error(&format!(
"Cannot delete the current annotation scale ({arg})."
));
} 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(&format!("Removed annotation scale {arg}."));
} else {
self.command_line
.push_info(&format!("No annotation scale named {arg}."));
}
}
}
"" => {
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("No annotation scales defined.");
} else {
self.command_line
.push_output(&format!("Annotation scales: {}", names.join(", ")));
}
}
_ => self
.command_line
.push_info("Usage: SCALELISTEDIT [ADD 1:50 | DELETE 1:50]"),
}
}
// OBJECTSCALE — open the Annotation Object Scale dialog for the
// selected object (add / remove its per-object scale representations).
"OBJECTSCALE" => {
return Some(Task::done(Message::AnnoObjectScaleOpen));
}
// DATALINK <path.csv> — import a CSV file into a table placed at the
// origin (one-time import; a live re-reading link is future work).
cmd if cmd == "DATALINK" || cmd.starts_with("DATALINK ") => {
let path = cmd.trim_start_matches("DATALINK").trim();
if path.is_empty() {
self.command_line.push_info(
"Usage: DATALINK <path-to-.csv> — imports the CSV into a table at the origin.",
);
return Some(Task::none());
}
match std::fs::read_to_string(path) {
Ok(text) => {
let rows_data: Vec<Vec<String>> = text
.lines()
.filter(|l| !l.trim().is_empty())
.map(|line| line.split(',').map(|s| s.trim().to_string()).collect())
.collect();
let 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("DATALINK: the CSV file is empty.");
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);
}
}
self.push_undo_snapshot(i, "DATALINK");
self.tabs[i]
.scene
.add_entity_clone(acadrust::EntityType::Table(table));
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"DATALINK: imported {nrows}×{ncols} cells into a table at the origin."
));
}
Err(e) => {
self.command_line
.push_error(&format!("DATALINK: cannot read \"{path}\": {e}"));
}
}
}
// LANDXMLIMPORT <path> — import survey points (LandXML <CgPoint>
// elements) as Point objects. Reads the coordinate text content
// (northing easting elevation) → Point at (easting, northing, elev).
cmd if cmd == "LANDXMLIMPORT" || cmd.starts_with("LANDXMLIMPORT ") => {
let path = cmd.trim_start_matches("LANDXMLIMPORT").trim();
if path.is_empty() {
self.command_line.push_info(
"Usage: LANDXMLIMPORT <path-to-.xml> (imports CgPoint survey points)",
);
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("LANDXMLIMPORT: no <CgPoint> survey points found.");
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].scene.bump_geometry();
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"LANDXMLIMPORT: imported {} survey point(s). Use ZOOM EXTENTS to view.",
pts.len()
));
}
Err(e) => self
.command_line
.push_error(&format!("LANDXMLIMPORT: cannot read \"{path}\": {e}")),
}
}
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS"
| "UOSNAP" => {
self.command_line
.push_info(&format!("{cmd}: not yet implemented."));
}
_ => 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
}