diff --git a/Cargo.lock b/Cargo.lock index 67d9848be..2049b9ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,7 +74,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.0" -source = "git+https://github.com/OpenAEC-Foundation/acadifc?branch=main#3ef1e9c3483c44b93264c93965572c23b8678f17" +source = "git+https://github.com/OpenAEC-Foundation/acadifc?rev=f045889e0243d88b6905124907487e0c0a1bf0e3#f045889e0243d88b6905124907487e0c0a1bf0e3" dependencies = [ "ahash 0.8.12", "anyhow", @@ -84,9 +84,11 @@ dependencies = [ "flate2", "indexmap", "itoa", + "memmap2", "nalgebra", "nom 7.1.3", "once_cell", + "rayon", "ryu", "serde", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index 43da1279e..21bd06b1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,14 +86,11 @@ lyon_tessellation = "1.0.20" # (IApplicationAssociationRegistrationUI dialog). # Win32_System_Registry — HKCU registration that lists the app under # "Open with" for the portable .exe. -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation"] } [patch.crates-io] -# The `acadrust` crate now tracks the OpenAEC-Foundation/acadifc fork's main -# (still the `acadrust` package): the in-flight DWG reader work plus the Arc -# copy-on-write entity storage that makes the undo document clone cheap. Drop the -# patch once these land in a published 0.4 crate. -acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc", branch = "main" } +# Track the DWG reader/writer performance work used by this release. +acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc", rev = "f045889e0243d88b6905124907487e0c0a1bf0e3" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Native enables the plugin host runtime (out-of-process plugins). diff --git a/src/app/document.rs b/src/app/document.rs index 791c104c0..bfb29d9d4 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -13,8 +13,11 @@ use iced; use std::any::Any; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +static NEXT_DOCUMENT_TAB_ID: AtomicU64 = AtomicU64::new(1); + // ── Dynamic input ────────────────────────────────────────────────────────── /// One quantity shown in the dynamic-input overlay near the cursor. @@ -154,9 +157,15 @@ impl DocumentTab { } pub(super) struct DocumentTab { + /// Stable identity across tab insert/remove operations. Background work + /// must never target a tab by its transient vector index. + pub(super) id: u64, pub(super) scene: Scene, pub(super) current_path: Option, pub(super) dirty: bool, + /// Monotonic committed-edit/undo/redo revision. Background save completion + /// uses it with scene epochs so an older snapshot never clears newer work. + pub(super) edit_revision: u64, pub(super) tab_title: String, pub(super) properties: PropertiesPanel, pub(super) layers: LayerPanel, @@ -413,9 +422,11 @@ impl DocumentTab { } } Self { + id: NEXT_DOCUMENT_TAB_ID.fetch_add(1, Ordering::Relaxed), scene, current_path: None, dirty: false, + edit_revision: 0, prev_selection: Vec::new(), tab_title: format!("Drawing{}", n), properties: PropertiesPanel::empty(), diff --git a/src/app/history.rs b/src/app/history.rs index cd185a1ac..8a5a434ae 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -117,6 +117,7 @@ impl OpenCADStudio { fn push_undo_entry(&mut self, i: usize, snapshot: HistorySnapshot) { self.tabs[i].history.undo_stack.push(snapshot); + self.tabs[i].edit_revision = self.tabs[i].edit_revision.wrapping_add(1); self.clear_redo_history(i); self.trim_history(i); } @@ -480,6 +481,7 @@ impl OpenCADStudio { had_full: bool, changes: &[(Handle, crate::scene::ChangeKind)], ) { + self.tabs[i].edit_revision = self.tabs[i].edit_revision.wrapping_add(1); { let scene = &mut self.tabs[i].scene; if had_full { diff --git a/src/app/mod.rs b/src/app/mod.rs index a854cd002..c3ea1f92a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -739,6 +739,10 @@ pub(super) struct OpenCADStudio { // ── Unsaved-changes dialog ──────────────────────────────────────────── /// Set when the user tries to close a tab or quit while there are unsaved changes. pending_close: Option, + /// Latest save job per stable tab id. Older completions may finish, but + /// cannot mark a newer document state clean or redirect its path. + active_save_jobs: std::collections::HashMap, + save_job_serial: u64, /// OS window for the unsaved-changes confirmation dialog. // ── Custom Save-As dialog ───────────────────────────────────────────── @@ -847,6 +851,35 @@ pub(super) enum PendingClose { Quit, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SavePurpose { + Manual, + SaveAs, + Autosave, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SaveContinuation { + None, + CloseTab, + Quit, +} + +#[derive(Debug, Clone)] +pub struct SaveOutcome { + job_id: u64, + tab_id: u64, + epoch: u64, + revision: u64, + camera_generation: u64, + path: PathBuf, + previous_autosave: Option, + set_current_path: bool, + purpose: SavePurpose, + continuation: SaveContinuation, + result: Result<(), String>, +} + /// Where a colour chosen in the standalone palette window should be applied. #[derive(Debug, Clone)] pub enum ColorPickTarget { @@ -1404,8 +1437,8 @@ pub enum Message { AecDropBack, /// Periodic autosave tick — write `.sv$` recovery files for dirty tabs. AutoSave, - /// Save-as path picked for the unsaved-changes → save → close flow. - UnsavedPickedSavePath(Option), + /// Native background save/autosave completed. + SaveFinished(SaveOutcome), // ───────────────────────────────────────────────────────────────────── CommandInput(String), CommandSubmit, @@ -2418,6 +2451,8 @@ impl OpenCADStudio { opening: None, pending_opens: std::collections::VecDeque::new(), pending_close: None, + active_save_jobs: std::collections::HashMap::new(), + save_job_serial: 0, save_dialog_format: "DWG 2018".to_string(), save_dialog_filename: "drawing.dwg".to_string(), save_dialog_for_unsaved: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 5351c470f..7079f004f 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -234,130 +234,83 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } pub(super) fn on_unsaved_dialog_save(&mut self) -> Task { - match self.pending_close.take() { - Some(crate::app::PendingClose::Tab(idx)) => { - if let Some(path) = if cfg!(target_arch = "wasm32") { None } else { self.tabs[idx].current_path.clone() } { - match crate::io::save(&self.tabs[idx].scene.document, &path) { - Ok(()) => { - self.command_line - .push_output(&format!("Saved: {}", path.display())); - self.tabs[idx].dirty = false; - let close_win = self.close_unsaved_dialog_window(); - let close_tab = self.update(Message::TabClose(idx)); - return Task::batch(vec![close_win, close_tab]); - } - Err(e) => { - // Keep dialog open for retry. - self.command_line.push_error(&format!("Save failed: {e}")); - self.pending_close = Some(crate::app::PendingClose::Tab(idx)); - } - } - } else { - // No path — close unsaved dialog and save a default - // DWG 2018 via the native destination dialog (no - // version picker; that is reserved for Save As). - self.pending_close = Some(crate::app::PendingClose::Tab(idx)); - self.save_dialog_for_unsaved = true; - let close_win = self.close_unsaved_dialog_window(); - let open_save = self.save_default_dwg2018(idx); - return Task::batch([close_win, open_save]); - } - } - Some(crate::app::PendingClose::Quit) => { - if let Some(idx) = self.tabs.iter().position(|t| t.dirty) { - if let Some(path) = if cfg!(target_arch = "wasm32") { None } else { self.tabs[idx].current_path.clone() } { - match crate::io::save(&self.tabs[idx].scene.document, &path) { - Ok(()) => { - self.command_line - .push_output(&format!("Saved: {}", path.display())); - self.tabs[idx].dirty = false; - } - Err(e) => { - self.command_line.push_error(&format!("Save failed: {e}")); - self.pending_close = Some(crate::app::PendingClose::Quit); - return Task::none(); - } - } - } else { - // No path — close unsaved dialog and save a - // default DWG 2018 via the native destination - // dialog (version picker is Save-As only). - self.active_tab = idx; - self.pending_close = Some(crate::app::PendingClose::Quit); - self.save_dialog_for_unsaved = true; - let close_win = self.close_unsaved_dialog_window(); - let open_save = self.save_default_dwg2018(idx); - return Task::batch([close_win, open_save]); - } - } - if self.tabs.iter().any(|t| t.dirty) { - // More dirty tabs — keep window open. - self.pending_close = Some(crate::app::PendingClose::Quit); - } else { - let close_win = self.close_unsaved_dialog_window(); - return Task::batch(vec![close_win, self.exit_app()]); - } - } - None => {} + #[cfg(not(target_arch = "wasm32"))] + { + let Some(pending) = self.pending_close.clone() else { + return Task::none(); + }; + let (idx, continuation) = match pending { + crate::app::PendingClose::Tab(idx) => { + (idx, crate::app::SaveContinuation::CloseTab) } - Task::none() + crate::app::PendingClose::Quit => { + let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) else { + self.pending_close = None; + return Task::batch([ + self.close_unsaved_dialog_window(), + self.exit_app(), + ]); + }; + (idx, crate::app::SaveContinuation::Quit) + } + }; + + if self.active_save_jobs.contains_key(&self.tabs[idx].id) { + self.command_line + .push_info("Save already running for this drawing."); + return Task::none(); + } + + if let Some(path) = self.tabs[idx].current_path.clone() { + let version = self.tabs[idx].scene.document.version; + self.prepare_native_save(idx); + let close = self.close_unsaved_dialog_window(); + let save = self.queue_native_save( + idx, + path, + version, + crate::app::SavePurpose::Manual, + continuation, + false, + ); + return Task::batch([close, save]); + } + + self.active_tab = idx; + self.save_dialog_for_unsaved = true; + let close = self.close_unsaved_dialog_window(); + let save = self.save_default_dwg2018(idx); + return Task::batch([close, save]); + } + + #[cfg(target_arch = "wasm32")] + { + match self.pending_close.take() { + Some(crate::app::PendingClose::Tab(idx)) => { + self.pending_close = Some(crate::app::PendingClose::Tab(idx)); + self.save_dialog_for_unsaved = true; + let close = self.close_unsaved_dialog_window(); + let save = self.save_default_dwg2018(idx); + Task::batch([close, save]) + } + Some(crate::app::PendingClose::Quit) => { + if let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) { + self.active_tab = idx; + self.pending_close = Some(crate::app::PendingClose::Quit); + self.save_dialog_for_unsaved = true; + let close = self.close_unsaved_dialog_window(); + let save = self.save_default_dwg2018(idx); + Task::batch([close, save]) + } else { + Task::batch([ + self.close_unsaved_dialog_window(), + self.exit_app(), + ]) + } + } + None => Task::none(), + } + } } - pub(super) fn on_unsaved_picked_save_path_some(&mut self, path: std::path::PathBuf) -> Task { - let (_ext, version) = crate::io::parse_save_format(&self.save_dialog_format); - match self.pending_close.take() { - Some(crate::app::PendingClose::Tab(idx)) => { - match crate::io::save_as_version( - &self.tabs[idx].scene.document, - &path, - version, - ) { - Ok(()) => { - self.command_line - .push_output(&format!("Saved: {}", path.display())); - #[cfg(not(target_arch = "wasm32"))] - let _ = std::fs::remove_file(self.autosave_target(idx)); - self.tabs[idx].current_path = Some(path); - self.tabs[idx].dirty = false; - return self.update(Message::TabClose(idx)); - } - Err(e) => { - self.command_line.push_error(&format!("Save failed: {e}")); - self.pending_close = Some(crate::app::PendingClose::Tab(idx)); - return self.open_unsaved_dialog_window(); - } - } - } - Some(crate::app::PendingClose::Quit) => { - let i = self.active_tab; - match crate::io::save_as_version( - &self.tabs[i].scene.document, - &path, - version, - ) { - Ok(()) => { - self.command_line - .push_output(&format!("Saved: {}", path.display())); - #[cfg(not(target_arch = "wasm32"))] - let _ = std::fs::remove_file(self.autosave_target(i)); - self.tabs[i].current_path = Some(path); - self.tabs[i].dirty = false; - if self.tabs.iter().any(|t| t.dirty) { - self.pending_close = Some(crate::app::PendingClose::Quit); - return self.open_unsaved_dialog_window(); - } else { - return self.exit_app(); - } - } - Err(e) => { - self.command_line.push_error(&format!("Save failed: {e}")); - self.pending_close = Some(crate::app::PendingClose::Quit); - return self.open_unsaved_dialog_window(); - } - } - } - None => {} - } - Task::none() - } } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 23555c023..02d27f6ff 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -768,6 +768,213 @@ pub(super) fn on_open_file(&mut self) -> Task { } } + #[cfg(not(target_arch = "wasm32"))] + pub(in crate::app) fn prepare_native_save(&mut self, i: usize) { + self.sync_vport_display(i); + sync_annotation_scale_header(&mut self.tabs[i].scene); + self.stamp_header_sysvars(i); + self.tabs[i].scene.document.header.user_real1 = + self.tabs[i].scene.annotation_scale as f64; + self.sync_truck_solids_to_acis(i); + } + + #[cfg(not(target_arch = "wasm32"))] + pub(in crate::app) fn queue_native_save( + &mut self, + i: usize, + path: std::path::PathBuf, + version: acadrust::DxfVersion, + purpose: crate::app::SavePurpose, + continuation: crate::app::SaveContinuation, + set_current_path: bool, + ) -> Task { + let tab_id = self.tabs[i].id; + if self.active_save_jobs.contains_key(&tab_id) { + if purpose != crate::app::SavePurpose::Autosave { + self.command_line + .push_info("Save already running for this drawing."); + } + return Task::none(); + } + + let epoch = self.tabs[i].scene.geometry_epoch; + let revision = self.tabs[i].edit_revision; + let camera_generation = self.tabs[i].scene.camera_generation; + let thumbnail = if purpose == crate::app::SavePurpose::Autosave { + None + } else { + let scene = &self.tabs[i].scene; + Some(( + scene.entity_wires(), + scene.camera.borrow().clone(), + scene.bg_color, + version >= acadrust::DxfVersion::AC1027, + self.vp_size, + )) + }; + let clone_started = std::time::Instant::now(); + let mut snapshot = self.tabs[i].scene.document.clone(); + let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0; + if std::env::var_os("OCS_PERF").is_some() { + eprintln!( + "[perf] save-snapshot {:.1}ms entities={} objects={} purpose={purpose:?}", + clone_ms, + snapshot.entities().count(), + snapshot.objects.len(), + ); + } + + self.save_job_serial = self.save_job_serial.wrapping_add(1); + let job_id = self.save_job_serial; + self.active_save_jobs.insert(tab_id, job_id); + let previous_autosave = set_current_path.then(|| self.autosave_target(i)); + let backup = purpose != crate::app::SavePurpose::Autosave && self.backup_on_save; + let worker_path = path.clone(); + + Task::perform( + async move { + let result = std::thread::spawn(move || { + if let Some((wires, camera, bg_color, png, viewport)) = thumbnail { + let started = std::time::Instant::now(); + snapshot.preview = crate::io::thumbnail::from_snapshot( + &wires, + &camera, + bg_color, + png, + viewport, + ); + if std::env::var_os("OCS_PERF").is_some() { + eprintln!( + "[perf] save-thumbnail {:.1}ms wires={}", + started.elapsed().as_secs_f64() * 1000.0, + wires.len(), + ); + } + } + crate::io::save_owned_as_version_atomic( + snapshot, + &worker_path, + version, + backup, + ) + }) + .join() + .unwrap_or_else(|_| Err("save worker panicked".to_string())); + crate::app::SaveOutcome { + job_id, + tab_id, + epoch, + revision, + camera_generation, + path, + previous_autosave, + set_current_path, + purpose, + continuation, + result, + } + }, + Message::SaveFinished, + ) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn on_save_finished( + &mut self, + outcome: crate::app::SaveOutcome, + ) -> Task { + let latest = self.active_save_jobs.get(&outcome.tab_id).copied() + == Some(outcome.job_id); + if latest { + self.active_save_jobs.remove(&outcome.tab_id); + } + + let Some(i) = self.tabs.iter().position(|tab| tab.id == outcome.tab_id) else { + if outcome.purpose == crate::app::SavePurpose::Autosave { + let _ = std::fs::remove_file(&outcome.path); + } + return Task::none(); + }; + if !latest { + if outcome.purpose == crate::app::SavePurpose::Autosave { + let _ = std::fs::remove_file(&outcome.path); + } + return Task::none(); + } + + if let Err(error) = outcome.result { + self.command_line + .push_error(&format!("Save failed: {error}")); + return match outcome.continuation { + crate::app::SaveContinuation::CloseTab => { + self.pending_close = Some(crate::app::PendingClose::Tab(i)); + self.open_unsaved_dialog_window() + } + crate::app::SaveContinuation::Quit => { + self.pending_close = Some(crate::app::PendingClose::Quit); + self.open_unsaved_dialog_window() + } + crate::app::SaveContinuation::None => Task::none(), + }; + } + + let snapshot_is_current = self.tabs[i].scene.geometry_epoch == outcome.epoch + && self.tabs[i].edit_revision == outcome.revision + && self.tabs[i].scene.camera_generation == outcome.camera_generation; + let mut tasks = Vec::new(); + match outcome.purpose { + crate::app::SavePurpose::Autosave => { + self.command_line.push_output("Autosaved 1 drawing"); + } + crate::app::SavePurpose::Manual | crate::app::SavePurpose::SaveAs => { + self.command_line + .push_output(&format!("Saved: {}", outcome.path.display())); + self.recent_thumbs.remove(&outcome.path); + if let Some(previous) = outcome.previous_autosave { + if previous != outcome.path { + let _ = std::fs::remove_file(previous); + } + } + if outcome.set_current_path { + self.tabs[i].current_path = Some(outcome.path.clone()); + tasks.push(self.push_recent(outcome.path.clone())); + } + if snapshot_is_current { + self.tabs[i].dirty = false; + let _ = std::fs::remove_file(outcome.path.with_extension("sv$")); + } + } + } + + match outcome.continuation { + crate::app::SaveContinuation::None => {} + crate::app::SaveContinuation::CloseTab if snapshot_is_current => { + self.pending_close = None; + tasks.push(self.close_unsaved_dialog_window()); + tasks.push(self.update(Message::TabClose(i))); + } + crate::app::SaveContinuation::Quit if snapshot_is_current => { + self.pending_close = None; + if self.tabs.iter().any(|tab| tab.dirty) { + self.pending_close = Some(crate::app::PendingClose::Quit); + tasks.push(self.open_unsaved_dialog_window()); + } else { + tasks.push(self.close_unsaved_dialog_window()); + tasks.push(self.exit_app()); + } + } + crate::app::SaveContinuation::CloseTab => { + self.pending_close = Some(crate::app::PendingClose::Tab(i)); + tasks.push(self.open_unsaved_dialog_window()); + } + crate::app::SaveContinuation::Quit => { + self.pending_close = Some(crate::app::PendingClose::Quit); + tasks.push(self.open_unsaved_dialog_window()); + } + } + Task::batch(tasks) + } + pub(super) fn on_save_file(&mut self) -> Task { if self.read_only { self.command_line @@ -775,37 +982,28 @@ pub(super) fn on_open_file(&mut self) -> Task { 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 - // in-session toggle (#121). - self.sync_vport_display(i); - // Persist Ortho ($ORTHOMODE) + running OSNAP ($OSMODE) into the - // drawing header so they survive save/reopen (per-drawing). - self.stamp_header_sysvars(i); + // Web serializes immediately below. Native preparation happens + // once after the destination/version is known. + #[cfg(target_arch = "wasm32")] + { + self.sync_vport_display(i); + self.stamp_header_sysvars(i); + } // Native: save straight to the known path. Web has no path // (downloads instead), so always go through the Save dialog. #[cfg(not(target_arch = "wasm32"))] if let Some(path) = self.tabs[i].current_path.clone() { - self.tabs[i].scene.document.header.user_real1 = - self.tabs[i].scene.annotation_scale as f64; // A direct Save preserves the document's current version. - if self.backup_on_save { - crate::io::write_backup(&path); - } - self.sync_truck_solids_to_acis(i); let ver = self.tabs[i].scene.document.version; - self.stamp_thumbnail(i, ver); - match crate::io::save(&self.tabs[i].scene.document, &path) { - Ok(()) => { - self.command_line - .push_output(&format!("Saved: {}", path.display())); - self.tabs[i].dirty = false; - // A clean save supersedes any autosave recovery copy. - let _ = std::fs::remove_file(path.with_extension("sv$")); - } - Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), - } - return Task::none(); + self.prepare_native_save(i); + return self.queue_native_save( + i, + path, + ver, + crate::app::SavePurpose::Manual, + crate::app::SaveContinuation::None, + false, + ); } self.save_dialog_for_unsaved = false; self.save_default_dwg2018(i) @@ -937,37 +1135,24 @@ pub(super) fn on_open_file(&mut self) -> Task { }; let (_ext, version) = crate::io::parse_save_format(&self.save_dialog_format); let i = self.active_tab; - sync_annotation_scale_header(&mut self.tabs[i].scene); - // Persist Ortho / running OSNAP into the header (Save-As path). - self.stamp_header_sysvars(i); - self.sync_truck_solids_to_acis(i); - self.stamp_thumbnail(i, version); - if self.backup_on_save { - crate::io::write_backup(&path); - } - match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) { - Ok(()) => { - self.command_line - .push_output(&format!("Saved: {}", path.display())); - // Drop any prior autosave copy — including the temp one used - // while the drawing was still unsaved — before the tab takes on - // its new path. - let _ = std::fs::remove_file(self.autosave_target(i)); - self.tabs[i].current_path = Some(path.clone()); - self.tabs[i].dirty = false; - let _ = std::fs::remove_file(path.with_extension("sv$")); - let recent = self.push_recent(path.clone()); - if self.save_dialog_for_unsaved { - return Task::batch([ - recent, - self.update(Message::UnsavedPickedSavePath(Some(path))), - ]); - } - return recent; + self.prepare_native_save(i); + let continuation = if self.save_dialog_for_unsaved { + match self.pending_close { + Some(crate::app::PendingClose::Tab(_)) => crate::app::SaveContinuation::CloseTab, + Some(crate::app::PendingClose::Quit) => crate::app::SaveContinuation::Quit, + None => crate::app::SaveContinuation::None, } - Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), - } - Task::none() + } else { + crate::app::SaveContinuation::None + }; + self.queue_native_save( + i, + path, + version, + crate::app::SavePurpose::SaveAs, + continuation, + true, + ) } /// AEC-drop warning → "Save anyway": accept the loss and proceed with the @@ -1019,25 +1204,25 @@ pub(super) fn on_open_file(&mut self) -> Task { /// touches the original file or the dirty flag. #[cfg(not(target_arch = "wasm32"))] pub(super) fn on_autosave(&mut self) -> Task { - let mut n = 0; + let mut tasks = Vec::new(); for i in 0..self.tabs.len() { - if !self.tabs[i].dirty { + if !self.tabs[i].dirty + || self.active_save_jobs.contains_key(&self.tabs[i].id) + { continue; } let version = self.tabs[i].scene.document.version; - if let Ok(bytes) = - crate::io::save_to_bytes(&self.tabs[i].scene.document, "dwg", version) - { - if std::fs::write(self.autosave_target(i), bytes).is_ok() { - n += 1; - } - } + let target = self.autosave_target(i); + tasks.push(self.queue_native_save( + i, + target, + version, + crate::app::SavePurpose::Autosave, + crate::app::SaveContinuation::None, + false, + )); } - if n > 0 { - self.command_line - .push_output(&format!("Autosaved {n} drawing(s)")); - } - Task::none() + Task::batch(tasks) } #[cfg(target_arch = "wasm32")] diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8134a4916..73bbdf96c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3770,17 +3770,11 @@ impl OpenCADStudio { Message::AutoSave => self.on_autosave(), - Message::UnsavedPickedSavePath(Some(path)) => { - self.on_unsaved_picked_save_path_some(path) - } + #[cfg(not(target_arch = "wasm32"))] + Message::SaveFinished(outcome) => self.on_save_finished(outcome), - Message::UnsavedPickedSavePath(None) => { - // User cancelled the save-as dialog — re-open the confirmation dialog. - if self.pending_close.is_some() { - return self.open_unsaved_dialog_window(); - } - Task::none() - } + #[cfg(target_arch = "wasm32")] + Message::SaveFinished(_) => Task::none(), // ── Page Setup ──────────────────────────────────────────────── Message::UpdateCheckResult(latest) => { diff --git a/src/io/mod.rs b/src/io/mod.rs index f51fcc46d..be8771e6d 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -183,6 +183,12 @@ pub fn load_file(path: &Path) -> Result { match effective.as_str() { "dwg" => { + #[cfg(not(target_arch = "wasm32"))] + let mut doc = DwgReader::from_mmap(path) + .map_err(|e| e.to_string())? + .read() + .map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] let mut doc = DwgReader::from_file(path) .map_err(|e| e.to_string())? .read() @@ -418,19 +424,130 @@ pub fn save_as_version( path: &Path, version: acadrust::DxfVersion, ) -> Result<(), String> { - let mut doc = doc.clone(); + let clone_started = std::time::Instant::now(); + let snapshot = doc.clone(); + let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0; + save_owned_as_version_inner(snapshot, path, version, false, clone_ms) +} + +/// Save an owned document snapshot. Preparation, serialization, compression and +/// disk I/O can therefore run on a worker without borrowing live editor state. +/// Output is written beside the destination and atomically renamed only after a +/// complete file exists, so a failed save cannot truncate the previous drawing. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub fn save_owned_as_version_atomic( + doc: CadDocument, + path: &Path, + version: acadrust::DxfVersion, + backup: bool, +) -> Result<(), String> { + save_owned_as_version_inner(doc, path, version, backup, 0.0) +} + +fn save_owned_as_version_inner( + mut doc: CadDocument, + path: &Path, + version: acadrust::DxfVersion, + backup: bool, + clone_ms: f64, +) -> Result<(), String> { + let perf = std::env::var_os("OCS_PERF").is_some(); + let total_started = std::time::Instant::now(); doc.version = version; + let styles_started = std::time::Instant::now(); sync_current_styles_on_save(&mut doc); + let styles_ms = styles_started.elapsed().as_secs_f64() * 1000.0; + let dimensions_started = std::time::Instant::now(); crate::modules::draw::modify::explode::bake_dimension_blocks(&mut doc); - let ext = path + let dimensions_ms = dimensions_started.elapsed().as_secs_f64() * 1000.0; + let temp_path = save_temp_path(path); + let ext = temp_path .extension() .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); - match ext.as_str() { + let write_started = std::time::Instant::now(); + let result = match ext.as_str() { "dxf" => DxfWriter::new(&doc) - .write_to_file(path) + .write_to_file(&temp_path) .map_err(|e| e.to_string()), - _ => DwgWriter::write_to_file(path, &doc).map_err(|e| e.to_string()), + _ => DwgWriter::write_to_file(&temp_path, &doc).map_err(|e| e.to_string()), + }; + if let Err(error) = result { + let _ = std::fs::remove_file(&temp_path); + return Err(error); + } + if backup { + write_backup(path); + } + if let Err(error) = replace_save_file(&temp_path, path) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("replace {}: {error}", path.display())); + } + if perf { + eprintln!( + "[perf] save total={:.1}ms clone={:.1} styles={:.1} dimensions={:.1} write={:.1} entities={} objects={} path={}", + total_started.elapsed().as_secs_f64() * 1000.0, + clone_ms, + styles_ms, + dimensions_ms, + write_started.elapsed().as_secs_f64() * 1000.0, + doc.entities().count(), + doc.objects.len(), + path.display(), + ); + } + Ok(()) +} + +fn save_temp_path(path: &Path) -> PathBuf { + static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let stem = path + .file_stem() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "drawing".to_string()); + let extension = path + .extension() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "dwg".to_string()); + let name = format!( + ".{stem}.ocs-save-{}-{serial}.{extension}", + std::process::id() + ); + path.parent().unwrap_or_else(|| Path::new(".")).join(name) +} + +#[cfg(not(target_os = "windows"))] +fn replace_save_file(temp_path: &Path, path: &Path) -> std::io::Result<()> { + std::fs::rename(temp_path, path) +} + +#[cfg(target_os = "windows")] +fn replace_save_file(temp_path: &Path, path: &Path) -> std::io::Result<()> { + if !path.exists() { + return std::fs::rename(temp_path, path); + } + use std::os::windows::ffi::OsStrExt; + let replaced: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let replacement: Vec = temp_path + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::ReplaceFileW( + replaced.as_ptr(), + replacement.as_ptr(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null(), + ) + }; + if ok == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) } } @@ -450,18 +567,42 @@ pub fn save_to_bytes( ext: &str, version: acadrust::DxfVersion, ) -> Result, String> { + let perf = std::env::var_os("OCS_PERF").is_some(); + let total_started = std::time::Instant::now(); + let clone_started = std::time::Instant::now(); let mut doc = doc.clone(); + let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0; doc.version = version; + let styles_started = std::time::Instant::now(); sync_current_styles_on_save(&mut doc); + let styles_ms = styles_started.elapsed().as_secs_f64() * 1000.0; + let dimensions_started = std::time::Instant::now(); crate::modules::draw::modify::explode::bake_dimension_blocks(&mut doc); - match ext.to_lowercase().as_str() { + let dimensions_ms = dimensions_started.elapsed().as_secs_f64() * 1000.0; + let write_started = std::time::Instant::now(); + let result = match ext.to_lowercase().as_str() { "dxf" => DxfWriter::new(&doc).write_to_vec().map_err(|e| e.to_string()), _ => { let mut buf = std::io::Cursor::new(Vec::new()); DwgWriter::write_to_writer(&mut buf, &doc).map_err(|e| e.to_string())?; Ok(buf.into_inner()) } + }; + if perf { + let bytes = result.as_ref().map_or(0, Vec::len); + eprintln!( + "[perf] save-bytes total={:.1}ms clone={:.1} styles={:.1} dimensions={:.1} write={:.1} bytes={} entities={} objects={}", + total_started.elapsed().as_secs_f64() * 1000.0, + clone_ms, + styles_ms, + dimensions_ms, + write_started.elapsed().as_secs_f64() * 1000.0, + bytes, + doc.entities().count(), + doc.objects.len(), + ); } + result } diff --git a/src/io/thumbnail.rs b/src/io/thumbnail.rs index e9d70256f..2868ec9d3 100644 --- a/src/io/thumbnail.rs +++ b/src/io/thumbnail.rs @@ -15,6 +15,7 @@ use image::{ImageFormat, Rgb, RgbImage}; use std::io::Cursor; use crate::scene::{Scene, WireModel}; +use crate::scene::view::camera::Camera; /// Longest edge of the generated thumbnail, in pixels. const MAX_DIM: u32 = 256; @@ -31,6 +32,20 @@ const MAX_DIM: u32 = 256; /// (AC1027) on, so the caller passes `false` for older targets → BMP/DIB. pub fn from_scene(scene: &Scene, png: bool, viewport: (f32, f32)) -> Option { let wires = scene.entity_wires(); + let camera = scene.camera.borrow(); + from_snapshot(&wires, &camera, scene.bg_color, png, viewport) +} + +/// Build a preview from immutable render inputs. Native background saves retain +/// the resident wire `Arc` and a small camera copy, then run the point scan and +/// image encoding on the save worker instead of blocking the UI thread. +pub fn from_snapshot( + wires: &[WireModel], + camera: &Camera, + bg_color: [f32; 4], + png: bool, + viewport: (f32, f32), +) -> Option { if wires.is_empty() { return None; } @@ -49,9 +64,8 @@ pub fn from_scene(scene: &Scene, png: bool, viewport: (f32, f32)) -> Option Vec { /// field but not `base`, so without this the saved group 10 goes stale and the /// dimension's line / leader / origin jumps on reload (#181). Angular-2-line /// keeps a distinct base point (the second line's point) and is left alone. -fn sync_dimension_base_points(doc: &mut CadDocument) { - for e in doc.entities_mut() { - if let EntityType::Dimension(d) = e { - let def = match d { - Dimension::Linear(x) => Some(x.definition_point), - Dimension::Aligned(x) => Some(x.definition_point), - Dimension::Radius(x) => Some(x.definition_point), - Dimension::Diameter(x) => Some(x.definition_point), - Dimension::Ordinate(x) => Some(x.definition_point), - Dimension::Angular3Pt(x) => Some(x.definition_point), +fn sync_dimension_base_points_and_collect_pending(doc: &mut CadDocument) -> Vec { + let existing_blocks: rustc_hash::FxHashSet = doc + .block_records + .iter() + .map(|record| record.name.to_ascii_lowercase()) + .collect(); + let dimensions: Vec<(Handle, Option, bool)> = doc + .entities() + .filter_map(|entity| { + let EntityType::Dimension(dimension) = entity else { + return None; + }; + let definition_point = match dimension { + Dimension::Linear(value) => Some(value.definition_point), + Dimension::Aligned(value) => Some(value.definition_point), + Dimension::Radius(value) => Some(value.definition_point), + Dimension::Diameter(value) => Some(value.definition_point), + Dimension::Ordinate(value) => Some(value.definition_point), + Dimension::Angular3Pt(value) => Some(value.definition_point), Dimension::Angular2Ln(_) => None, }; - if let Some(p) = def { - d.base_mut().definition_point = p; + let base = dimension.base(); + let needs_sync = + definition_point.filter(|point| *point != base.definition_point); + let block_missing = base.block_name.trim().is_empty() + || !existing_blocks.contains(&base.block_name.to_ascii_lowercase()); + Some((base.common.handle, needs_sync, block_missing)) + }) + .collect(); + + let mut pending = Vec::new(); + for (handle, definition_point, block_missing) in dimensions { + if let Some(point) = definition_point { + if let Some(EntityType::Dimension(dimension)) = doc.get_entity_mut(handle) { + dimension.base_mut().definition_point = point; } } + if block_missing { + pending.push(handle); + } } + pending } /// Smallest free `*D` anonymous block name in `doc`. -fn next_dimension_block_name(doc: &CadDocument) -> String { - let mut n = 0u64; +fn next_dimension_block_name(doc: &CadDocument, next: &mut u64) -> String { loop { + let n = *next; + *next = (*next).saturating_add(1); let cand = format!("*D{n}"); if doc.block_records.get(&cand).is_none() { return cand; } - n += 1; } } @@ -993,37 +1018,32 @@ fn next_dimension_block_name(doc: &CadDocument) -> String { /// DWG, or copied via the `*D`-cloning copy path) are left untouched so their /// original graphics are preserved. pub fn bake_dimension_blocks(doc: &mut CadDocument) { - // Keep group-10 (base.definition_point) in step with the per-type geometry - // before writing — see sync_dimension_base_points. - sync_dimension_base_points(doc); - - // Handles of dimensions whose block reference is missing or dangling. - let pending: Vec = doc - .entities() - .filter_map(|e| match e { - EntityType::Dimension(d) => { - let bn = &d.base().block_name; - if bn.trim().is_empty() || doc.block_records.get(bn).is_none() { - Some(d.base().common.handle) - } else { - None - } - } + // Keep group-10 in step and find missing blocks in one entity pass. Existing + // `*D` blocks are the save cache: only invalidated/new dimensions enter the + // relatively expensive geometry generation below. + let pending = sync_dimension_base_points_and_collect_pending(doc); + let dimensions: Vec<(Handle, Dimension)> = pending + .into_iter() + .filter_map(|handle| match doc.get_entity(handle) { + Some(EntityType::Dimension(d)) => Some((handle, d.clone())), _ => None, }) .collect(); - for handle in pending { - let dim = match doc.get_entity(handle) { - Some(EntityType::Dimension(d)) => d.clone(), - _ => continue, - }; - let subs = explode_dimension(&dim, doc); + use crate::par::prelude::*; + let doc_ref: &CadDocument = doc; + let generated: Vec<(Handle, Vec)> = dimensions + .par_iter() + .map(|(handle, dim)| (*handle, explode_dimension(dim, doc_ref))) + .collect(); + + let mut next_block_number = 0u64; + for (handle, subs) in generated { if subs.is_empty() { continue; } - let name = next_dimension_block_name(doc); + let name = next_dimension_block_name(doc, &mut next_block_number); // Reserve three consecutive handles for the record / block / endblk. // Adding the block + endblk (which carry explicit handles) advances the // document's handle counter past them, so the NULL-handle sub-entities diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 2c3ea480e..e60c33f92 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -479,6 +479,128 @@ pub struct InteractionHandleIndex { handles: SpatialSet, } +struct WireIndexEntries { + wire: Option>, + segments: Vec>, + snap_points: Vec>, + key_vertices: Vec>, + key_segments: Vec>, + fill_triangles: Vec>, + pick_triangles: Vec>, + glyphs: Vec>, + unbounded: bool, + max_line_half_width_px: f32, +} + +fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntries { + let mut entries = WireIndexEntries { + wire: finite_wire_aabb3(wire).map(|aabb| Entry3 { + aabb, + value: wire_idx, + }), + segments: Vec::with_capacity(wire.points.len().saturating_sub(1)), + snap_points: Vec::with_capacity(wire.snap_pts.len()), + key_vertices: Vec::with_capacity(wire.key_vertices.len()), + key_segments: Vec::with_capacity(wire.key_vertices.len().saturating_sub(1)), + fill_triangles: Vec::with_capacity(wire.fill_tris.len() / 3), + pick_triangles: Vec::with_capacity(wire.pick_tris.len() / 3), + glyphs: Vec::with_capacity(wire.text_verts.len() / 6), + unbounded: false, + max_line_half_width_px: if wire.line_weight_px.is_finite() { + (wire.line_weight_px * 0.5).max(0.0) + } else { + 0.0 + }, + }; + entries.unbounded = entries.wire.is_none(); + + for start in 0..wire.points.len().saturating_sub(1) { + let Some(aabb) = points_aabb3([ + wire_point(wire, start), + wire_point(wire, start + 1), + ]) else { + continue; + }; + entries.segments.push(Entry3 { + aabb, + value: SegmentRef { + wire: wire_idx, + start: start as u32, + }, + }); + } + for (index, (point, _)) in wire.snap_pts.iter().enumerate() { + if point.is_finite() { + entries.snap_points.push(Entry3 { + aabb: [point.x, point.y, point.z, point.x, point.y, point.z], + value: SnapPointRef { + wire: wire_idx, + index: index as u32, + }, + }); + } + } + for (index, &point) in wire.key_vertices.iter().enumerate() { + if point.iter().all(|value| value.is_finite()) { + entries.key_vertices.push(Entry3 { + aabb: [point[0], point[1], point[2], point[0], point[1], point[2]], + value: KeyVertexRef { + wire: wire_idx, + index: index as u32, + }, + }); + } + } + for start in 0..wire.key_vertices.len().saturating_sub(1) { + let Some(aabb) = + points_aabb3([wire.key_vertices[start], wire.key_vertices[start + 1]]) + else { + continue; + }; + entries.key_segments.push(Entry3 { + aabb, + value: KeySegmentRef { + wire: wire_idx, + start: start as u32, + }, + }); + } + append_triangle_entries( + wire_idx, + &wire.fill_tris, + &wire.fill_tris_low, + &mut entries.fill_triangles, + ); + append_triangle_entries( + wire_idx, + &wire.pick_tris, + &wire.pick_tris_low, + &mut entries.pick_triangles, + ); + for start in (0..wire.text_verts.len()).step_by(6) { + let Some(quad) = wire.text_verts.get(start..start + 6) else { + break; + }; + let Some(aabb) = points_aabb3(quad.iter().map(|vertex| { + [ + vertex.pos[0] as f64 + vertex.pos_low[0] as f64, + vertex.pos[1] as f64 + vertex.pos_low[1] as f64, + vertex.pos[2] as f64 + vertex.pos_low[2] as f64, + ] + })) else { + continue; + }; + entries.glyphs.push(Entry3 { + aabb, + value: GlyphRef { + wire: wire_idx, + start: start as u32, + }, + }); + } + entries +} + impl InteractionHandleIndex { pub fn build(entries: impl IntoIterator) -> Self { Self { @@ -554,106 +676,76 @@ impl InteractionIndex { let mut unbounded_wires = Vec::new(); let mut max_line_half_width_px = 0.0f32; - for (wire_idx, wire) in wires.iter().enumerate() { - if wire.line_weight_px.is_finite() { - max_line_half_width_px = - max_line_half_width_px.max((wire.line_weight_px * 0.5).max(0.0)); - } - let wire_idx = wire_idx as u32; - if let Some(aabb) = finite_wire_aabb3(wire) { - wire_entries.push(Entry3 { - aabb, - value: wire_idx, - }); - } else { - unbounded_wires.push(wire_idx); - } + #[cfg(not(target_arch = "wasm32"))] + let per_wire: Vec = { + use crate::par::prelude::*; + wires + .par_iter() + .enumerate() + .map(|(index, wire)| collect_wire_index_entries(index as u32, wire)) + .collect() + }; + #[cfg(target_arch = "wasm32")] + let per_wire: Vec = wires + .iter() + .enumerate() + .map(|(index, wire)| collect_wire_index_entries(index as u32, wire)) + .collect(); - for start in 0..wire.points.len().saturating_sub(1) { - let a = wire_point(wire, start); - let b = wire_point(wire, start + 1); - let Some(aabb) = points_aabb3([a, b]) else { - continue; - }; - segment_entries.push(Entry3 { - aabb, - value: SegmentRef { - wire: wire_idx, - start: start as u32, - }, - }); + // Every per-wire worker knows its exact output sizes. Reserve the flat + // arrays once before draining them so a dense block drawing does not + // repeatedly copy already-flattened entries while the Vecs grow. + wire_entries.reserve(per_wire.iter().filter(|entries| entries.wire.is_some()).count()); + unbounded_wires.reserve(per_wire.iter().filter(|entries| entries.unbounded).count()); + segment_entries.reserve(per_wire.iter().map(|entries| entries.segments.len()).sum()); + snap_point_entries.reserve( + per_wire + .iter() + .map(|entries| entries.snap_points.len()) + .sum(), + ); + key_vertex_entries.reserve( + per_wire + .iter() + .map(|entries| entries.key_vertices.len()) + .sum(), + ); + key_segment_entries.reserve( + per_wire + .iter() + .map(|entries| entries.key_segments.len()) + .sum(), + ); + fill_triangle_entries.reserve( + per_wire + .iter() + .map(|entries| entries.fill_triangles.len()) + .sum(), + ); + pick_triangle_entries.reserve( + per_wire + .iter() + .map(|entries| entries.pick_triangles.len()) + .sum(), + ); + glyph_entries.reserve(per_wire.iter().map(|entries| entries.glyphs.len()).sum()); + + for (wire_idx, mut entries) in per_wire.into_iter().enumerate() { + max_line_half_width_px = + max_line_half_width_px.max(entries.max_line_half_width_px); + if let Some(entry) = entries.wire { + wire_entries.push(entry); } - for (index, (point, _)) in wire.snap_pts.iter().enumerate() { - if !point.is_finite() { - continue; - } - snap_point_entries.push(Entry3 { - aabb: [point.x, point.y, point.z, point.x, point.y, point.z], - value: SnapPointRef { - wire: wire_idx, - index: index as u32, - }, - }); - } - for (index, &point) in wire.key_vertices.iter().enumerate() { - if !point.iter().all(|value| value.is_finite()) { - continue; - } - key_vertex_entries.push(Entry3 { - aabb: [point[0], point[1], point[2], point[0], point[1], point[2]], - value: KeyVertexRef { - wire: wire_idx, - index: index as u32, - }, - }); - } - for start in 0..wire.key_vertices.len().saturating_sub(1) { - let Some(aabb) = - points_aabb3([wire.key_vertices[start], wire.key_vertices[start + 1]]) - else { - continue; - }; - key_segment_entries.push(Entry3 { - aabb, - value: KeySegmentRef { - wire: wire_idx, - start: start as u32, - }, - }); - } - append_triangle_entries( - wire_idx, - &wire.fill_tris, - &wire.fill_tris_low, - &mut fill_triangle_entries, - ); - append_triangle_entries( - wire_idx, - &wire.pick_tris, - &wire.pick_tris_low, - &mut pick_triangle_entries, - ); - for start in (0..wire.text_verts.len()).step_by(6) { - let Some(quad) = wire.text_verts.get(start..start + 6) else { - break; - }; - let Some(aabb) = points_aabb3(quad.iter().map(|vertex| { - [ - vertex.pos[0] as f64 + vertex.pos_low[0] as f64, - vertex.pos[1] as f64 + vertex.pos_low[1] as f64, - vertex.pos[2] as f64 + vertex.pos_low[2] as f64, - ] - })) else { - continue; - }; - glyph_entries.push(Entry3 { - aabb, - value: GlyphRef { - wire: wire_idx, - start: start as u32, - }, - }); + if entries.unbounded { + unbounded_wires.push(wire_idx as u32); } + segment_entries.append(&mut entries.segments); + snap_point_entries.append(&mut entries.snap_points); + key_vertex_entries.append(&mut entries.key_vertices); + key_segment_entries.append(&mut entries.key_segments); + fill_triangle_entries.append(&mut entries.fill_triangles); + pick_triangle_entries.append(&mut entries.pick_triangles); + glyph_entries.append(&mut entries.glyphs); } #[cfg(not(target_arch = "wasm32"))] diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 7f94f1323..bd8f44e86 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -149,7 +149,9 @@ pub struct Pipeline { /// ref-counted, so N paper viewports (or Model tiles) drawing one identical /// resident set hold one copy of the GPU vertex buffers between them — /// their camera uniforms + scissor stay per-slot, only the geometry is - /// deduplicated. Never mutated in place, only reassigned on content change. + /// deduplicated. Never mutated in place; an arena-backed slot may also + /// replace this thin draw-range list on camera changes without touching the + /// shared resident buffer. pub(crate) gpu_wires: std::sync::Arc>, /// Persistent per-entity wire instance arena (native, `OCS_WIRE_GPU_PATCH`). /// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an @@ -164,6 +166,10 @@ pub struct Pipeline { /// The Model content id both arenas currently mirror (`u64::MAX` = none). #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_arena_id: u64, + /// Last content/camera/viewport tuple used to derive visible instance + /// ranges from the resident arena. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) wire_cull_key: (u64, u64, u32, u32), /// This content viewport's non-rectangular clip boundary as a triangle-fan /// vertex buffer in the render target's normalized device coords (`None` = /// rectangular / unclipped, where the viewport's own render rectangle does @@ -1450,6 +1456,8 @@ impl Pipeline { wire_arena_mesh: None, #[cfg(not(target_arch = "wasm32"))] wire_arena_id: u64::MAX, + #[cfg(not(target_arch = "wasm32"))] + wire_cull_key: (u64::MAX, u64::MAX, 0, 0), clip_boundary: None, gpu_selected_wires: vec![], gpu_preview_wires: vec![], @@ -2572,7 +2580,10 @@ impl Pipeline { pass.set_bind_group(1, bg.as_ref(), &[]); } pass.set_vertex_buffer(0, edges.instance_buffer.slice(..)); - pass.draw(0..6, 0..edges.instance_count); + pass.draw( + 0..6, + edges.first_instance..edges.first_instance + edges.instance_count, + ); } } } @@ -2638,7 +2649,10 @@ impl Pipeline { pass.set_bind_group(1, bg.as_ref(), &[]); } pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)); - pass.draw(0..6, 0..wire.instance_count); + pass.draw( + 0..6, + wire.first_instance..wire.first_instance + wire.instance_count, + ); } // Live overlay wires (command preview / interim / grip drag) always // on top: the xray pipeline (depth_compare=Always, no depth write) @@ -2653,7 +2667,10 @@ impl Pipeline { pass.set_bind_group(1, bg.as_ref(), &[]); } pass.set_vertex_buffer(0, pw.instance_buffer.slice(..)); - pass.draw(0..6, 0..pw.instance_count); + pass.draw( + 0..6, + pw.first_instance..pw.first_instance + pw.instance_count, + ); } } } @@ -2791,7 +2808,10 @@ impl Pipeline { pass.set_bind_group(1, bg.as_ref(), &[]); } pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)); - pass.draw(0..6, 0..wire.instance_count); + pass.draw( + 0..6, + wire.first_instance..wire.first_instance + wire.instance_count, + ); } } } diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 09502b317..63dd7fa9f 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -62,6 +62,9 @@ struct Slab { inst_len: u32, const_off: u32, const_len: u32, + /// World-XY bounds for plan-view draw-range culling. Unbounded whenever a + /// source wire does not carry a trustworthy entity AABB. + aabb: [f32; 4], /// Entity-level draw depth used when this slab was emitted. Individual /// consts may carry block-local offsets around it; structural edits shift /// the whole slab by the base-depth delta instead of flattening those @@ -182,6 +185,37 @@ fn handle_ranges(wires: &[&WireModel]) -> Option> { Some(out) } +fn run_aabb(wires: &[&WireModel]) -> [f32; 4] { + let mut out = [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ]; + for wire in wires { + let [x0, y0, x1, y1] = wire.aabb; + if !x0.is_finite() + || !y0.is_finite() + || !x1.is_finite() + || !y1.is_finite() + || x0 > x1 + || y0 > y1 + { + return WireModel::UNBOUNDED_AABB; + } + let pad = (wire.world_width * 0.5).max(0.0); + out[0] = out[0].min(x0 - pad); + out[1] = out[1].min(y0 - pad); + out[2] = out[2].max(x1 + pad); + out[3] = out[3].max(y1 + pad); + } + if out[0].is_finite() { + out + } else { + WireModel::UNBOUNDED_AABB + } +} + fn make_const_bg( device: &wgpu::Device, bgl: &wgpu::BindGroupLayout, @@ -197,22 +231,50 @@ fn make_const_bg( })) } -fn alloc_inst(device: &wgpu::Device, cap: u64) -> wgpu::Buffer { - device.create_buffer(&wgpu::BufferDescriptor { +fn alloc_inst_initialized( + device: &wgpu::Device, + cap: u64, + data: &[WireInstance], +) -> wgpu::Buffer { + let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("wire_arena.ibuf"), size: cap * std::mem::size_of::() as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }) + mapped_at_creation: true, + }); + if !data.is_empty() { + let bytes = bytemuck::cast_slice(data); + let mut mapped = buffer + .slice(..bytes.len() as u64) + .get_mapped_range_mut(); + mapped.copy_from_slice(bytes); + drop(mapped); + } + buffer.unmap(); + buffer } -fn alloc_const(device: &wgpu::Device, cap: u64) -> wgpu::Buffer { - device.create_buffer(&wgpu::BufferDescriptor { +fn alloc_const_initialized( + device: &wgpu::Device, + cap: u64, + data: &[WireConst], +) -> wgpu::Buffer { + let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("wire_arena.cbuf"), size: cap * std::mem::size_of::() as u64, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }) + mapped_at_creation: true, + }); + if !data.is_empty() { + let bytes = bytemuck::cast_slice(data); + let mut mapped = buffer + .slice(..bytes.len() as u64) + .get_mapped_range_mut(); + mapped.copy_from_slice(bytes); + drop(mapped); + } + buffer.unmap(); + buffer } fn blank_const() -> WireConst { @@ -230,8 +292,7 @@ fn blank_instance() -> WireInstance { distance_a: 0.0, distance_b: 0.0, wire_id: 0, - world_hw_a: 0.0, - world_hw_b: 0.0, + taper_ratio: [0; 2], } } @@ -241,44 +302,128 @@ impl WireArena { /// path). pub fn build( device: &wgpu::Device, - queue: &wgpu::Queue, + _queue: &wgpu::Queue, wires: &[&WireModel], depth_map: &FxHashMap, const_bgl: &wgpu::BindGroupLayout, mesh_edge: bool, ) -> Option { let ranges = handle_ranges(wires)?; + let perf = std::env::var_os("OCS_PERF").is_some(); + let total_started = std::time::Instant::now(); + + // Reject an oversized batch before parallel emission allocates hundreds + // of megabytes. `points.len() - 1` is an upper bound because NaN-break + // segments are skipped by emit_wire_native. + let max_instances: usize = wires + .iter() + .map(|w| w.points.len().saturating_sub(1)) + .sum(); + if max_instances as u64 > MAX_INSTANCES || wires.len() as u64 + 1 > MAX_CONSTS { + return None; + } + + struct BuildPlan { + handle: Handle, + start: usize, + end: usize, + const_off: u32, + base_depth: f32, + } + struct PackedSlab { + handle: Handle, + const_off: u32, + base_depth: f32, + aabb: [f32; 4], + instances: Vec, + consts: Vec, + } + + // Assign global const slots serially so parallel workers can emit final + // wire_id values directly. Indexed parallel collect preserves handle and + // wire submission order. + let mut next_const = 1u32; + let plans: Vec = ranges + .into_iter() + .map(|(handle, start, end)| { + let const_off = next_const; + next_const += (end - start) as u32; + let base_depth = if mesh_edge { + 0.0 + } else { + depth_map.get(&handle.value()).map_or(0.0, |d| d[0]) + }; + BuildPlan { + handle, + start, + end, + const_off, + base_depth, + } + }) + .collect(); + + let pack_started = std::time::Instant::now(); + use crate::par::prelude::*; + let packed: Vec = plans + .par_iter() + .map(|plan| { + let run = &wires[plan.start..plan.end]; + let capacity: usize = run + .iter() + .map(|w| w.points.len().saturating_sub(1)) + .sum(); + let mut instances: Vec = Vec::with_capacity(capacity); + let mut consts: Vec = Vec::with_capacity(run.len()); + for (local, &w) in run.iter().enumerate() { + let wire_id = plan.const_off + local as u32; + // 3D mesh outline edges are occluded by true depth and must NOT + // take the draw-order z-bias (or hidden back edges peek through + // the shaded fill) — matching WireGpu::from_run. + let dd = if mesh_edge { 0.0 } else { wire_draw_depth(w, depth_map) }; + let (mut emitted, cst) = emit_wire_native(w, wire_id, w.color, dd); + instances.append(&mut emitted); + consts.push(cst); + } + PackedSlab { + handle: plan.handle, + const_off: plan.const_off, + base_depth: plan.base_depth, + aabb: run_aabb(run), + instances, + consts, + } + }) + .collect(); + let pack_ms = pack_started.elapsed().as_secs_f64() * 1000.0; + + let inst_count: usize = packed.iter().map(|slab| slab.instances.len()).sum(); + let const_count: usize = 1 + packed.iter().map(|slab| slab.consts.len()).sum::(); + if inst_count as u64 > MAX_INSTANCES || const_count as u64 > MAX_CONSTS { + return None; + } // const slot 0 = blank tombstone target. - let mut instances: Vec = Vec::new(); - let mut consts_cpu: Vec = vec![blank_const()]; - let mut slabs: FxHashMap = FxHashMap::default(); - for (h, i, j) in ranges { + let mut instances: Vec = Vec::with_capacity(inst_count); + let mut consts_cpu: Vec = Vec::with_capacity(const_count); + consts_cpu.push(blank_const()); + let mut slabs: FxHashMap = + FxHashMap::with_capacity_and_hasher(packed.len(), Default::default()); + for mut packed_slab in packed { let inst_off = instances.len() as u32; - let const_off = consts_cpu.len() as u32; - let base_depth = if mesh_edge { - 0.0 - } else { - depth_map.get(&h.value()).map_or(0.0, |d| d[0]) - }; - for &w in &wires[i..j] { - let wire_id = consts_cpu.len() as u32; - // 3D mesh outline edges are occluded by true depth and must NOT - // take the draw-order z-bias (or hidden back edges peek through - // the shaded fill) — matching WireGpu::from_run. - let dd = if mesh_edge { 0.0 } else { wire_draw_depth(w, depth_map) }; - let (mut insts, cst) = emit_wire_native(w, wire_id, w.color, dd); - instances.append(&mut insts); - consts_cpu.push(cst); - } + let inst_len = packed_slab.instances.len() as u32; + let const_len = packed_slab.consts.len() as u32; + instances.append(&mut packed_slab.instances); + consts_cpu.append(&mut packed_slab.consts); slabs.insert( - h, + packed_slab.handle, Slab { inst_off, - inst_len: instances.len() as u32 - inst_off, - const_off, - const_len: consts_cpu.len() as u32 - const_off, - base_depth, + inst_len, + const_off: packed_slab.const_off, + const_len, + aabb: packed_slab.aabb, + base_depth: packed_slab.base_depth, }, ); } @@ -296,13 +441,24 @@ impl WireArena { let const_cap = ((const_tail as u64 * HEADROOM_NUM / HEADROOM_DEN) .max(MIN_CONST_CAP) .min(MAX_CONSTS)) as u32; - let inst_buf = alloc_inst(device, inst_cap as u64); - let const_buf = alloc_const(device, const_cap as u64); - if inst_tail > 0 { - queue.write_buffer(&inst_buf, 0, bytemuck::cast_slice(&instances)); - } - queue.write_buffer(&const_buf, 0, bytemuck::cast_slice(&consts_cpu)); + let upload_started = std::time::Instant::now(); + let inst_buf = alloc_inst_initialized(device, inst_cap as u64, &instances); + let const_buf = alloc_const_initialized(device, const_cap as u64, &consts_cpu); + let upload_ms = upload_started.elapsed().as_secs_f64() * 1000.0; let const_bind_group = make_const_bg(device, const_bgl, &const_buf); + if perf { + eprintln!( + "[perf] arena-build-detail total={:.1}ms pack={:.1} mapped-upload={:.1} handles={} wires={} instances={} instance-bytes={} consts={}", + total_started.elapsed().as_secs_f64() * 1000.0, + pack_ms, + upload_ms, + slabs.len(), + wires.len(), + inst_tail, + inst_tail as usize * std::mem::size_of::(), + const_tail, + ); + } Some(Self { inst_buf, @@ -381,6 +537,7 @@ impl WireArena { } else { depth_map.get(&h.value()).map_or(0.0, |d| d[0]) }; + let aabb = run_aabb(run); if !self.slabs.contains_key(&h) && self @@ -419,7 +576,9 @@ impl WireArena { const_off as u64 * csz, bytemuck::cast_slice(&csts), ); - self.slabs.get_mut(&h).unwrap().base_depth = base_depth; + let slab = self.slabs.get_mut(&h).unwrap(); + slab.base_depth = base_depth; + slab.aabb = aabb; continue; } @@ -465,6 +624,7 @@ impl WireArena { inst_len, const_off, const_len, + aabb, base_depth, }, ); @@ -509,9 +669,101 @@ impl WireArena { } vec![WireGpu { instance_buffer: self.inst_buf.clone(), + first_instance: 0, instance_count: self.inst_tail, is_3d_mesh_edge: self.mesh_edge, const_bind_group: Some(self.const_bind_group.clone()), }] } + + /// Draw only plan-view entity ranges that can reach the viewport. The + /// instance buffer stays fully resident, so pan/zoom changes only this tiny + /// list of offsets — no repack and no GPU upload. Non-plan views use the + /// conservative full draw because a 2-D entity AABB does not contain Z. + pub fn wire_gpus_visible( + &self, + view_rot: glam::Mat4, + eye: glam::DVec3, + clip_w: u32, + clip_h: u32, + ) -> Vec { + if self.inst_tail == 0 { + return vec![]; + } + let projected_x = view_rot.transform_vector3(glam::Vec3::X); + let projected_y = view_rot.transform_vector3(glam::Vec3::Y); + let projected_z = view_rot.transform_vector3(glam::Vec3::Z); + let xy_scale = projected_x + .truncate() + .length() + .max(projected_y.truncate().length()) + .max(f32::MIN_POSITIVE); + if projected_z.truncate().length() > xy_scale * 1e-5 { + return self.wire_gpus(); + } + + let mut ranges: Vec<(u32, u32)> = self + .slabs + .values() + .filter(|slab| { + slab.inst_len > 0 + && !super::aabb_offscreen(slab.aabb, view_rot, eye, clip_w, clip_h) + }) + .map(|slab| (slab.inst_off, slab.inst_off + slab.inst_len)) + .collect(); + ranges.sort_unstable_by_key(|range| range.0); + let mut merged: Vec<(u32, u32)> = Vec::with_capacity(ranges.len()); + for (start, end) in ranges { + if let Some((_, previous_end)) = merged.last_mut() { + if *previous_end == start { + *previous_end = end; + continue; + } + } + merged.push((start, end)); + } + let mut ranges = merged; + if ranges.is_empty() { + return vec![]; + } + + // Cap CPU draw-call overhead on pathologically interleaved draw order. + // Merging a few separated visible spans draws their offscreen gap too, + // but retains order and still avoids the rest of a multi-million + // instance drawing. + const MAX_RANGES: usize = 64; + if ranges.len() > MAX_RANGES { + let group = (ranges.len() + MAX_RANGES - 1) / MAX_RANGES; + ranges = ranges + .chunks(group) + .map(|chunk| (chunk[0].0, chunk[chunk.len() - 1].1)) + .collect(); + } + + if std::env::var_os("OCS_PERF").is_some() { + let submitted: u64 = ranges + .iter() + .map(|(start, end)| (end - start) as u64) + .sum(); + if submitted < self.inst_tail as u64 { + eprintln!( + "[perf] wire-cull submitted={} resident={} ranges={}", + submitted, + self.inst_tail, + ranges.len(), + ); + } + } + + ranges + .into_iter() + .map(|(start, end)| WireGpu { + instance_buffer: self.inst_buf.clone(), + first_instance: start, + instance_count: end - start, + is_3d_mesh_edge: self.mesh_edge, + const_bind_group: Some(self.const_bind_group.clone()), + }) + .collect() + } } diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 4217ff357..9f17f2513 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -65,9 +65,10 @@ fn instance_buffer_mapped( // even though it's constant along the wire. On native we hoist those into a // per-wire `WireConst` storage buffer indexed by `wire_id`, so the instance // keeps only the per-segment data (endpoints + arc-length distances). Cuts the -// instance from 104 B to 60 B (~42 %) and removes the redundant per-segment -// re-fetch of the shared constants. WebGL2 has no vertex-stage storage buffers, -// so the wasm build below keeps the original self-contained fat instance. +// instance from 104 B to one 64-byte cache line and removes the redundant +// per-segment re-fetch of the shared constants. WebGL2 has no vertex-stage +// storage buffers, so the wasm build below keeps the original self-contained +// fat instance. #[cfg(not(target_arch = "wasm32"))] #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] @@ -80,11 +81,11 @@ pub struct WireInstance { pub distance_b: f32, /// Index into the per-wire `WireConst` storage buffer (group 1). pub wire_id: u32, - /// World-space half-width at each endpoint for a TAPERED band. `0.0` = - /// use the per-wire constant (`WireConst.world_half_width`). The shader - /// interpolates between the two so the band tapers across the segment. - pub world_hw_a: f32, - pub world_hw_b: f32, + /// Endpoint width / the per-wire maximum width, normalized by the vertex + /// fetch unit. `[0, 0]` means use the constant width. Ratios retain the + /// full f32 world-width scale in `WireConst` while making every instance + /// exactly one 64-byte cache line. + pub taper_ratio: [u16; 2], } #[cfg(not(target_arch = "wasm32"))] @@ -99,8 +100,7 @@ impl WireInstance { wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_a) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_b) as u64, shader_location: 5, format: wgpu::VertexFormat::Float32 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, wire_id) as u64, shader_location: 6, format: wgpu::VertexFormat::Uint32 }, - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, world_hw_a) as u64, shader_location: 7, format: wgpu::VertexFormat::Float32 }, - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, world_hw_b) as u64, shader_location: 8, format: wgpu::VertexFormat::Float32 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, taper_ratio) as u64, shader_location: 7, format: wgpu::VertexFormat::Unorm16x2 }, ]; wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, @@ -284,6 +284,8 @@ impl WirePipelineMode { pub struct WireGpu { pub instance_buffer: wgpu::Buffer, + /// First instance in a shared arena buffer. Standalone buffers start at 0. + pub first_instance: u32, pub instance_count: u32, /// `true` when the source `WireModel` also carries `fill_tris` /// (i.e. it is a 3D mesh face — PolyfaceMesh / PolygonMesh — whose @@ -520,9 +522,20 @@ pub(crate) fn emit_wire_native( return (Vec::new(), cst); } let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; - // Per-point half-width for a tapered band (empty ⇒ 0 ⇒ shader uses the - // per-wire constant width). - let tw = |i: usize| -> f32 { wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 }; + // Store an endpoint/max-width ratio. The shared f32 maximum keeps drawing + // units and range out of the packed field; UNORM16 contributes only a + // relative error below 1/65535. Preserve zero as the existing constant + // width fallback sentinel. + let taper_ratio = |i: usize| -> u16 { + let width = wire.taper_widths.get(i).copied().unwrap_or(0.0); + if width <= 0.0 || wire.world_width <= 0.0 { + 0 + } else { + ((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32) + .round() + .max(1.0) as u16 + } + }; let mut instances: Vec = Vec::with_capacity(seg_count); for i in 0..seg_count { let a = wire.points[i]; @@ -538,8 +551,7 @@ pub(crate) fn emit_wire_native( distance_a: dists[i], distance_b: dists[i + 1], wire_id, - world_hw_a: tw(i), - world_hw_b: tw(i + 1), + taper_ratio: [taper_ratio(i), taper_ratio(i + 1)], }); } (instances, cst) @@ -652,6 +664,7 @@ impl WireGpu { let buf = instance_buffer_mapped(device, "wire.run.ibuf", chunk); Self { instance_buffer: buf, + first_instance: 0, instance_count: chunk.len() as u32, is_3d_mesh_edge: mesh_edge, const_bind_group: Some(bg.clone()), @@ -684,6 +697,7 @@ impl WireGpu { let buf = instance_buffer_mapped(device, "wire.run.compat.ibuf", chunk); Self { instance_buffer: buf, + first_instance: 0, instance_count: chunk.len() as u32, is_3d_mesh_edge: mesh_edge, const_bind_group: None, @@ -740,6 +754,7 @@ impl WireGpu { let instance_buffer = instance_buffer_mapped(device, &label, chunk); Self { instance_buffer, + first_instance: 0, instance_count: chunk.len() as u32, is_3d_mesh_edge: false, const_bind_group: Some(bg.clone()), @@ -766,6 +781,7 @@ impl WireGpu { let instance_buffer = instance_buffer_mapped(device, &label, chunk); Self { instance_buffer, + first_instance: 0, instance_count: chunk.len() as u32, is_3d_mesh_edge: false, const_bind_group: None, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 01094e3f5..3abf5215e 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -271,6 +271,10 @@ impl shader::Primitive for Primitive { inner.cached_mesh_source = None; inner.cached_face3d_source = None; inner.cached_face3d_depth_source = None; + #[cfg(not(target_arch = "wasm32"))] + { + inner.wire_cull_key = (u64::MAX, u64::MAX, 0, 0); + } inner.render_sig = u64::MAX; } // The MSAA / depth / resolve textures are always sized to the @@ -709,6 +713,41 @@ impl shader::Primitive for Primitive { inner.compute_hatch_lod(queue, view_rot, eye, clip_size.width, clip_size.height); inner.compute_wipeout_lod(view_rot, eye, clip_size.width, clip_size.height); inner.compute_mesh_lod(view_rot, eye, clip_size.width, clip_size.height); + #[cfg(not(target_arch = "wasm32"))] + { + let cull_key = ( + vp.wire_content_id, + vp.camera_generation, + clip_size.width, + clip_size.height, + ); + if inner.wire_arena_id == vp.wire_content_id + && inner.wire_cull_key != cull_key + { + let mut visible = inner + .wire_arena + .as_ref() + .map(|arena| { + arena.wire_gpus_visible( + view_rot, + eye, + clip_size.width, + clip_size.height, + ) + }) + .unwrap_or_default(); + if let Some(arena) = inner.wire_arena_mesh.as_ref() { + visible.extend(arena.wire_gpus_visible( + view_rot, + eye, + clip_size.width, + clip_size.height, + )); + } + inner.gpu_wires = std::sync::Arc::new(visible); + inner.wire_cull_key = cull_key; + } + } if vp.show_viewcube { inner.viewcube.upload( queue, diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index 92ca87e58..0ce06d60d 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -1,9 +1,9 @@ // Wire shader (native) — same as wire.wgsl, but the per-wire constants // (color / half_width / dash pattern / draw_depth) live in a per-wire storage // buffer indexed by `wire_id` instead of being replicated on every segment -// instance. Cuts the instance from 104 B to 60 B and removes the redundant -// per-segment re-fetch of constants. WebGL2 has no vertex-stage storage -// buffers, so the wasm build uses wire.wgsl (fat instance) instead. +// instance. Cuts the instance from 104 B to one 64-byte cache line and removes +// the redundant per-segment re-fetch of constants. WebGL2 has no vertex-stage +// storage buffers, so the wasm build uses wire.wgsl (fat instance) instead. struct Uniforms { viewport_size: vec2, @@ -44,10 +44,9 @@ struct InstanceIn { @location(4) distance_a: f32, @location(5) distance_b: f32, @location(6) wire_id: u32, - // Per-endpoint world half-width for a tapered band (0 = use the per-wire - // constant `world_half_width`). - @location(7) world_hw_a: f32, - @location(8) world_hw_b: f32, + // Per-endpoint width / per-wire maximum width. Vertex UNORM16 conversion + // expands this to 0..1; zero keeps the constant-width fallback. + @location(7) taper_ratio: vec2, } const DRAW_ORDER_BIAS: f32 = 0.001; @@ -73,8 +72,10 @@ struct VertexOut { // Half-width of one segment end: a tapered band's own end width wins, then a // constant world-unit band, then the screen-pixel lineweight (LWDISPLAY off // collapses to a hairline). -fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 { - if taper > 0.0 { return max(taper / u.world_per_pixel, 0.5); } +fn resolve_hw(taper_ratio: f32, world_hw: f32, px_hw: f32) -> f32 { + if taper_ratio > 0.0 { + return max((taper_ratio * world_hw) / u.world_per_pixel, 0.5); + } if world_hw > 0.0 { return max(world_hw / u.world_per_pixel, 0.5); } return select(0.5, px_hw, u.lwdisplay_enable > 0.5); } @@ -114,12 +115,12 @@ fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 { // by `world_half_width / world_per_pixel` (screen pixels) so the band grows // and shrinks with zoom. A normal wire (world_half_width == 0) uses the // screen-pixel half-width, honouring the LWDISPLAY toggle. - // A tapered band carries a per-endpoint world half-width on the instance: + // A tapered band carries normalized endpoint widths on the instance: // interpolate across the segment so the band narrows/widens smoothly. A // constant band uses the per-wire `world_half_width`. Both clamp to a // half-pixel so a zoomed-out band stays a hairline instead of vanishing. - let hw_a = resolve_hw(in.world_hw_a, c.world_half_width, c.half_width); - let hw_b = resolve_hw(in.world_hw_b, c.world_half_width, c.half_width); + let hw_a = resolve_hw(in.taper_ratio.x, c.world_half_width, c.half_width); + let hw_b = resolve_hw(in.taper_ratio.y, c.world_half_width, c.half_width); let hw = mix(hw_a, hw_b, which_end); // Extend the quad longitudinally by the end half-width and let the