diff --git a/Cargo.toml b/Cargo.toml index 8f209a6f7..d328e0698 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ naga = { version = "27", features = ["wgsl-in"] } [target.'cfg(target_os = "windows")'.dependencies] libloading = "0.8" -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi", "Win32_Storage_Xps", "Win32_Globalization", "Win32_Security_Authentication_Identity"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi", "Win32_Storage_Xps", "Win32_Globalization", "Win32_Security_Authentication_Identity"] } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } diff --git a/src/app/mod.rs b/src/app/mod.rs index b66878604..06f190af8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -619,6 +619,10 @@ pub(super) struct OpenCADStudio { dyn_mode: i16, /// Currently visible page in the application Options dialog. options_tab: crate::ui::window::options::OptionsTab, + /// Saved graphics options (backend, compat renderer); read at launch, so + /// edits here only take effect after a restart. + #[cfg(not(target_arch = "wasm32"))] + graphics_prefs: crate::gpu_backend::GraphicsPrefs, /// The Options window's commit point (see `options_session`). options_saved: Option, /// Close was pressed with unapplied changes; the discard guard is up. @@ -2262,6 +2266,15 @@ pub enum Message { OptionsOpen, /// Switch the visible page in Options. OptionsTabChanged(crate::ui::window::options::OptionsTab), + /// Options > Graphics: pin a backend (applies at next launch). + #[cfg(not(target_arch = "wasm32"))] + GraphicsBackendChanged(crate::gpu_backend::BackendChoice), + /// Options > Graphics: force the packed compatibility renderer. + #[cfg(not(target_arch = "wasm32"))] + GraphicsCompatToggled(bool), + /// Options > Graphics: prefer OpenGL on older GPUs. + #[cfg(not(target_arch = "wasm32"))] + GraphicsLegacyGlToggled(bool), /// Set CURSORSIZE from the Display-page slider. CursorSizeChanged(i32), /// Set PICKBOX from the Selection-page slider. @@ -4153,6 +4166,8 @@ impl OpenCADStudio { dyn_input: true, dyn_mode: 3, options_tab: crate::ui::window::options::OptionsTab::General, + #[cfg(not(target_arch = "wasm32"))] + graphics_prefs: crate::gpu_backend::load_prefs(), options_saved: None, options_close_confirm: false, spacemouse: { diff --git a/src/app/settings.rs b/src/app/settings.rs index 349d41f27..3f597c41e 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -347,6 +347,7 @@ where "modeling" => OptionsTab::Modeling, "selection" => OptionsTab::Selection, "user-preferences" => OptionsTab::UserPreferences, + "graphics" => OptionsTab::Graphics, _ => OptionsTab::General, }) } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 0681d0e73..2479c1878 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -7932,6 +7932,27 @@ impl OpenCADStudio { Task::none() } + #[cfg(not(target_arch = "wasm32"))] + Message::GraphicsBackendChanged(choice) => { + self.graphics_prefs.backend = choice; + crate::gpu_backend::save_prefs(self.graphics_prefs); + Task::none() + } + + #[cfg(not(target_arch = "wasm32"))] + Message::GraphicsCompatToggled(on) => { + self.graphics_prefs.compat_renderer = on; + crate::gpu_backend::save_prefs(self.graphics_prefs); + Task::none() + } + + #[cfg(not(target_arch = "wasm32"))] + Message::GraphicsLegacyGlToggled(on) => { + self.graphics_prefs.legacy_gl = on; + crate::gpu_backend::save_prefs(self.graphics_prefs); + Task::none() + } + Message::CursorSizeChanged(value) => { self.cursor_size = value.clamp(1, 100); self.persist_settings_if_changed(); @@ -8976,16 +8997,7 @@ impl OpenCADStudio { } Message::AboutCopyInfo => { - let info = format!( - "Open CAD Studio v{}\nRevision: {}\nCommit date: {}\nProfile: {}\nFeatures: {}\nOS: {}\nArch: {}", - env!("OCS_FULL_VERSION"), - env!("OCS_GIT_REV"), - env!("OCS_COMMIT_DATE"), - env!("OCS_BUILD_PROFILE"), - env!("OCS_BUILD_FEATURES"), - crate::ui::window::about::platform_name(), - crate::ui::window::about::architecture_name(), - ); + let info = crate::ui::window::about::report(); #[cfg(target_arch = "wasm32")] { crate::sys::write_clipboard_text(&info); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 3b143fd66..87b0fb129 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -332,6 +332,16 @@ impl OpenCADStudio { self.spacemouse_preferences, self.spacemouse.status(), self.spacemouse_paused, self.spacemouse_details, ), + { + #[cfg(not(target_arch = "wasm32"))] + let page = crate::ui::window::options::graphics::view( + self.graphics_prefs, + crate::gpu_backend::active_gpu(), + ); + #[cfg(target_arch = "wasm32")] + let page = iced::widget::Space::new().into(); + page + }, &self.snap_angle_input, &self.zoom_factor_input, { diff --git a/src/gpu_backend.rs b/src/gpu_backend.rs index 8b5141cd1..bac15d63e 100644 --- a/src/gpu_backend.rs +++ b/src/gpu_backend.rs @@ -76,6 +76,174 @@ pub fn gpu_guard_active() -> bool { !candidate_backends().is_empty() } +/// Test hook: make the synthetic probe report a legacy (below-baseline) GPU. +pub const FORCE_LEGACY_ENV: &str = "OCS_GPU_PROBE_FORCE_LEGACY"; + +/// The backend the user pinned in Options > Graphics. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BackendChoice { + /// Probe and pick the best backend (the default). + #[default] + Auto, + Dx12, + Vulkan, + Gl, +} + +impl BackendChoice { + /// Choices offered on this OS. + pub fn available() -> Vec { + let mut v = vec![BackendChoice::Auto]; + if cfg!(target_os = "windows") { + v.push(BackendChoice::Dx12); + } + v.push(BackendChoice::Vulkan); + v.push(BackendChoice::Gl); + v + } + + /// The `WGPU_BACKEND` value, or `None` for automatic. + pub fn as_str(self) -> Option<&'static str> { + match self { + BackendChoice::Auto => None, + BackendChoice::Dx12 => Some("dx12"), + BackendChoice::Vulkan => Some("vulkan"), + BackendChoice::Gl => Some("gl"), + } + } + + pub fn parse(s: &str) -> Self { + match s.trim().to_ascii_lowercase().as_str() { + "dx12" => BackendChoice::Dx12, + "vulkan" => BackendChoice::Vulkan, + "gl" => BackendChoice::Gl, + _ => BackendChoice::Auto, + } + } + + pub fn label(self) -> &'static str { + match self { + BackendChoice::Auto => "Automatic (recommended)", + BackendChoice::Dx12 => "DirectX 12", + BackendChoice::Vulkan => "Vulkan", + BackendChoice::Gl => "OpenGL", + } + } +} + +/// Graphics settings that apply at the next launch. They live in their own +/// file because the backend is chosen before the application (and its +/// settings) exists. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GraphicsPrefs { + pub backend: BackendChoice, + /// Force the packed renderer that avoids shader storage buffers. + pub compat_renderer: bool, + /// On Windows, prefer OpenGL when the GPU is below the WebGPU baseline. + pub legacy_gl: bool, +} + +impl Default for GraphicsPrefs { + fn default() -> Self { + Self { + backend: BackendChoice::Auto, + compat_renderer: false, + legacy_gl: true, + } + } +} + +fn prefs_path() -> Option { + if let Ok(dir) = std::env::var(SENTINEL_DIR_ENV) { + return Some(PathBuf::from(dir).join("graphics.json")); + } + crate::config::config_dir().map(|d| d.join("graphics.json")) +} + +/// Saved graphics preferences; defaults when absent or unreadable. +pub fn load_prefs() -> GraphicsPrefs { + let Some(text) = prefs_path().and_then(|p| std::fs::read_to_string(p).ok()) else { + return GraphicsPrefs::default(); + }; + let Ok(v) = serde_json::from_str::(&text) else { + return GraphicsPrefs::default(); + }; + let d = GraphicsPrefs::default(); + GraphicsPrefs { + backend: v + .get("backend") + .and_then(|x| x.as_str()) + .map_or(d.backend, BackendChoice::parse), + compat_renderer: v + .get("compat_renderer") + .and_then(|x| x.as_bool()) + .unwrap_or(d.compat_renderer), + legacy_gl: v + .get("legacy_gl") + .and_then(|x| x.as_bool()) + .unwrap_or(d.legacy_gl), + } +} + +/// Persist graphics preferences (best effort). +pub fn save_prefs(prefs: GraphicsPrefs) { + let Some(p) = prefs_path() else { + return; + }; + if let Some(parent) = p.parent() { + let _ = std::fs::create_dir_all(parent); + } + let json = serde_json::json!({ + "backend": prefs.backend.as_str().unwrap_or("auto"), + "compat_renderer": prefs.compat_renderer, + "legacy_gl": prefs.legacy_gl, + }); + let _ = std::fs::write(p, serde_json::to_string_pretty(&json).unwrap_or_default()); +} + +/// What this run ended up using, and why. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ActiveGpu { + /// `WGPU_BACKEND` value in effect (`None` = left to iced's default). + pub backend: Option, + /// Adapter name when a probe identified it. + pub adapter: Option, + /// Driver name / version when a probe reported it. + pub driver: String, + /// How the backend was chosen (`automatic`, `--backend`, ...). + pub origin: String, + /// The adapter is below the WebGPU baseline. + pub legacy: bool, +} + +static ACTIVE: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn record_active(active: ActiveGpu) { + let _ = ACTIVE.set(active); +} + +/// The backend decision for this run (default-empty before `resolve_gpu`). +pub fn active_gpu() -> ActiveGpu { + ACTIVE.get().cloned().unwrap_or_default() +} + +/// Short `Adapter (backend)` text for the About dialog. +pub fn active_gpu_summary() -> String { + let a = active_gpu(); + let backend = match a.backend.as_deref() { + Some("dx12") => "DirectX 12", + Some("vulkan") => "Vulkan", + Some("gl") => "OpenGL", + Some("metal") => "Metal", + Some(other) => other, + None => "default", + }; + match a.adapter { + Some(name) => format!("{name} ({backend})"), + None => backend.to_string(), + } +} + /// Capabilities reported by a passing probe child (one JSON line on stdout). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProbeCaps { @@ -87,6 +255,12 @@ pub struct ProbeCaps { pub max_storage: u32, /// True when this is a software rasterizer (WARP / llvmpipe / SwiftShader). pub software: bool, + /// True when the adapter falls below the WebGPU baseline (missing + /// downlevel flags or shader model < 5): an older GPU that DX12/Vulkan + /// accept but run poorly or unreliably. + pub legacy: bool, + /// Driver name and version as the adapter reports them (may be empty). + pub driver: String, } impl ProbeCaps { @@ -100,6 +274,8 @@ impl ProbeCaps { adapter: "synthetic-test-adapter".to_string(), max_storage, software: false, + legacy: std::env::var_os(FORCE_LEGACY_ENV).is_some_and(|_| backend != "gl"), + driver: String::new(), } } @@ -111,6 +287,12 @@ impl ProbeCaps { adapter: v.get("adapter")?.as_str().unwrap_or("unknown").to_string(), max_storage: v.get("max_storage")?.as_u64()? as u32, software: v.get("software")?.as_bool()?, + legacy: v.get("legacy").and_then(|x| x.as_bool()).unwrap_or(false), + driver: v + .get("driver") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), }) } @@ -120,6 +302,8 @@ impl ProbeCaps { "adapter": self.adapter, "max_storage": self.max_storage, "software": self.software, + "legacy": self.legacy, + "driver": self.driver, }) .to_string() } @@ -463,11 +647,41 @@ fn run_probe_for(arg: &str) -> Option { /// Resolve and apply `WGPU_BACKEND` for this launch. /// -/// Precedence: `--backend` > existing `WGPU_BACKEND` > `--safe-mode` > +/// Precedence: `--backend` > existing `WGPU_BACKEND` > `--safe-mode` > saved +/// Graphics option (skipped when that backend died with the previous run) > /// per-backend probes (minus crash-implicated backends) > software adapter > /// last-resort `gl`. Inactive on macOS/wasm (env untouched). pub fn resolve_gpu(cli_backend: Option<&str>, safe_mode: bool) -> GpuResolution { + let prefs = load_prefs(); let env_backend = std::env::var("WGPU_BACKEND").ok(); + let excluded = if previous_run_crashed() { + crashed_backends() + } else { + Vec::new() + }; + // A saved backend choice acts like `--backend`, except when it is the + // backend that took down the previous run: the user must never be locked + // into a setting that cannot start. + let saved = prefs + .backend + .as_str() + .filter(|b| !excluded.iter().any(|e| e == b)); + let origin = if cli_backend.is_some() { + "--backend" + } else if env_backend.is_some() { + "WGPU_BACKEND" + } else if safe_mode { + "safe mode" + } else if saved.is_some() { + "Graphics options" + } else { + "automatic" + }; + let cli_backend = cli_backend.or(if env_backend.is_none() && !safe_mode { + saved + } else { + None + }); if cli_backend.is_some() || env_backend.is_some() || safe_mode || !gpu_guard_active() { let d = decide_backend( cli_backend, @@ -482,18 +696,21 @@ pub fn resolve_gpu(cli_backend: Option<&str>, safe_mode: bool) -> GpuResolution if let Some(v) = &d.backend_value { std::env::set_var("WGPU_BACKEND", v); } + record_active(ActiveGpu { + backend: d.backend_value.clone(), + adapter: None, + driver: String::new(), + origin: origin.to_string(), + legacy: false, + }); return GpuResolution { backend_value: d.backend_value, reason: d.reason, compat_renderer: false, }; } - let candidates = candidate_backends(); - let excluded = if previous_run_crashed() { - crashed_backends() - } else { - Vec::new() - }; + let mut candidates = candidate_backends(); + let mut origin = origin.to_string(); let mut results: Vec<(String, Option)> = Vec::new(); for backend in &candidates { if excluded.iter().any(|e| e == backend) { @@ -511,6 +728,29 @@ pub fn resolve_gpu(cli_backend: Option<&str>, safe_mode: bool) -> GpuResolution break; } } + // Older GPUs that DX12/Vulkan accept but run poorly: when the winning + // probe says the adapter is below the WebGPU baseline, try GL first. GL + // only wins if its own probe passes, so this never trades a working + // backend for a broken one. + if cfg!(target_os = "windows") + && prefs.legacy_gl + && !excluded.iter().any(|e| e == "gl") + && results + .iter() + .find_map(|(_, c)| c.as_ref()) + .is_some_and(|c| c.legacy && !c.software && c.backend != "gl") + { + let gl = run_probe_for("gl"); + eprintln!( + "[gpu] older GPU detected; probe gl: {}", + gl.as_ref().map(|c| c.adapter.as_str()).unwrap_or("FAILED") + ); + if gl.is_some() { + candidates = prefer_gl(&candidates); + origin = "automatic (older GPU, OpenGL preferred)".to_string(); + } + results.push(("gl".to_string(), gl)); + } let sw = if results.iter().all(|(_, c)| c.is_none()) { run_probe_for("sw") } else { @@ -538,6 +778,21 @@ pub fn resolve_gpu(cli_backend: Option<&str>, safe_mode: bool) -> GpuResolution eprintln!("[gpu] {}", compat_notice()); } } + let chosen = results + .iter() + .filter(|(b, _)| Some(b) == d.backend_value.as_ref()) + .find_map(|(_, c)| c.clone()); + record_active(ActiveGpu { + backend: d.backend_value.clone(), + adapter: d.adapter_name.clone(), + driver: chosen.as_ref().map(|c| c.driver.clone()).unwrap_or_default(), + origin: if d.reason.is_some() && origin == "automatic" { + "automatic (fallback)".to_string() + } else { + origin + }, + legacy: chosen.is_some_and(|c| c.legacy), + }); GpuResolution { backend_value: d.backend_value, reason: d.reason, @@ -545,6 +800,13 @@ pub fn resolve_gpu(cli_backend: Option<&str>, safe_mode: bool) -> GpuResolution } } +/// `candidates` with `gl` moved to the front. +pub fn prefer_gl(candidates: &[String]) -> Vec { + let mut out = vec!["gl".to_string()]; + out.extend(candidates.iter().filter(|b| *b != "gl").cloned()); + out +} + /// Child-process entry point for `--gpu-probe `: exercise one /// backend the way the canvas does (device + staging-style mapped upload + /// render pipeline creation + offscreen render + readback). Prints one JSON @@ -718,6 +980,8 @@ fn exercise_adapter(adapter: &iced::wgpu::Adapter) -> Option { adapter: info.name.clone(), max_storage: adapter.limits().max_storage_buffers_per_shader_stage, software: is_software_adapter(&info), + legacy: !adapter.get_downlevel_capabilities().is_webgpu_compliant(), + driver: format!("{} {}", info.driver, info.driver_info).trim().to_string(), }); } None @@ -902,6 +1166,8 @@ mod tests { adapter: format!("test {backend}"), max_storage: storage, software: false, + legacy: false, + driver: String::new(), } } @@ -1013,6 +1279,8 @@ mod tests { adapter: "llvmpipe".to_string(), max_storage: 8, software: true, + legacy: false, + driver: String::new(), }; let d = decide_backend( None, @@ -1060,6 +1328,8 @@ mod tests { adapter: "Intel HD 405 (Braswell)".to_string(), max_storage: 8, software: false, + legacy: false, + driver: String::new(), }; let line = caps.to_json_line(); assert_eq!(ProbeCaps::from_json_line(&line), Some(caps)); @@ -1214,4 +1484,74 @@ mod tests { unsafe { std::env::remove_var(FORCE_OK_ENV) }; unsafe { std::env::remove_var(FORCE_CAPS_ENV) }; } + + #[test] + fn legacy_gpu_routes_to_gl_first_but_gl_must_still_pass() { + let order = prefer_gl(&cands(&["dx12", "vulkan", "gl"])); + assert_eq!(order, cands(&["gl", "dx12", "vulkan"])); + // GL probe passed: it wins silently. + let results = vec![ + ("dx12".to_string(), Some(ProbeCaps { legacy: true, ..ok_caps("dx12", 8) })), + ("gl".to_string(), Some(ok_caps("gl", 8))), + ]; + let d = decide_backend(None, None, false, &order, &[], &results, None, true); + assert_eq!(d.backend_value.as_deref(), Some("gl")); + assert!(d.reason.is_none()); + // GL probe failed: the working DX12 stays. + let results = vec![ + ("dx12".to_string(), Some(ProbeCaps { legacy: true, ..ok_caps("dx12", 8) })), + ("gl".to_string(), None), + ]; + let d = decide_backend( + None, + None, + false, + &cands(&["dx12", "vulkan", "gl"]), + &[], + &results, + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("dx12")); + } + + #[test] + fn probe_json_without_new_fields_still_parses() { + let line = r#"{"backend":"dx12","adapter":"x","max_storage":8,"software":false}"#; + let caps = ProbeCaps::from_json_line(line).expect("old probe output parses"); + assert!(!caps.legacy); + assert!(caps.driver.is_empty()); + } + + #[test] + fn backend_choice_round_trips() { + for c in [ + BackendChoice::Auto, + BackendChoice::Dx12, + BackendChoice::Vulkan, + BackendChoice::Gl, + ] { + assert_eq!(BackendChoice::parse(c.as_str().unwrap_or("auto")), c); + } + assert_eq!(BackendChoice::parse("garbage"), BackendChoice::Auto); + } + + #[test] + fn graphics_prefs_persist_and_default_when_missing() { + let _guard = SENTINEL_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!("ocs-gpu-prefs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // SAFETY: serialized by SENTINEL_TESTS; process-local test hook. + unsafe { std::env::set_var(SENTINEL_DIR_ENV, &dir) }; + assert_eq!(load_prefs(), GraphicsPrefs::default()); + let p = GraphicsPrefs { + backend: BackendChoice::Vulkan, + compat_renderer: true, + legacy_gl: false, + }; + save_prefs(p); + assert_eq!(load_prefs(), p); + unsafe { std::env::remove_var(SENTINEL_DIR_ENV) }; + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/lib.rs b/src/lib.rs index 2038f1903..210df2614 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,5 +29,6 @@ pub mod rest; pub mod scene; pub mod snap; pub mod sys; +pub mod sysinfo; pub mod ui; pub mod videos; diff --git a/src/main.rs b/src/main.rs index d5674fee7..834e9215c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -194,8 +194,9 @@ fn main() -> iced::Result { // The probe enables the packed renderer automatically on GPUs without // shader storage buffers; an explicit `--compat-renderer` does the // same without the notice. - let compat_renderer = args.compat_renderer || gpu.compat_renderer; - let gpu_compat_auto = !args.compat_renderer && gpu.compat_renderer; + let saved_compat = OpenCADStudio::gpu_backend::load_prefs().compat_renderer; + let compat_renderer = args.compat_renderer || saved_compat || gpu.compat_renderer; + let gpu_compat_auto = !args.compat_renderer && !saved_compat && gpu.compat_renderer; let _ = cli::GUI_CONFIG.set(cli::GuiConfig { files: if args.new { Vec::new() } else { args.files }, new: args.new, diff --git a/src/sysinfo.rs b/src/sysinfo.rs new file mode 100644 index 000000000..6e5ea1c14 --- /dev/null +++ b/src/sysinfo.rs @@ -0,0 +1,226 @@ +//! Host hardware facts for About and the copied system report. +//! +//! Read straight from the OS (registry, `/proc`, `sysctl`) so no extra crate +//! is pulled in for three strings. Everything degrades to "Unknown" rather +//! than failing: this is diagnostics, never a reason to refuse to open. + +use std::sync::OnceLock; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SystemInfo { + /// Marketing name, e.g. `Intel(R) Core(TM) Ultra 7 255U`. + pub cpu: String, + /// Logical processors available to this process. + pub logical_cores: usize, + /// Installed physical memory, bytes (0 = unknown). + pub ram_total: u64, + /// Memory available right now, bytes (0 = unknown). + pub ram_available: u64, + /// OS name / version / build. + pub os: String, +} + +static INFO: OnceLock = OnceLock::new(); + +/// Host facts, read once per run. +pub fn system_info() -> &'static SystemInfo { + INFO.get_or_init(read) +} + +/// `16.0 GB`, or `Unknown` for 0. +pub fn format_bytes(bytes: u64) -> String { + if bytes == 0 { + return "Unknown".to_string(); + } + format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) +} + +/// One-line CPU description for the About row. +pub fn cpu_summary() -> String { + let i = system_info(); + if i.logical_cores > 0 { + format!("{} ({} threads)", i.cpu, i.logical_cores) + } else { + i.cpu.clone() + } +} + +/// One-line RAM description for the About row. +pub fn ram_summary() -> String { + format_bytes(system_info().ram_total) +} + +#[cfg(not(target_arch = "wasm32"))] +fn unknown_if_empty(s: String) -> String { + let s = s.trim().to_string(); + if s.is_empty() { + "Unknown".to_string() + } else { + s + } +} + +#[cfg(target_arch = "wasm32")] +fn read() -> SystemInfo { + SystemInfo { + cpu: "Unknown".into(), + logical_cores: 0, + ram_total: 0, + ram_available: 0, + os: "Web".into(), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn read() -> SystemInfo { + let (cpu, ram_total, ram_available, os) = platform(); + SystemInfo { + cpu: unknown_if_empty(cpu), + logical_cores: std::thread::available_parallelism().map_or(0, |n| n.get()), + ram_total, + ram_available, + os: unknown_if_empty(os), + } +} + +#[cfg(target_os = "windows")] +fn platform() -> (String, u64, u64, String) { + use windows_sys::Win32::System::Registry::{ + RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ, + }; + use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + fn reg_string(subkey: &str, value: &str) -> String { + let (subkey, value) = (wide(subkey), wide(value)); + let mut buf = [0u16; 512]; + let mut bytes = (buf.len() * 2) as u32; + // SAFETY: both names are NUL-terminated and `buf` / `bytes` describe + // the writable buffer exactly. + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + RRF_RT_REG_SZ, + std::ptr::null_mut(), + buf.as_mut_ptr().cast(), + &mut bytes, + ) + }; + if status != 0 { + return String::new(); + } + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf16_lossy(&buf[..len]) + } + + let cpu = reg_string( + r"HARDWARE\DESCRIPTION\System\CentralProcessor\0", + "ProcessorNameString", + ); + + // SAFETY: `MEMORYSTATUSEX` is plain data; `dwLength` is set as required. + let (total, avail) = unsafe { + let mut m: MEMORYSTATUSEX = std::mem::zeroed(); + m.dwLength = std::mem::size_of::() as u32; + if GlobalMemoryStatusEx(&mut m) != 0 { + (m.ullTotalPhys, m.ullAvailPhys) + } else { + (0, 0) + } + }; + + let key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion"; + let mut name = reg_string(key, "ProductName"); + let build = reg_string(key, "CurrentBuildNumber"); + let display = reg_string(key, "DisplayVersion"); + // The registry still says "Windows 10" on Windows 11; the build number + // (22000+) is the reliable tell. + if build.parse::().is_ok_and(|b| b >= 22000) { + name = name.replace("Windows 10", "Windows 11"); + } + let mut os = name; + if !display.is_empty() { + os.push(' '); + os.push_str(&display); + } + if !build.is_empty() { + os.push_str(&format!(" (build {build})")); + } + (cpu, total, avail, os) +} + +#[cfg(target_os = "linux")] +fn platform() -> (String, u64, u64, String) { + let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").unwrap_or_default(); + let cpu = cpuinfo + .lines() + .find_map(|l| l.strip_prefix("model name")) + .and_then(|l| l.split_once(':')) + .map(|(_, v)| v.trim().to_string()) + .unwrap_or_default(); + let meminfo = std::fs::read_to_string("/proc/meminfo").unwrap_or_default(); + let kb = |key: &str| -> u64 { + meminfo + .lines() + .find_map(|l| l.strip_prefix(key)) + .and_then(|l| l.trim_start_matches(':').split_whitespace().next()) + .and_then(|n| n.parse::().ok()) + .unwrap_or(0) + * 1024 + }; + let os_release = std::fs::read_to_string("/etc/os-release").unwrap_or_default(); + let os = os_release + .lines() + .find_map(|l| l.strip_prefix("PRETTY_NAME=")) + .map(|v| v.trim_matches('"').to_string()) + .unwrap_or_default(); + (cpu, kb("MemTotal"), kb("MemAvailable"), os) +} + +#[cfg(target_os = "macos")] +fn platform() -> (String, u64, u64, String) { + fn run(cmd: &str, args: &[&str]) -> String { + std::process::Command::new(cmd) + .args(args) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() + } + let cpu = run("sysctl", &["-n", "machdep.cpu.brand_string"]); + let total = run("sysctl", &["-n", "hw.memsize"]).parse().unwrap_or(0); + let os = format!("macOS {}", run("sw_vers", &["-productVersion"])); + (cpu, total, 0, os) +} + +#[cfg(all( + not(target_arch = "wasm32"), + not(any(target_os = "windows", target_os = "linux", target_os = "macos")) +))] +fn platform() -> (String, u64, u64, String) { + (String::new(), 0, 0, String::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_bytes_and_unknown() { + assert_eq!(format_bytes(0), "Unknown"); + assert_eq!(format_bytes(16 * 1024 * 1024 * 1024), "16.0 GB"); + } + + #[test] + fn host_facts_are_never_empty() { + let i = system_info(); + assert!(!i.cpu.is_empty()); + assert!(!i.os.is_empty()); + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + assert!(i.ram_total > 0, "installed RAM should be readable"); + } +} diff --git a/src/ui/window/about.rs b/src/ui/window/about.rs index 9d48336ba..3d326563a 100644 --- a/src/ui/window/about.rs +++ b/src/ui/window/about.rs @@ -44,6 +44,39 @@ fn info_card<'a>( .into() } +/// A full-width label/value line. CPU and GPU names are too long for the +/// fixed-width cards above. +fn info_row<'a>( + label: std::borrow::Cow<'static, str>, + value: impl iced::widget::text::IntoFragment<'a>, + width: Length, +) -> Element<'a, Message> { + container( + row![ + text(label).size(11).style(muted_style).width(Length::Fixed(110.0)), + text(value).size(13).width(Fill), + ] + .spacing(8) + .align_y(iced::Center), + ) + .padding([8, 12]) + .width(width) + .style(surface_style) + .into() +} + +/// `Adapter (backend)` for the GPU row. +fn gpu_summary() -> String { + #[cfg(target_arch = "wasm32")] + { + "WebGPU / WebGL".to_string() + } + #[cfg(not(target_arch = "wasm32"))] + { + crate::gpu_backend::active_gpu_summary() + } +} + pub(crate) fn platform_name() -> &'static str { #[cfg(target_arch = "wasm32")] { @@ -69,6 +102,59 @@ pub(crate) fn architecture_name() -> &'static str { } } +/// The text `Copy Info` puts on the clipboard: everything a bug report needs +/// about the build and the machine, including which graphics backend the +/// resolver picked and why. +pub(crate) fn report() -> String { + use crate::sysinfo::{format_bytes, system_info}; + let sys = system_info(); + #[allow(unused_mut)] + let mut out = format!( + "Open CAD Studio v{}\nRevision: {}\nCommit date: {}\nProfile: {}\nFeatures: {}\n\n\ + [System]\nOS: {} ({} {})\nCPU: {}\nLogical processors: {}\nRAM: {} total, {} available\n", + env!("OCS_FULL_VERSION"), + env!("OCS_GIT_REV"), + env!("OCS_COMMIT_DATE"), + env!("OCS_BUILD_PROFILE"), + env!("OCS_BUILD_FEATURES"), + sys.os, + platform_name(), + architecture_name(), + sys.cpu, + sys.logical_cores, + format_bytes(sys.ram_total), + format_bytes(sys.ram_available), + ); + #[cfg(not(target_arch = "wasm32"))] + { + use crate::gpu_backend::{active_gpu, load_prefs}; + let gpu = active_gpu(); + let prefs = load_prefs(); + let or_unknown = |s: &str| { + if s.is_empty() { + "Unknown".to_string() + } else { + s.to_string() + } + }; + out.push_str(&format!( + "\n[Graphics]\nGPU: {}\nDriver: {}\nBackend: {}\nSelected by: {}\n\ + Below WebGPU baseline: {}\nWGPU_BACKEND: {}\n\ + Saved options: backend={}, compat renderer={}, OpenGL on older GPUs={}\n", + or_unknown(gpu.adapter.as_deref().unwrap_or("")), + or_unknown(&gpu.driver), + or_unknown(gpu.backend.as_deref().unwrap_or("")), + or_unknown(&gpu.origin), + if gpu.legacy { "yes" } else { "no" }, + std::env::var("WGPU_BACKEND").unwrap_or_else(|_| "(unset)".into()), + prefs.backend.as_str().unwrap_or("auto"), + prefs.compat_renderer, + prefs.legacy_gl, + )); + } + out +} + /// Build card text: the metadata suffix without its leading `+` /// (`194.gef189d77`, `gef189d77.dirty`), or "Release" for a clean build on /// the release tag. @@ -137,6 +223,19 @@ pub fn view_window( .spacing(8) .width(content_width); + let row_width = if matches!(sizing.width, Length::Fill) { + Fill + } else { + Length::Fixed(148.0 * 3.0 + 16.0) + }; + let system = column![ + info_row(t!("CPU"), crate::sysinfo::cpu_summary(), row_width), + info_row(t!("RAM"), crate::sysinfo::ram_summary(), row_width), + info_row(t!("GPU (backend)"), gpu_summary(), row_width), + ] + .spacing(6) + .width(content_width); + let copy = dialog_button(t!("Copy Info"), Message::AboutCopyInfo, true); container( @@ -144,6 +243,7 @@ pub fn view_window( hero, metadata, build_info, + system, row![Space::new().width(content_width), copy] .width(sizing.width) .align_y(iced::Center), diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index f0e5d24d7..69ecf636e 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -1,3 +1,5 @@ +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod graphics; pub(crate) mod spacemouse; use crate::app::config::UiThemeConfig; use crate::app::settings; @@ -35,6 +37,7 @@ pub enum OptionsTab { Modeling, Selection, UserPreferences, + Graphics, } /// Application preferences the dialog reads that are plain scalars on the app. @@ -201,6 +204,7 @@ pub fn view_window<'a>( selection: SelectionPrefs, prefs: AppPrefs, spacemouse: Element<'a, Message>, + graphics: Element<'a, Message>, snap_angle_input: &'a str, zoom_factor_input: &'a str, drawing_prefs: DrawingPrefs, @@ -1675,6 +1679,7 @@ pub fn view_window<'a>( OptionsTab::Drafting => drafting.into(), OptionsTab::Modeling => modeling.into(), OptionsTab::UserPreferences => user_prefs.into(), + OptionsTab::Graphics => graphics, }; // A vertical rail rather than a horizontal strip: the tab names are @@ -1697,7 +1702,11 @@ pub fn view_window<'a>( tab_button(crate::t!("3D Modeling"), OptionsTab::Modeling), tab_button(crate::t!("Selection"), OptionsTab::Selection), tab_button(crate::t!("User Preferences"), OptionsTab::UserPreferences), - ] + ]; + // The web build has no backend to choose, so it has no Graphics page. + #[cfg(not(target_arch = "wasm32"))] + let tabs = tabs.push(tab_button(crate::t!("Graphics"), OptionsTab::Graphics)); + let tabs = tabs .spacing(2) .width(iced::Length::Fixed(TAB_RAIL_WIDTH)); diff --git a/src/ui/window/options/graphics.rs b/src/ui/window/options/graphics.rs new file mode 100644 index 000000000..a1f722a2d --- /dev/null +++ b/src/ui/window/options/graphics.rs @@ -0,0 +1,102 @@ +//! Options > Graphics: which graphics backend the next launch uses. +//! +//! Everything here is read before the application boots (the backend is +//! chosen first), so changes are saved at once and apply on restart rather +//! than going through OK / Apply. + +use crate::app::Message; +use crate::gpu_backend::{ActiveGpu, BackendChoice, GraphicsPrefs}; +use iced::widget::{checkbox, column, pick_list, row, text, Space}; +use iced::{Element, Fill}; + +fn current_row(label: std::borrow::Cow<'static, str>, value: String) -> Element<'static, Message> { + row![ + text(label).size(12).width(150), + text(value).size(12).width(Fill), + ] + .spacing(10) + .into() +} + +fn unknown(s: &str) -> String { + if s.is_empty() { + crate::t!("Unknown").into_owned() + } else { + s.to_string() + } +} + +pub(crate) fn view(prefs: GraphicsPrefs, active: ActiveGpu) -> Element<'static, Message> { + let backend = pick_list( + Some(prefs.backend), + BackendChoice::available(), + |choice: &BackendChoice| crate::t!(choice.label()).into_owned(), + ) + .on_select(Message::GraphicsBackendChanged) + .width(260); + + column![ + text(crate::t!("Graphics")).size(15), + Space::new().height(6), + text(crate::t!( + "Saved immediately. Changes take effect the next time Open CAD Studio starts." + )) + .size(11) + .width(Fill), + Space::new().height(16), + text(crate::t!("This session")).size(13), + Space::new().height(8), + current_row( + crate::t!("GPU"), + unknown(active.adapter.as_deref().unwrap_or("")) + ), + Space::new().height(4), + current_row(crate::t!("Driver"), unknown(&active.driver)), + Space::new().height(4), + current_row( + crate::t!("Backend"), + unknown(active.backend.as_deref().unwrap_or("")) + ), + Space::new().height(4), + current_row(crate::t!("Selected by"), unknown(&active.origin)), + Space::new().height(20), + text(crate::t!("Next launch")).size(13), + Space::new().height(8), + row![text(crate::t!("Graphics backend")).size(12).width(150), backend] + .spacing(10) + .align_y(iced::Center), + Space::new().height(12), + row![ + checkbox(prefs.compat_renderer) + .on_toggle(Message::GraphicsCompatToggled) + .size(15), + text(crate::t!( + "Use the compatibility renderer (for GPUs without shader storage buffers)" + )) + .size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(10), + row![ + checkbox(prefs.legacy_gl) + .on_toggle(Message::GraphicsLegacyGlToggled) + .size(15), + text(crate::t!( + "Prefer OpenGL on older GPUs (Windows, automatic backend only)" + )) + .size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(16), + text(crate::t!( + "If a pinned backend fails to start, the next launch falls back to automatic selection. \ + The --backend option and the WGPU_BACKEND variable override this page." + )) + .size(11) + .width(Fill), + ] + .spacing(0) + .into() +}