perf: optimize large drawing workflows

This commit is contained in:
Hakan Seven committed 2026-07-25 02:58:46 +03:00
1 parent 1c7e4bb850
commit 71e0b88839
17 files changed
+1204 -430

No files matched your search

+11
View File
@@ -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<PathBuf>,
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(),
+2
View File
@@ -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 {
+37 -2
View File
@@ -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<PendingClose>,
/// 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<u64, u64>,
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<PathBuf>,
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<std::path::PathBuf>),
/// 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,
+76 -123
View File
@@ -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<Message> {
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<Message> {
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()
}
}
+254 -69
View File
@@ -768,6 +768,213 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
}
}
#[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<Message> {
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<Message> {
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<Message> {
if self.read_only {
self.command_line
@@ -775,37 +982,28 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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<Message> {
};
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<Message> {
/// touches the original file or the dirty flag.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_autosave(&mut self) -> Task<Message> {
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")]
+4 -10
View File
@@ -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) => {