From f32d0422d2ca82ec4355c95c52d95680cf931a79 Mon Sep 17 00:00:00 2001 From: yztink Date: Thu, 1 Oct 2026 23:34:15 -0700 Subject: [PATCH] fix(script): support AutoCAD .scr scripts with multi-line, single-line, and prefix handling --- src/app/command_driver/utilities.rs | 58 ++++++++++ src/app/commands/fileops.rs | 159 ++++++++++++++++++++++++---- src/app/commands/mod.rs | 5 + src/app/mod.rs | 15 ++- src/app/update/file.rs | 8 +- src/app/update/mod.rs | 11 +- 6 files changed, 222 insertions(+), 34 deletions(-) diff --git a/src/app/command_driver/utilities.rs b/src/app/command_driver/utilities.rs index 83493ca36..7f7fb3385 100644 --- a/src/app/command_driver/utilities.rs +++ b/src/app/command_driver/utilities.rs @@ -1097,4 +1097,62 @@ impl OpenCADStudio { return task; } } + + /// Feed one line from a command script (.scr) into the command pipeline. + /// + /// If an interactive command is active, the line (or its tokens) is routed to + /// the active command's prompts (e.g. coordinates or options). If no command + /// is active, the line starts a new command (supporting both bare verbs and + /// inline single-line commands). + pub(in crate::app) fn feed_script_line(&mut self, line: &str) -> Task { + let i = self.active_tab; + let trimmed = line.trim(); + + // 1. Text editor currently open (e.g. TEXT command content step). + if self.text_inline.is_some() { + if let Some(editor) = self.text_inline.as_mut() { + editor.value = line.to_string(); + } + let committed = self.text_inline_commit(); + let task = self.post_editor_closed(committed); + return if committed { + Task::batch([task, Task::done(Message::CommandEscape)]) + } else { + task + }; + } + + // 2. Blank line submits Enter to the active command (or repeats last). + if trimmed.is_empty() { + return self.feed_command(StepInput::Enter); + } + + // 3. A command is currently active: route to its prompt handlers. + if self.tabs[i].active_cmd.is_some() { + if self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.input_kind().is_free_text()) + .unwrap_or(false) + { + return self.feed_command(StepInput::Text(trimmed.to_string())); + } + + let tokens: Vec<&str> = trimmed.split_whitespace().collect(); + let mut tasks = Vec::new(); + for tok in tokens { + if self.tabs[i].active_cmd.is_none() { + // Preceding token finished the active command; remaining tokens start a new one. + tasks.push(self.run_command_line(tok)); + } else { + tasks.push(self.feed_active_cmd(tok)); + } + } + return Task::batch(tasks); + } + + // 4. No command is active: run as top-level command. + self.run_command_line(trimmed) + } } + diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 07a7f69c0..1b8a68b71 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -339,33 +339,55 @@ impl OpenCADStudio { // line is fed through the same command path the `--script` startup // flag uses. Blank rows submit Enter to the active command. "SCRIPT" | "SCR" => { - use crate::command::ValuePromptCommand; - let c = ValuePromptCommand::new("SCRIPT", "SCRIPT path to the .scr file:"); - self.command_line.push_info(&c.prompt()); - self.tabs[i].active_cmd = Some(Box::new(c)); + #[cfg(not(target_arch = "wasm32"))] + { + return Some(Task::perform( + async move { + crate::sys::file_dialog() + .add_filter("Script files (*.scr)", &["scr"]) + .pick_file() + .await + .map(|h| crate::sys::handle_path(&h)) + }, + |path| match path { + Some(p) => Message::Command(format!("SCRIPT \"{}\"", p.display())), + None => Message::Noop, + }, + )); + } + #[cfg(target_arch = "wasm32")] + { + use crate::command::ValuePromptCommand; + let c = ValuePromptCommand::new("SCRIPT", "SCRIPT path to the .scr file:"); + self.command_line.push_info(&c.prompt()); + self.tabs[i].active_cmd = Some(Box::new(c)); + } } cmd if cmd.starts_with("SCRIPT ") || cmd.starts_with("SCR ") => { let path = cmd.split_once(' ').map(|(_, r)| r.trim().to_string()); match path { - Some(p) if !p.is_empty() => match std::fs::read_to_string(&p) { - Ok(text) => { - let cmds: Vec> = text - .lines() - .map(str::trim) - .filter(|l| !l.starts_with('#') && !l.starts_with(';')) - .map(|l| Task::done(Message::ScriptLine(l.to_string()))) - .collect(); - self.command_line.push_output(crate::tf!( - "SCRIPT: running {} command(s) from {p}.", - cmds.len() - ).as_ref()); - return Some(Task::batch(cmds)); + Some(p) if !p.is_empty() => { + let clean_path = p.trim().trim_matches(|c| c == '"' || c == '\''); + match std::fs::read_to_string(clean_path) { + Ok(text) => { + let cmds: Vec> = text + .lines() + .map(str::trim) + .filter(|l| !l.starts_with('#') && !l.starts_with(';')) + .map(|l| Task::done(Message::ScriptLine(l.to_string()))) + .collect(); + self.command_line.push_output(crate::tf!( + "SCRIPT: running {} command(s) from {clean_path}.", + cmds.len() + ).as_ref()); + return Some(Task::batch(cmds)); + } + Err(e) => { + self.command_line + .push_error(crate::tf!("SCRIPT: cannot read {clean_path}: {e}").as_ref()); + } } - Err(e) => { - self.command_line - .push_error(crate::tf!("SCRIPT: cannot read {p}: {e}").as_ref()); - } - }, + } _ => { self.command_line .push_info(crate::t!("Usage: SCRIPT ").as_ref()); @@ -418,3 +440,96 @@ fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> { } None } + +#[cfg(test)] +mod tests { + use crate::app::{Message, OpenCADStudio}; + + fn fresh_app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn multiline_script_draws_line() { + let mut app = fresh_app(); + let i = app.active_tab; + let before_count = app.tabs[i].scene.document.entities().count(); + + // Feed standard multi-line CAD script sequence + let _ = app.update(Message::ScriptLine("LINE".into())); + assert!(app.tabs[i].active_cmd.is_some(), "LINE command should be active"); + + let _ = app.update(Message::ScriptLine("0,0".into())); + assert!(app.tabs[i].active_cmd.is_some(), "LINE command should stay active after first point"); + + let _ = app.update(Message::ScriptLine("10,10".into())); + let _ = app.update(Message::ScriptLine("".into())); // Enter to finish + + let after_count = app.tabs[i].scene.document.entities().count(); + assert_eq!(after_count, before_count + 1, "Line entity should have been created"); + assert!(app.tabs[i].active_cmd.is_none(), "LINE command should be finished"); + } + + #[test] + fn single_line_script_still_works() { + let mut app = fresh_app(); + let i = app.active_tab; + let before_count = app.tabs[i].scene.document.entities().count(); + + let _ = app.update(Message::ScriptLine("CIRCLE 5,5 2".into())); + + let after_count = app.tabs[i].scene.document.entities().count(); + assert_eq!(after_count, before_count + 1, "Circle entity should have been created"); + assert!(app.tabs[i].active_cmd.is_none(), "CIRCLE command should be finished"); + } + + #[test] + fn autodesk_command_prefixes_strip_underscore_and_dot() { + let mut app = fresh_app(); + let i = app.active_tab; + let before_count = app.tabs[i].scene.document.entities().count(); + + let _ = app.update(Message::ScriptLine("_CIRCLE 10,10 3".into())); + + let after_count = app.tabs[i].scene.document.entities().count(); + assert_eq!(after_count, before_count + 1, "_CIRCLE should resolve to CIRCLE and create entity"); + } + + #[test] + fn test_script_scr_draws_all_entities() { + let mut app = fresh_app(); + let i = app.active_tab; + const SCRIPT: &str = r#" +_LINE +0,0 +100,0 +100,100 +0,100 +C + +_LINE +0,0 +100,100 + +_LINE +0,100 +100,0 + +CIRCLE +50,50 +35 + +CIRCLE 50,50 15 + +ZOOM EXTENTS +"#; + for line in SCRIPT.lines().map(str::trim).filter(|l| !l.starts_with('#') && !l.starts_with(';')) { + let _ = app.update(Message::ScriptLine(line.to_string())); + } + // 4 square edges + 2 diagonal lines + 2 circles = 8 entities + assert_eq!(app.tabs[i].scene.document.entities().count(), 8); + } +} + diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 55cd5f817..06ef404dc 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -113,6 +113,11 @@ impl OpenCADStudio { Some(rest) => (rest.trim(), true), None => (cmd, false), }; + let cmd = cmd.trim(); + // AutoCAD command prefixes: '_' (international name), '.' (built-in name bypass) + let cmd = cmd.strip_prefix('.').unwrap_or(cmd); + let cmd = cmd.strip_prefix('_').unwrap_or(cmd); + let cmd = cmd.strip_prefix('.').unwrap_or(cmd); let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); if is_spacemouse_command(cmd) { diff --git a/src/app/mod.rs b/src/app/mod.rs index 1602c6392..371ba62eb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1294,6 +1294,7 @@ pub(super) struct OpenCADStudio { /// drawings in a file manager produces exactly that (one process per file, /// all arriving at once), which makes this queue load-bearing, not polish. pub(super) pending_opens: std::collections::VecDeque, + pub(super) pending_startup_script_lines: Vec, /// One global interaction-index build at a time. Large drawings can each /// hold millions of entries, so file-open bursts must not multiply peak /// CPU and memory by the number of tabs. @@ -4278,6 +4279,7 @@ impl OpenCADStudio { missing_fonts_downloading: false, suppressed_missing_fonts: rustc_hash::FxHashSet::default(), pending_opens: std::collections::VecDeque::new(), + pending_startup_script_lines: Vec::new(), active_interaction_index: None, queued_interaction_indices: std::collections::VecDeque::new(), pending_close: None, @@ -4624,21 +4626,26 @@ impl OpenCADStudio { s.command_line.push_warning(¬ice); crate::scene::pipeline::report_gpu_line(&format!("[gpu] {notice}")); } - let cli_open: Task = if !cfg.files.is_empty() { + let has_files = !cfg.files.is_empty(); + let cli_open: Task = if has_files { Task::batch( cfg.files .into_iter() .map(|p| Task::done(Message::OpenExternal(p))), ) - } else if cfg.new { + } else if cfg.new || !cfg.script_lines.is_empty() { Task::done(Message::TabNew) } else { Task::none() }; - // Startup command script: each line dispatched as if typed at the - // command line, in order, after any file open is requested. + // Startup command script: if files were passed to open, defer the + // script lines until the document finishes opening. Otherwise, dispatch + // them on the newly created drawing tab. let script: Task = if cfg.script_lines.is_empty() { Task::none() + } else if has_files { + s.pending_startup_script_lines = cfg.script_lines; + Task::none() } else { Task::batch( cfg.script_lines diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 7d9455805..c90563426 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1976,7 +1976,13 @@ impl OpenCADStudio { } self.drain_pending_open() }; - Task::batch([thumbs_task, pending_open_task, interaction_task]) + let startup_script_task = if !self.pending_startup_script_lines.is_empty() { + let lines = std::mem::take(&mut self.pending_startup_script_lines); + Task::batch(lines.into_iter().map(|l| Task::done(Message::ScriptLine(l)))) + } else { + Task::none() + }; + Task::batch([thumbs_task, pending_open_task, interaction_task, startup_script_task]) } pub(super) fn on_wblock_save_result_some( diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 3d0dd18ef..2918d7886 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -982,6 +982,9 @@ impl OpenCADStudio { .extension() .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); + if ext == "scr" { + return self.dispatch_command(&format!("SCRIPT \"{}\"", path.display())); + } if !matches!(ext.as_str(), "dwg" | "dxf" | "bak" | "sv$") { self.command_line.push_error( crate::tf!("Unsupported file type: {}", path.display()).as_ref(), @@ -2506,13 +2509,7 @@ impl OpenCADStudio { self.dispatch_command(&cmd) } - Message::ScriptLine(line) => { - if line.trim().is_empty() { - self.feed_command(crate::command::StepInput::Enter) - } else { - self.run_command_line(&line) - } - } + Message::ScriptLine(line) => self.feed_script_line(&line), Message::ToggleLayers => { if self.active_modal == Some(super::ModalKind::Layers) {