diff --git a/Cargo.lock b/Cargo.lock index 46c8499c3..63d47cdfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,8 +18,10 @@ version = "0.6.2" dependencies = [ "acadrust", "bytemuck", + "clap", "console_error_panic_hook", "cosmic-text", + "env_logger", "flate2", "fontdb", "glam 0.33.0", @@ -240,6 +242,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -908,6 +960,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -964,6 +1056,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1377,6 +1475,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equator" version = "0.4.2" @@ -2415,6 +2536,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -2454,6 +2581,30 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "jni" version = "0.22.4" @@ -3638,6 +3789,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.5" @@ -5202,6 +5359,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.26.3" @@ -5928,6 +6091,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.2" diff --git a/Cargo.toml b/Cargo.toml index a3ee5ef82..b52638ac9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,10 @@ truck-modeling = "0.6" truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation"], optional = true } truck-polymesh = "0.6" rfd = "0.17" +# Command-line parsing: file arg, --serve/--export/--script and the headless flags. +clap = { version = "4", features = ["derive"] } +# Opt-in logging via --log / RUST_LOG (surfaces wgpu / iced / winit diagnostics). +env_logger = "0.11" acadrust = "0.3.4" printpdf = "0.9.1" flate2 = "1" diff --git a/src/app/automation.rs b/src/app/automation.rs index 1d7ab1cc8..c2d8c59d3 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -32,6 +32,29 @@ pub fn serve() { } } +/// Headless one-shot format conversion (`--export IN OUT`). Loads `input`, +/// writes `output` (format chosen from `output`'s extension), and returns a +/// process exit code (0 on success). No window is created. +pub fn export_headless(input: &std::path::Path, output: &std::path::Path) -> i32 { + let doc = match crate::io::load_file(input) { + Ok(doc) => doc, + Err(e) => { + eprintln!("export: cannot read {}: {e}", input.display()); + return 1; + } + }; + match crate::io::save(&doc, output) { + Ok(()) => { + println!("Exported {} → {}", input.display(), output.display()); + 0 + } + Err(e) => { + eprintln!("export: cannot write {}: {e}", output.display()); + 1 + } + } +} + /// `--port ` if present on the command line. fn port_arg() -> Option { let mut args = std::env::args(); diff --git a/src/app/mod.rs b/src/app/mod.rs index ac9f5624e..24048110d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,7 @@ #[cfg(not(target_arch = "wasm32"))] mod automation; #[cfg(not(target_arch = "wasm32"))] -pub use automation::serve; +pub use automation::{export_headless, serve}; mod cmd_result; mod commands; mod document; @@ -332,6 +332,9 @@ pub(super) struct OpenCADStudio { /// Whether the one-time default-association prompt has already been shown. /// Persisted via [`settings::UserSettings`] so it survives restarts. default_assoc_prompted: bool, + /// Read-only session (`--read-only`): editing is allowed but every save + /// path is refused. Set once at boot from the CLI config. + read_only: bool, /// Tag of the latest available release (without the leading "v"), /// e.g. `"0.3.0"`. `None` when up-to-date or check hasn't returned. update_notice_version: Option, @@ -1594,6 +1597,7 @@ impl OpenCADStudio { point_size_buf: String::new(), point_size_relative: true, default_assoc_prompted: false, + read_only: false, update_notice_version: None, update_notice_body: None, clipboard: Vec::new(), @@ -1834,18 +1838,32 @@ impl OpenCADStudio { Message::UpdateCheckResult, ); let focus_cmd = s.focus_cmd_input(); - // Open a file path passed on the command line. Used by the - // OS file association: double-clicking a .dwg in the file - // explorer launches the binary with the file path as the - // first positional argument. Unknown / flag-style args are - // ignored. `Message::OpenRecent` does the existence check - // and reports a clean error if the path is bogus. - let cli_open: Task = std::env::args_os() - .nth(1) - .map(PathBuf::from) - .filter(|p| !p.as_os_str().to_string_lossy().starts_with('-')) - .map(|p| Task::done(Message::OpenRecent(p))) - .unwrap_or_else(Task::none); + // Startup configuration from the command line (see `cli`). A file + // argument — also how the OS file association launches us when a .dwg + // is double-clicked — opens via `OpenRecent`, which existence-checks + // the path and reports a clean error if it is bogus. `--new` opens a + // fresh drawing tab instead of the welcome screen. `--read-only` + // disables saving. `--script` queues command lines to run once up. + let cfg = crate::cli::gui_config(); + s.read_only = cfg.read_only; + let cli_open: Task = if let Some(p) = cfg.file { + Task::done(Message::OpenRecent(p)) + } else if cfg.new { + 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. + let script: Task = if cfg.script_lines.is_empty() { + Task::none() + } else { + Task::batch( + cfg.script_lines + .into_iter() + .map(|line| Task::done(Message::Command(line))), + ) + }; // One-time prompt offering to make Open CAD Studio the default app for // .dwg / .dxf. Shown only on the first launch that hasn't answered it // yet; the flag is persisted so we never ask twice. @@ -1855,7 +1873,14 @@ impl OpenCADStudio { } ( s, - Task::batch([open_main, check_update, focus_cmd, cli_open, assoc_prompt]), + Task::batch([ + open_main, + check_update, + focus_cmd, + cli_open, + script, + assoc_prompt, + ]), ) } diff --git a/src/app/update.rs b/src/app/update.rs index de30df33e..92264ffec 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -947,6 +947,11 @@ impl OpenCADStudio { Message::ObjImportPath(None) => Task::none(), Message::SaveFile => { + if self.read_only { + self.command_line + .push_error("Read-only session (--read-only): saving is disabled."); + return Task::none(); + } let i = self.active_tab; // Stamp the live grid/snap toggles onto the VPort so the file // reflects them even if they came from settings with no @@ -973,6 +978,11 @@ impl OpenCADStudio { } Message::SaveAs => { + if self.read_only { + self.command_line + .push_error("Read-only session (--read-only): saving is disabled."); + return Task::none(); + } let i = self.active_tab; self.save_dialog_for_unsaved = false; self.open_save_dialog_window(i) diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 000000000..fb25e6e57 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,88 @@ +//! Command-line interface. +//! +//! Parsing lives here; `main` interprets the result. Three run modes split out +//! of the parsed args: +//! - `--serve` headless JSON automation server (see `app::serve`) +//! - `--export IN OUT` one-shot headless format conversion, then exit +//! - otherwise launch the GUI editor, configured via [`GuiConfig`] +//! +//! GUI-only options (open-file, `--new`, `--read-only`, `--script`) are stashed +//! in [`GUI_CONFIG`] for `app::boot` to read, since the iced daemon boots with +//! no arguments of its own. + +use std::path::PathBuf; +use std::sync::OnceLock; + +use clap::Parser; + +/// Open CAD Studio command-line options. +#[derive(Parser, Debug, Default)] +#[command( + name = "OpenCADStudio", + version, + about = "Open CAD Studio — 2D/3D CAD editor", + long_about = None, +)] +pub struct Cli { + /// CAD file to open at startup (.dwg / .dxf). Also used by the OS file + /// association when a drawing is double-clicked. + pub file: Option, + + /// Start with a new empty drawing, ignoring any file argument. + #[arg(long)] + pub new: bool, + + /// Open read-only: editing is allowed but saving is disabled. + #[arg(long)] + pub read_only: bool, + + /// Restrict the GPU backend (e.g. dx12, vulkan, gl, metal). Sets WGPU_BACKEND. + #[arg(long, value_name = "BACKEND")] + pub backend: Option, + + /// Safe mode: force the GL backend, for flaky/hybrid GPU drivers. + #[arg(long, visible_alias = "no-gpu")] + pub safe_mode: bool, + + /// Run the headless JSON automation server (stdin/stdout, or --port). + #[arg(long)] + pub serve: bool, + + /// TCP port for --serve (defaults to stdin/stdout). + #[arg(long, value_name = "PORT")] + pub port: Option, + + /// Headless convert: read IN, write OUT (format from OUT's extension), exit. + #[arg(long, num_args = 2, value_names = ["IN", "OUT"])] + pub export: Option>, + + /// Run a command script at startup: one command line per line of FILE. + #[arg(long, value_name = "FILE")] + pub script: Option, + + /// Log level (error|warn|info|debug|trace). Also honours RUST_LOG. + #[arg(long, value_name = "LEVEL")] + pub log: Option, +} + +/// GUI startup configuration, handed from `main` to `app::boot` out-of-band +/// because the iced daemon's boot closure takes no arguments. +#[derive(Debug, Default, Clone)] +pub struct GuiConfig { + /// File to open on launch (`None` for a blank session). + pub file: Option, + /// Open a fresh drawing tab on launch instead of the welcome screen. + pub new: bool, + /// Saving disabled for this session. + pub read_only: bool, + /// Command lines to run once the editor is up. + pub script_lines: Vec, +} + +/// Set once by `main` before the GUI boots; read by `app::boot`. +pub static GUI_CONFIG: OnceLock = OnceLock::new(); + +/// The GUI config, or a default empty one if `main` never set it (e.g. tests). +pub fn gui_config() -> GuiConfig { + GUI_CONFIG.get().cloned().unwrap_or_default() +} diff --git a/src/lib.rs b/src/lib.rs index fdfbc5c01..cf4655fbe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ #![allow(non_snake_case)] pub mod app; +#[cfg(not(target_arch = "wasm32"))] +pub mod cli; pub mod command; pub mod entities; pub mod io; diff --git a/src/main.rs b/src/main.rs index 7b01737f1..b77f6d18b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,8 @@ #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] mod app; +#[cfg(not(target_arch = "wasm32"))] +mod cli; mod command; mod entities; mod io; @@ -20,32 +22,78 @@ mod sys; mod update_check; fn main() -> iced::Result { - // On some Windows hybrid-GPU laptops the AMD OpenGL driver (atio6axx.dll) - // access-violates the moment wgpu enumerates its GL backend at startup, - // killing the app before any window appears — even though DX12 would work - // fine (#55). Restrict wgpu to DX12/Vulkan so the GL ICD is never touched. - // An explicit user-set WGPU_BACKEND still wins. - #[cfg(target_os = "windows")] - if std::env::var_os("WGPU_BACKEND").is_none() { - std::env::set_var("WGPU_BACKEND", "dx12,vulkan"); - } - // Web (wasm) uses the single-window entry; native uses the multi-window - // daemon. Trunk calls `main` from its generated JS bootstrap. + // daemon. Trunk calls `main` from its generated JS bootstrap. The web build + // takes no CLI args, so it skips parsing entirely. #[cfg(target_arch = "wasm32")] { console_error_panic_hook::set_once(); - app::run_web() + return app::run_web(); } #[cfg(not(target_arch = "wasm32"))] { - // Headless automation server — drive the app over JSON on stdin/stdout - // with no GUI (issue #29). `--serve` is the opt-in; everything else - // launches the editor. - if std::env::args().skip(1).any(|a| a == "--serve") { + use clap::Parser; + let args = cli::Cli::parse(); + + // Opt-in logging. `--log LEVEL` seeds RUST_LOG; the subscriber then + // surfaces wgpu / iced / winit diagnostics that are otherwise silent. + if let Some(level) = &args.log { + std::env::set_var("RUST_LOG", level); + } + if std::env::var_os("RUST_LOG").is_some() { + let _ = env_logger::try_init(); + } + + // GPU backend selection. Explicit `--backend` wins; `--safe-mode` + // forces GL for flaky drivers. On Windows, fall back to DX12/Vulkan so + // the AMD OpenGL ICD (atio6axx.dll) is never touched at startup — it + // access-violates on some hybrid-GPU laptops before any window appears + // (#55). An already-set WGPU_BACKEND always wins. + if let Some(backend) = &args.backend { + std::env::set_var("WGPU_BACKEND", backend); + } else if args.safe_mode { + std::env::set_var("WGPU_BACKEND", "gl"); + } + #[cfg(target_os = "windows")] + if std::env::var_os("WGPU_BACKEND").is_none() { + std::env::set_var("WGPU_BACKEND", "dx12,vulkan"); + } + + // Headless modes exit without ever creating a window. + if args.serve { + // `app::serve` reads --port itself from the raw args. app::serve(); return Ok(()); } + if let Some(io) = &args.export { + // clap enforces exactly two values for --export. + let code = app::export_headless(&io[0], &io[1]); + std::process::exit(code); + } + + // GUI: stash the startup config for `app::boot` to pick up. + let script_lines = args + .script + .as_ref() + .map(|p| match std::fs::read_to_string(p) { + Ok(text) => text + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(str::to_string) + .collect(), + Err(e) => { + eprintln!("--script: cannot read {}: {e}", p.display()); + Vec::new() + } + }) + .unwrap_or_default(); + let _ = cli::GUI_CONFIG.set(cli::GuiConfig { + file: if args.new { None } else { args.file }, + new: args.new, + read_only: args.read_only, + script_lines, + }); app::run() } }