From ea93e169ea09c3afd2e49019ebfe5ee467fb47bd Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 24 Jul 2026 23:25:33 +0300 Subject: [PATCH] perf: make edit history incremental Replace full-document snapshots with Arc first-touch deltas and batch cache invalidation. Reuse interaction and GPU category caches across unrelated edits. --- Cargo.lock | 2 +- src/app/command_driver.rs | 65 ++-- src/app/commands/draw.rs | 2 +- src/app/commands/fileops.rs | 10 +- src/app/commands/styleprops.rs | 2 +- src/app/document.rs | 66 ++-- src/app/history.rs | 453 +++++++++++++++++++++------- src/app/mod.rs | 2 +- src/app/properties.rs | 13 +- src/app/update/command.rs | 16 +- src/app/update/mod.rs | 5 +- src/app/update/viewport.rs | 23 +- src/scene/entity.rs | 131 ++++++-- src/scene/mod.rs | 284 ++++++++++++----- src/scene/modify.rs | 39 ++- src/scene/pick/interaction_index.rs | 34 +++ src/scene/pipeline/mod.rs | 22 ++ src/scene/selection.rs | 34 ++- src/scene/view/render.rs | 115 +++++-- 19 files changed, 979 insertions(+), 339 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ffb89db..67d9848b 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#30792198e6faf2875125d5a1c8a189a3fe25a9ca" +source = "git+https://github.com/OpenAEC-Foundation/acadifc?branch=main#3ef1e9c3483c44b93264c93965572c23b8678f17" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index cb7ed97e..1bdc7100 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -354,7 +354,7 @@ impl OpenCADStudio { // body — wire re-tessellation alone leaves the solid drawn at // its old spot. (#135) if self.tabs[i].scene.any_solid(&handles) { - self.tabs[i].scene.populate_meshes_from_document(); + self.tabs[i].scene.refresh_meshes_for_handles(&handles); } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); @@ -374,7 +374,7 @@ impl OpenCADStudio { let pending = self.begin_undo(i, label, handles.len(), delta_safe); let new_handles = self.tabs[i].scene.copy_entities(&handles, &transform); if self.tabs[i].scene.any_solid(&new_handles) { - self.tabs[i].scene.populate_meshes_from_document(); + self.tabs[i].scene.refresh_meshes_for_handles(&new_handles); } self.tabs[i].dirty = true; self.tabs[i].scene.deselect_all(); @@ -522,7 +522,7 @@ impl OpenCADStudio { self.refresh_properties(); } Err(err) => { - let _ = self.tabs[i].history.undo_stack.pop(); + self.discard_last_undo_entry(i); self.command_line.push_error(&err); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { @@ -584,10 +584,7 @@ impl OpenCADStudio { self.tabs[i].scene.document.get_entity(nh), Some(acadrust::EntityType::Dimension(_)) ) { - crate::modules::draw::modify::explode::invalidate_dim_block( - &mut self.tabs[i].scene.document, - nh, - ); + self.tabs[i].scene.invalidate_dim_block_recorded(nh); } } } @@ -707,10 +704,7 @@ impl OpenCADStudio { self.tabs[i].scene.document.get_entity(nh), Some(acadrust::EntityType::Dimension(_)) ) { - crate::modules::draw::modify::explode::invalidate_dim_block( - &mut self.tabs[i].scene.document, - nh, - ); + self.tabs[i].scene.invalidate_dim_block_recorded(nh); } } if let Some(cmd) = &mut self.tabs[i].active_cmd { @@ -978,10 +972,7 @@ impl OpenCADStudio { // A restyled dimension renders from its baked *D block — // drop the stale block so the new style shows (#398). if any_dim { - crate::modules::draw::modify::explode::invalidate_dim_block( - &mut self.tabs[i].scene.document, - *h, - ); + self.tabs[i].scene.invalidate_dim_block_recorded(*h); } // Hatch fills render from a prebuilt model (#415). self.tabs[i].scene.refresh_fill_model(*h); @@ -1364,7 +1355,7 @@ impl OpenCADStudio { self.command_line.push_output("PEDIT: applied."); self.refresh_properties(); } else { - let _ = self.tabs[i].history.undo_stack.pop(); + self.discard_last_undo_entry(i); self.command_line .push_error("PEDIT: operation not applicable to this entity."); } @@ -1554,8 +1545,15 @@ impl OpenCADStudio { win_max, delta, } => { - self.push_undo_snapshot(i, "STRETCH"); + let structural = handles.iter().any(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(acadrust::EntityType::Dimension(_)) + ) + }); + let pending = self.begin_undo(i, "STRETCH", handles.len(), !structural); let mut count = 0usize; + let mut changed_handles = Vec::new(); // Helper: is DXF point (x, y) inside the world-space window? // Drawing plane is world XY (= DXF XY). @@ -1571,6 +1569,7 @@ impl OpenCADStudio { // stale afterwards and must be dropped (see #398 / #372). let mut stretched_dims: Vec = Vec::new(); for handle in &handles { + let before = self.tabs[i].scene.document.get_entity_arc(*handle); let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) else { continue; }; @@ -1746,7 +1745,11 @@ impl OpenCADStudio { } } if stretched { + if let Some(before) = before { + self.tabs[i].scene.record_undo_before(*handle, Some(before)); + } self.tabs[i].scene.mark_entity_dirty(*handle); + changed_handles.push(*handle); count += 1; } } @@ -1754,10 +1757,7 @@ impl OpenCADStudio { // one exists (file roundtrip) — drop it so tessellation falls // back to the live points and the next save re-bakes. (#372) for h in stretched_dims { - crate::modules::draw::modify::explode::invalidate_dim_block( - &mut self.tabs[i].scene.document, - h, - ); + self.tabs[i].scene.invalidate_dim_block_recorded(h); } // Geometry was edited in place via get_entity_mut, which the @@ -1765,7 +1765,15 @@ impl OpenCADStudio { // moved entities so the viewport reflects the stretch right away // instead of only on the next unrelated redraw. See #95. if count > 0 { - self.tabs[i].scene.bump_geometry_no_blocks(); + if structural { + self.tabs[i].scene.bump_geometry(); + } else { + let changes: Vec<_> = changed_handles + .iter() + .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + } } self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; @@ -1775,6 +1783,9 @@ impl OpenCADStudio { self.command_line .push_output(&format!("STRETCH: {count} entity(ies) stretched.")); self.refresh_properties(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } } // ── Solid3D creation (BOX / SPHERE / CYLINDER) ──────────────── CmdResult::CommitSolid3D { mesh_fn } => { @@ -2322,6 +2333,7 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); } CmdResult::DdeditEntity { handle, new_text } => { + self.push_undo_snapshot(i, "DDEDIT"); let mut updated = false; let mut is_dim = false; if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { @@ -2355,16 +2367,13 @@ impl OpenCADStudio { if is_dim { // The edited override changed the dimension text; drop its // stale *D block so save re-bakes it. (#181) - crate::modules::draw::modify::explode::invalidate_dim_block( - &mut self.tabs[i].scene.document, - handle, - ); + self.tabs[i].scene.invalidate_dim_block_recorded(handle); } if updated { - self.push_undo_snapshot(i, "DDEDIT"); self.tabs[i].dirty = true; self.command_line.push_output("DDEDIT: text updated."); } else { + self.discard_last_undo_entry(i); self.command_line .push_error("DDEDIT: entity type not supported."); } @@ -2759,7 +2768,7 @@ fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) { } // The dimension line moved, so its baked *D block is stale — drop it so // the next save re-bakes it (no-op for non-dimensions). (#181) - crate::modules::draw::modify::explode::invalidate_dim_block(&mut scene.document, h); + scene.invalidate_dim_block_recorded(h); } scene.bump_geometry(); } diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 231643da..272e9e80 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -715,7 +715,7 @@ impl OpenCADStudio { // Stash the erased entities so OOPS can restore them. self.oops_cache = handles .iter() - .filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned()) + .filter_map(|h| self.tabs[i].scene.document.get_entity_arc(*h)) .collect(); self.tabs[i].scene.erase_entities(&handles); self.tabs[i].dirty = true; diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 9fad6582..80093ea8 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -78,15 +78,15 @@ impl OpenCADStudio { if self.oops_cache.is_empty() { self.command_line.push_info("OOPS: nothing to restore."); } else { - self.push_undo_snapshot(i, "OOPS"); let restored = std::mem::take(&mut self.oops_cache); + let pending = self.begin_undo(i, "OOPS", restored.len(), true); + let restored = self.tabs[i].scene.restore_erased_entities(restored); let n = restored.len(); - for e in restored { - self.tabs[i].scene.add_entity_clone(e); - } - self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; self.refresh_properties(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } self.command_line .push_output(&format!("OOPS: restored {n} object(s).")); } diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 2f8f365a..7848fa9a 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1785,7 +1785,7 @@ impl OpenCADStudio { self.command_line .push_output(&format!("RENAME: '{}' → '{}'.", old_name, new_name)); } else { - let _ = self.tabs[i].history.undo_stack.pop(); + self.discard_last_undo_entry(i); if !known { self.command_line.push_error(&format!("RENAME: unknown type '{}'. Use LAYER BLOCK STYLE DIMSTYLE LINETYPE UCS VIEW", type_str)); } else if type_str == "BLOCK" { diff --git a/src/app/document.rs b/src/app/document.rs index 7de1e531..791c104c 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -13,6 +13,7 @@ use iced; use std::any::Any; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; // ── Dynamic input ────────────────────────────────────────────────────────── @@ -477,51 +478,60 @@ impl DocumentTab { } } -/// One undo/redo entry. Most edits touch the whole drawing's state and store a -/// [`FullSnapshot`] (a document clone); frequent entity-only edits (move / copy / -/// draw / erase) instead store a cheap [`DeltaSnapshot`] naming just the entities -/// they changed, so an 800k-entity drawing doesn't pay a ~46 ms document clone -/// per edit. +/// One undo/redo entry. Every edit is represented as a first-touch entity delta +/// plus an optional structure-only document image, so history never clones the +/// full entity store. #[derive(Clone)] pub(super) enum HistorySnapshot { - Full(FullSnapshot), Delta(DeltaSnapshot), } impl HistorySnapshot { pub(super) fn label(&self) -> &str { match self { - HistorySnapshot::Full(f) => &f.label, HistorySnapshot::Delta(d) => &d.label, } } + + /// Approximate retained history memory. Entity images are shared through + /// `Arc`; the estimate conservatively budgets their payloads and optional + /// structure state to keep pathological histories bounded. + pub(super) fn estimated_bytes(&self) -> usize { + match self { + HistorySnapshot::Delta(d) => d + .entities + .len() + .saturating_mul(512) + .saturating_add( + d.structure + .as_ref() + .map_or(0, |doc| doc.objects.len().saturating_mul(192)), + ) + .saturating_add(d.selected_before.len().saturating_mul(16)) + .saturating_add(d.selected_after.len().saturating_mul(16)) + .saturating_add(d.label.len()), + } + } } -/// A whole-document undo entry: the safe fallback for any command that may touch -/// layers, objects, block records, styles or tables. -#[derive(Clone)] -pub(super) struct FullSnapshot { - pub(super) document: CadDocument, - pub(super) current_layout: String, - pub(super) selected: Vec, - pub(super) dirty: bool, - pub(super) label: String, -} - -/// An entity-only undo entry: for each touched handle, its before-image and +/// A transactional undo entry: for each touched handle, its before-image and /// after-image (`None` = the entity was absent on that side, i.e. an add or an /// erase). Symmetric — undo applies the before side, redo the after side — so /// one delta moves between the undo and redo stacks without re-capturing. The -/// command by construction changes no layers / objects / blocks, so only the -/// cheap scalars (`current_layout`, selection, `dirty`) ride alongside. +/// Optional structure state covers layers, objects, blocks and tables without +/// cloning the flat entity store. #[derive(Clone)] pub(super) struct DeltaSnapshot { - pub(super) entities: Vec<(Handle, Option, Option)>, - pub(super) current_layout: String, + pub(super) entities: Vec<(Handle, Option>, Option>)>, + pub(super) current_layout_before: String, + pub(super) current_layout_after: String, pub(super) selected_before: Vec, pub(super) selected_after: Vec, pub(super) dirty_before: bool, pub(super) dirty_after: bool, + /// Opposite non-entity document state. `apply_delta_state` swaps this with + /// the live structure, so the same allocation shuttles between undo/redo. + pub(super) structure: Option, pub(super) label: String, } @@ -529,4 +539,14 @@ pub(super) struct DeltaSnapshot { pub(super) struct HistoryState { pub(super) undo_stack: Vec, pub(super) redo_stack: Vec, + pub(super) pending: Option, +} + +pub(super) struct PendingHistorySnapshot { + pub(super) label: String, + pub(super) current_layout: String, + pub(super) selected_before: Vec, + pub(super) dirty_before: bool, + pub(super) structure_before: CadDocument, + pub(super) recorder: Arc, } diff --git a/src/app/history.rs b/src/app/history.rs index 3fe5d2ee..a003f2f8 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1,15 +1,64 @@ use super::{ - document::{DeltaSnapshot, FullSnapshot, HistorySnapshot}, + document::{DeltaSnapshot, HistorySnapshot, PendingHistorySnapshot}, OpenCADStudio, }; use acadrust::{EntityType, Handle}; -use rustc_hash::FxHashSet as HashSet; +use rustc_hash::{FxHashMap, FxHashSet as HashSet}; +use std::sync::Arc; -/// Above this many touched entities a "delta-safe" command falls back to a full -/// snapshot: deep-cloning that many before-images would cost more than the (now -/// Arc-cheap) document clone, and huge bulk edits are rare. Below it, the delta -/// keeps a per-edit undo at roughly zero cost. -const DELTA_UNDO_MAX_ENTITIES: usize = 5000; +const DEFAULT_HISTORY_MAX_ENTRIES: usize = 256; +const DEFAULT_HISTORY_MAX_BYTES: usize = 512 * 1024 * 1024; + +fn history_limits() -> (usize, usize) { + use std::sync::OnceLock; + static LIMITS: OnceLock<(usize, usize)> = OnceLock::new(); + *LIMITS.get_or_init(|| { + let entries = std::env::var("OCS_HISTORY_MAX_ENTRIES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_HISTORY_MAX_ENTRIES); + let mib = std::env::var("OCS_HISTORY_MAX_MB") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_HISTORY_MAX_BYTES / (1024 * 1024)); + ( + if entries == 0 { usize::MAX } else { entries }, + if mib == 0 { + usize::MAX + } else { + mib.saturating_mul(1024 * 1024) + }, + ) + }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn defer_history_drop(entries: Vec) { + if entries.is_empty() { + return; + } + use std::sync::{mpsc, OnceLock}; + static TX: OnceLock>> = OnceLock::new(); + let tx = TX.get_or_init(|| { + let (tx, rx) = mpsc::channel::>(); + let _ = std::thread::Builder::new() + .name("ocs-history-drop".to_string()) + .spawn(move || { + while let Ok(entries) = rx.recv() { + drop(entries); + } + }); + tx + }); + if let Err(err) = tx.send(entries) { + drop(err.0); + } +} + +#[cfg(target_arch = "wasm32")] +fn defer_history_drop(entries: Vec) { + drop(entries); +} /// Pre-command state captured by [`OpenCADStudio::begin_undo`] and handed back to /// [`OpenCADStudio::commit_undo_delta`] to close a delta entry. Lives on the @@ -19,6 +68,7 @@ pub(super) struct PendingDelta { current_layout: String, selected_before: Vec, dirty_before: bool, + structure_before: Option, } impl OpenCADStudio { @@ -30,52 +80,187 @@ impl OpenCADStudio { .unwrap_or_else(|| fallback.to_string()) } - pub(super) fn capture_history_snapshot( - &self, + fn clear_redo_history(&mut self, i: usize) { + let discarded = std::mem::take(&mut self.tabs[i].history.redo_stack); + defer_history_drop(discarded); + } + + fn trim_history(&mut self, i: usize) { + let (max_entries, max_bytes) = history_limits(); + let history = &mut self.tabs[i].history; + let mut entries = history + .undo_stack + .len() + .saturating_add(history.redo_stack.len()); + let mut bytes = history + .undo_stack + .iter() + .chain(history.redo_stack.iter()) + .fold(0usize, |sum, item| { + sum.saturating_add(item.estimated_bytes()) + }); + let mut discarded = Vec::new(); + while entries > max_entries || bytes > max_bytes { + let item = if !history.undo_stack.is_empty() { + history.undo_stack.remove(0) + } else if !history.redo_stack.is_empty() { + history.redo_stack.remove(0) + } else { + break; + }; + entries -= 1; + bytes = bytes.saturating_sub(item.estimated_bytes()); + discarded.push(item); + } + defer_history_drop(discarded); + } + + fn push_undo_entry(&mut self, i: usize, snapshot: HistorySnapshot) { + self.tabs[i].history.undo_stack.push(snapshot); + self.clear_redo_history(i); + self.trim_history(i); + } + + pub(super) fn push_single_entity_history( + &mut self, i: usize, label: impl Into, - ) -> HistorySnapshot { - HistorySnapshot::Full(FullSnapshot { - document: self.tabs[i].scene.document.clone(), - current_layout: self.tabs[i].scene.current_layout.clone(), - selected: self.tabs[i].scene.selected.iter().copied().collect(), - dirty: self.tabs[i].dirty, + handle: Handle, + before: Arc, + ) { + self.finish_pending_history(i); + let Some(after) = self.tabs[i].scene.document.get_entity_arc(handle) else { + return; + }; + let selected: Vec = self.tabs[i].scene.selected.iter().copied().collect(); + let dirty_before = self.tabs[i].dirty; + let delta = DeltaSnapshot { + entities: vec![(handle, Some(before), Some(after))], + current_layout_before: self.tabs[i].scene.current_layout.clone(), + current_layout_after: self.tabs[i].scene.current_layout.clone(), + selected_before: selected.clone(), + selected_after: selected, + dirty_before, + dirty_after: true, + structure: None, label: label.into(), - }) + }; + self.push_undo_entry(i, HistorySnapshot::Delta(delta)); + } + + pub(super) fn discard_last_undo_entry(&mut self, i: usize) { + if self.tabs[i].history.pending.take().is_some() { + self.tabs[i].scene.document.end_entity_change_recording(); + return; + } + if let Some(discarded) = self.tabs[i].history.undo_stack.pop() { + defer_history_drop(vec![discarded]); + } + } + + pub(super) fn finish_pending_history(&mut self, i: usize) { + let Some(mut pending) = self.tabs[i].history.pending.take() else { + return; + }; + self.tabs[i].scene.document.end_entity_change_recording(); + let structure_after = self.tabs[i].scene.document.snapshot_structure(); + let before_images = pending.recorder.take_before_images(); + let added_handles: Vec = before_images + .iter() + .filter_map(|(handle, before)| { + (before.is_none() && self.tabs[i].scene.document.get_entity(*handle).is_some()) + .then_some(*handle) + }) + .collect(); + acadrust::CadDocument::align_added_entity_structure( + &mut pending.structure_before, + &structure_after, + &added_handles, + ); + let structure_changed = pending.structure_before != structure_after; + let entities: Vec<_> = before_images + .into_iter() + .map(|(handle, before)| { + let after = self.tabs[i].scene.document.get_entity_arc(handle); + (handle, before, after) + }) + .filter(|(_, before, after)| match (before, after) { + (Some(before), Some(after)) => !Arc::ptr_eq(before, after), + (None, None) => false, + _ => true, + }) + .collect(); + let selected_after: Vec = self.tabs[i].scene.selected.iter().copied().collect(); + let dirty_after = self.tabs[i].dirty; + if entities.is_empty() + && !structure_changed + && pending.selected_before == selected_after + && pending.dirty_before == dirty_after + { + return; + } + let delta = DeltaSnapshot { + entities, + current_layout_before: pending.current_layout, + current_layout_after: self.tabs[i].scene.current_layout.clone(), + selected_before: pending.selected_before, + selected_after, + dirty_before: pending.dirty_before, + dirty_after, + structure: structure_changed.then_some(pending.structure_before), + label: pending.label, + }; + self.push_undo_entry(i, HistorySnapshot::Delta(delta)); + } + + pub(super) fn finish_all_pending_history(&mut self) { + for i in 0..self.tabs.len() { + self.finish_pending_history(i); + } } pub(super) fn push_undo_snapshot(&mut self, i: usize, label: impl Into) { - let snapshot = self.capture_history_snapshot(i, label); - self.tabs[i].history.undo_stack.push(snapshot); - self.tabs[i].history.redo_stack.clear(); + self.finish_pending_history(i); + let label = label.into(); + let current_layout = self.tabs[i].scene.current_layout.clone(); + let selected_before = self.tabs[i].scene.selected.iter().copied().collect(); + let dirty_before = self.tabs[i].dirty; + let structure_before = self.tabs[i].scene.document.snapshot_structure(); + let recorder = self.tabs[i].scene.document.begin_entity_change_recording(); + self.tabs[i].history.pending = Some(PendingHistorySnapshot { + label, + current_layout, + selected_before, + dirty_before, + structure_before, + recorder, + }); } /// Begin undo capture for an entity edit that will touch `touched` entities. - /// When `delta_safe` (the caller's per-command predicate guarantees the edit - /// mutates only entities, through the five Scene primitives) and the edit is - /// small enough, starts a cheap Scene delta recording and returns the - /// pre-command state to pass to [`OpenCADStudio::commit_undo_delta`] after the - /// mutation. Otherwise pushes a full snapshot and returns `None`. + /// Starts a cheap Scene delta recording and returns the pre-command state to + /// pass to [`OpenCADStudio::commit_undo_delta`] after the mutation. A command + /// that may touch layers/objects/block records also retains a structure-only + /// document image; the O(N) entity store is excluded. pub(super) fn begin_undo( &mut self, i: usize, label: impl Into, - touched: usize, + _touched: usize, delta_safe: bool, ) -> Option { + self.finish_pending_history(i); let label = label.into(); - if delta_safe && touched <= DELTA_UNDO_MAX_ENTITIES { - self.tabs[i].scene.begin_undo_recording(); - Some(PendingDelta { - label, - current_layout: self.tabs[i].scene.current_layout.clone(), - selected_before: self.tabs[i].scene.selected.iter().copied().collect(), - dirty_before: self.tabs[i].dirty, - }) - } else { - self.push_undo_snapshot(i, label); - None - } + let structure_before = + (!delta_safe).then(|| self.tabs[i].scene.document.snapshot_structure()); + self.tabs[i].scene.begin_undo_recording(); + Some(PendingDelta { + label, + current_layout: self.tabs[i].scene.current_layout.clone(), + selected_before: self.tabs[i].scene.selected.iter().copied().collect(), + dirty_before: self.tabs[i].dirty, + structure_before, + }) } /// Copy is delta-safe only when no target is a Dimension and no complete @@ -85,12 +270,13 @@ impl OpenCADStudio { pub(super) fn delta_copy_safe(&self, i: usize, handles: &[Handle]) -> bool { use acadrust::objects::ObjectType; let doc = &self.tabs[i].scene.document; + let handle_set: HashSet = handles.iter().copied().collect(); let copies_dimension = handles .iter() .any(|h| matches!(doc.get_entity(*h), Some(EntityType::Dimension(_)))); let copies_complete_group = doc.objects.values().any(|o| match o { ObjectType::Group(g) => { - !g.entities.is_empty() && g.entities.iter().all(|h| handles.contains(h)) + !g.entities.is_empty() && g.entities.iter().all(|h| handle_set.contains(h)) } _ => false, }); @@ -102,8 +288,9 @@ impl OpenCADStudio { pub(super) fn delta_erase_safe(&self, i: usize, handles: &[Handle]) -> bool { use acadrust::objects::ObjectType; let doc = &self.tabs[i].scene.document; + let handle_set: HashSet = handles.iter().copied().collect(); !doc.objects.values().any(|o| match o { - ObjectType::Group(g) => g.entities.iter().any(|h| handles.contains(h)), + ObjectType::Group(g) => g.entities.iter().any(|h| handle_set.contains(h)), _ => false, }) } @@ -145,45 +332,61 @@ impl OpenCADStudio { // entity part stays correct; a leaked layer/group/block just won't // revert). Loud in debug, a warning in release — never a silent wrong. debug_assert!( - !rec.is_poisoned(), - "delta command '{}' mutated non-entity state", + !rec.is_poisoned() || pending.structure_before.is_some(), + "delta command '{}' mutated unrecorded non-entity state", pending.label ); - if rec.is_empty() { + if rec.is_empty() && pending.structure_before.is_none() { // Nothing actually changed (e.g. every target was on a locked // layer). Leave the undo/redo stacks untouched. return; } - if rec.is_poisoned() { + if rec.is_poisoned() && pending.structure_before.is_none() { eprintln!( "[undo] delta '{}' touched non-entity state; undo may be incomplete", pending.label ); } - let entities: Vec<(Handle, Option, Option)> = rec + let entities: Vec<(Handle, Option>, Option>)> = rec .into_before_images() .into_iter() .map(|(h, before)| { - let after = self.tabs[i].scene.document.get_entity(h).cloned(); + let after = self.tabs[i].scene.document.get_entity_arc(h); (h, before, after) }) .collect(); let selected_after = self.tabs[i].scene.selected.iter().copied().collect(); let dirty_after = self.tabs[i].dirty; + let mut structure = pending.structure_before; + if let Some(before_structure) = structure.as_mut() { + let after_structure = self.tabs[i].scene.document.snapshot_structure(); + let added_handles: Vec = entities + .iter() + .filter_map(|(handle, before, after)| { + (before.is_none() && after.is_some()).then_some(*handle) + }) + .collect(); + acadrust::CadDocument::align_added_entity_structure( + before_structure, + &after_structure, + &added_handles, + ); + if *before_structure == after_structure { + structure = None; + } + } let delta = DeltaSnapshot { entities, - current_layout: pending.current_layout, + current_layout_before: pending.current_layout, + current_layout_after: self.tabs[i].scene.current_layout.clone(), selected_before: pending.selected_before, selected_after, dirty_before: pending.dirty_before, dirty_after, + structure, label: pending.label, }; - self.tabs[i] - .history - .undo_stack - .push(HistorySnapshot::Delta(delta)); - self.tabs[i].history.redo_stack.clear(); + self.push_undo_entry(i, HistorySnapshot::Delta(delta)); } /// Apply one side of a delta entry in place: `undo` restores each entity's @@ -191,13 +394,21 @@ impl OpenCADStudio { /// means the entity is absent there (erase it). Reports the exact touched /// handles through `bump_entities` so the incremental caches patch per-handle /// — no full re-tessellation, no `populate_*` document walk. - fn apply_delta(&mut self, i: usize, d: &DeltaSnapshot, undo: bool) { - // Install the chosen side of every entity image (Scene handles the - // in-place / re-insert / remove, the derived-cache reseed and the - // block-record dedup); we report the touched handles incrementally. + fn apply_delta_state( + &mut self, + i: usize, + d: &mut DeltaSnapshot, + undo: bool, + ) -> Vec<(Handle, crate::scene::ChangeKind)> { + // Install the chosen side of every entity image. Derived-cache, geometry + // and UI invalidation are deferred until every requested undo/redo step + // has been applied. + if let Some(structure) = d.structure.take() { + let inverse = self.tabs[i].scene.document.swap_structure(structure); + d.structure = Some(inverse); + } let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo); let scene = &mut self.tabs[i].scene; - scene.bump_entities(&changes); let (sel, dirty) = if undo { (&d.selected_before, d.dirty_before) } else { @@ -209,49 +420,77 @@ impl OpenCADStudio { .filter(|h| scene.document.get_entity(*h).is_some()) .collect(); scene.selected = restored; - // Delta commands never change the layout; this is a no-op unless a - // future caller widens the delta scope, in which case it stays correct. - scene.set_current_layout(d.current_layout.clone()); - scene.clear_preview_wire(); + // Do not call set_current_layout here: it bumps geometry immediately. + // Entity deltas currently preserve the layout; direct assignment also + // keeps future widened deltas batchable. + scene.current_layout = if undo { + d.current_layout_before.clone() + } else { + d.current_layout_after.clone() + }; self.tabs[i].dirty = dirty; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].active_grip = None; - self.refresh_properties(); + changes } - pub(super) fn restore_history_snapshot(&mut self, i: usize, snapshot: FullSnapshot) { - self.tabs[i].scene.document = snapshot.document; - self.tabs[i] - .scene - .set_current_layout(snapshot.current_layout); - // Force a re-tessellation: the cached wires were keyed against the - // outgoing document / layout and would be returned unchanged - // otherwise (`set_current_layout` only bumps on actual change). - self.tabs[i].scene.bump_geometry(); - self.tabs[i].scene.selected = snapshot - .selected - .into_iter() - .filter(|h| self.tabs[i].scene.document.get_entity(*h).is_some()) - .collect::>(); - self.tabs[i].scene.populate_hatches_from_document(); - self.tabs[i].scene.populate_images_from_document(); - self.tabs[i].scene.populate_meshes_from_document(); - // Keep the Isolate/Hide set in step with the restored per-entity - // visibility so End Isolation stays correct after undo/redo. - self.tabs[i].scene.sync_hidden_from_invisible(); - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].scene.images.clear(); + /// Perform the one expensive cache/UI synchronization required after a + /// batch of history steps. Intermediate states are never rendered, so + /// rebuilding them only multiplies latency. + fn finish_history_apply( + &mut self, + i: usize, + had_full: bool, + changes: &[(Handle, crate::scene::ChangeKind)], + ) { + { + let scene = &mut self.tabs[i].scene; + if had_full { + // One document walk per derived category and one geometry bump + // for the final state. Unlike the old path, decoded images stay + // cached instead of being immediately cleared. + scene.rebuild_derived_caches(); + scene.sync_hidden_from_invisible(); + } else if !changes.is_empty() { + use crate::scene::ChangeKind::{Added, Modified, Removed}; + let mut net: FxHashMap = FxHashMap::default(); + for &(handle, kind) in changes { + match (net.get(&handle).copied(), kind) { + (None, kind) => { + net.insert(handle, kind); + } + (Some(Added), Removed) => { + net.remove(&handle); + } + (Some(Added), _) => {} + (Some(Removed), Added) => { + net.insert(handle, Modified); + } + (Some(Modified), Removed) => { + net.insert(handle, Removed); + } + (Some(Removed), _) | (Some(Modified), _) => {} + } + } + let final_changes: Vec<_> = net.into_iter().collect(); + for &(handle, _) in &final_changes { + scene.reseed_derived_caches(handle); + } + if !final_changes.is_empty() { + scene.bump_entities(&final_changes); + } + } + scene.clear_preview_wire(); + } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].active_grip = None; - self.tabs[i].dirty = snapshot.dirty; - let doc_layers = self.tabs[i].scene.document.layers.clone(); - let vp_info = self.tabs[i].scene.viewport_list(); - self.tabs[i] - .layers - .sync_with_viewports(&doc_layers, vp_info); - self.sync_ribbon_layers(); + if had_full { + let doc_layers = self.tabs[i].scene.document.layers.clone(); + let vp_info = self.tabs[i].scene.viewport_list(); + self.tabs[i] + .layers + .sync_with_viewports(&doc_layers, vp_info); + self.sync_ribbon_layers(); + } self.refresh_properties(); } @@ -265,6 +504,7 @@ impl OpenCADStudio { pub(super) fn undo_steps(&mut self, steps: usize) { let i = self.active_tab; + self.finish_pending_history(i); let available = self.tabs[i].history.undo_stack.len(); let steps = steps.min(available); if steps == 0 { @@ -273,22 +513,20 @@ impl OpenCADStudio { } let mut last_label = String::new(); + let mut had_full = false; + let mut changes = Vec::new(); for _ in 0..steps { let Some(snapshot) = self.tabs[i].history.undo_stack.pop() else { break; }; last_label = snapshot.label().to_string(); match snapshot { - HistorySnapshot::Full(f) => { - let current = self.capture_history_snapshot(i, f.label.clone()); - self.tabs[i].history.redo_stack.push(current); - self.restore_history_snapshot(i, f); - } - HistorySnapshot::Delta(d) => { + HistorySnapshot::Delta(mut d) => { // Symmetric: undo applies the before side, then the same // delta rides to the redo stack (it still holds the after // side) — no current-state capture needed. - self.apply_delta(i, &d, true); + had_full |= d.structure.is_some(); + changes.extend(self.apply_delta_state(i, &mut d, true)); self.tabs[i] .history .redo_stack @@ -296,12 +534,14 @@ impl OpenCADStudio { } } } + self.finish_history_apply(i, had_full, &changes); self.command_line .push_output(&format!("Undo: {last_label}")); } pub(super) fn redo_steps(&mut self, steps: usize) { let i = self.active_tab; + self.finish_pending_history(i); let available = self.tabs[i].history.redo_stack.len(); let steps = steps.min(available); if steps == 0 { @@ -310,19 +550,17 @@ impl OpenCADStudio { } let mut last_label = String::new(); + let mut had_full = false; + let mut changes = Vec::new(); for _ in 0..steps { let Some(snapshot) = self.tabs[i].history.redo_stack.pop() else { break; }; last_label = snapshot.label().to_string(); match snapshot { - HistorySnapshot::Full(f) => { - let current = self.capture_history_snapshot(i, f.label.clone()); - self.tabs[i].history.undo_stack.push(current); - self.restore_history_snapshot(i, f); - } - HistorySnapshot::Delta(d) => { - self.apply_delta(i, &d, false); + HistorySnapshot::Delta(mut d) => { + had_full |= d.structure.is_some(); + changes.extend(self.apply_delta_state(i, &mut d, false)); self.tabs[i] .history .undo_stack @@ -330,6 +568,7 @@ impl OpenCADStudio { } } } + self.finish_history_apply(i, had_full, &changes); self.command_line .push_output(&format!("Redo: {last_label}")); } diff --git a/src/app/mod.rs b/src/app/mod.rs index b56087a4..16b44d6c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -541,7 +541,7 @@ pub(super) struct OpenCADStudio { /// In-memory clipboard: cloned entities waiting to be pasted. clipboard: Vec, /// Entities removed by the most recent ERASE, kept so OOPS can restore them. - oops_cache: Vec, + oops_cache: Vec>, /// Paste anchor: lower-left corner of the clipboard entities' bounding box /// (or the point picked by COPYBASE). This point lands under the cursor at /// paste time. diff --git a/src/app/properties.rs b/src/app/properties.rs index 6884f78d..1f7fe3ec 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1125,7 +1125,6 @@ impl OpenCADStudio { pub(super) fn invalidate_property_targets(&mut self, i: usize, handles: &[Handle]) { for &handle in handles { - self.tabs[i].scene.mark_entity_dirty(handle); // Hatch / SOLID fills render from prebuilt cached models; rebuild // them or pattern edits (scale, background, …) stay invisible // (#415). @@ -1134,8 +1133,12 @@ impl OpenCADStudio { // Solid (ACIS) meshes bake their colour into the mesh, so a colour / // layer change needs an explicit recolour — re-tessellating wires // alone wouldn't update them. - self.tabs[i].scene.recolor_meshes(); - self.tabs[i].scene.bump_geometry_no_blocks(); + self.tabs[i].scene.recolor_meshes_for_handles(handles); + let changes: Vec<_> = handles + .iter() + .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); } /// Add an entity to the correct space (model or paper space layout). @@ -1355,6 +1358,10 @@ impl OpenCADStudio { .add_entity_to_layout(entity, &layout) { Ok(new_handle) => { + if self.tabs[i].scene.is_recording_undo() { + self.tabs[i].scene.record_undo_before(new_handle, None); + self.tabs[i].scene.poison_undo_recording(); + } self.tabs[i].scene.auto_fit_viewport(new_handle); // Adding a viewport straight onto the document layout // bypasses Scene::add_entity, which is what normally diff --git a/src/app/update/command.rs b/src/app/update/command.rs index e73e7310..034f8358 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -168,10 +168,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // A typed grip-menu value reshapes dimensions too — drop a // stale baked *D block (no-op for non-dims). (#398) - crate::modules::draw::modify::explode::invalidate_dim_block( - &mut self.tabs[i].scene.document, - pending.handle, - ); + self.tabs[i] + .scene + .invalidate_dim_block_recorded(pending.handle); self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; self.refresh_selected_grips(); @@ -987,10 +986,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // Menu actions reshape dimensions too — drop a stale baked *D // block so the edit is visible (no-op for non-dims). (#398) - crate::modules::draw::modify::explode::invalidate_dim_block( - &mut self.tabs[i].scene.document, - popup.handle, - ); + self.tabs[i] + .scene + .invalidate_dim_block_recorded(popup.handle); self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; self.refresh_selected_grips(); @@ -1918,7 +1916,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].dirty = true; } else { // Nothing changed — drop the snapshot pushed a moment ago. - let _ = self.tabs[i].history.undo_stack.pop(); + self.discard_last_undo_entry(i); } self.refresh_properties(); Task::none() diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index f413be24..8134a491 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -134,6 +134,9 @@ impl OpenCADStudio { } } let task = self.update_inner(msg); + // Close the document-level first-touch transaction started by + // push_undo_snapshot at this message boundary. + self.finish_all_pending_history(); // After every message, mirror the active command step's prompt so // its history line stays pinned (non-fading) until the step changes. let prompt = self.tabs[self.active_tab] @@ -2228,7 +2231,7 @@ impl OpenCADStudio { // Stash the erased entities so OOPS can restore them. self.oops_cache = handles .iter() - .filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned()) + .filter_map(|h| self.tabs[i].scene.document.get_entity_arc(*h)) .collect(); self.tabs[i].scene.erase_entities(&handles); self.tabs[i].dirty = true; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index bc9f4b4d..23b3a0ff 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1932,29 +1932,20 @@ impl OpenCADStudio { // once, dropping the overlay preview. if let Some(h) = self.grip_preview_handle.take() { // Undo entry for the drag (#332): the pre-drag backup - // swaps in, the PRE state is snapshotted, then the - // dragged result swaps back. Without this a grip edit - // (e.g. reshaping a polyline vertex) was un-undoable — - // the drag mutated the document without any history. + // and live dragged image form a one-entity Arc delta. The old + // swap/full-document snapshot made a vertex drag O(document). if let Some(orig) = self.grip_original.take() { - if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) { - let dragged = std::mem::replace(e, orig); - let snap = self.capture_history_snapshot(i, "GRIP"); - if let Some(e2) = self.tabs[i].scene.document.get_entity_mut(h) { - *e2 = dragged; - } - self.tabs[i].history.undo_stack.push(snap); - self.tabs[i].history.redo_stack.clear(); - self.tabs[i].dirty = true; - } + self.push_single_entity_history(i, "GRIP", h, std::sync::Arc::new(orig)); + self.tabs[i].dirty = true; } self.grip_text_verts = Vec::new(); self.grip_text_slide = false; self.tabs[i].scene.hidden.remove(&h); self.tabs[i].scene.clear_preview_wire(); // Only the dragged entity changed — re-tessellate just it. - self.tabs[i].scene.mark_entity_dirty(h); - self.tabs[i].scene.bump_geometry_no_blocks(); + self.tabs[i] + .scene + .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); } // Placement confirmed — keep the just-added leader. self.grip_add_provisional = None; diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 84e673b8..ddae1090 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -335,7 +335,7 @@ impl Scene { // overwritten) so an undo can restore it, and poison if this replace // also created a layer or crossed a block boundary. if self.is_recording_undo() { - let before = self.document.get_entity(handle).cloned(); + let before = self.document.get_entity_arc(handle); self.record_undo_before(handle, before); if creates_layer || affects_blocks { self.poison_undo_recording(); @@ -382,8 +382,8 @@ impl Scene { /// [`Scene::update_entity`]; used by delta-undo when it re-applies an /// entity's before / after image so the fills and meshes follow. pub(crate) fn reseed_derived_caches(&mut self, handle: Handle) { - let (hatch_seed, image_seed, mesh_seed) = match self.document.get_entity(handle) { - None => (None, None, None), + let (hatch_seed, image_seed) = match self.document.get_entity(handle) { + None => (None, None), Some(entity) => { let hatch_seed = if let EntityType::Hatch(dxf) = entity { let color = self.render_style(entity).0; @@ -395,22 +395,7 @@ impl Scene { None }; let image_seed = self.image_seed_for(entity); - let facet_res = self.document.header.facet_resolution; - let isolines = self.document.header.isolines.max(0) as usize; - let mesh_seed = if matches!( - entity, - EntityType::Solid3D(_) - | EntityType::Region(_) - | EntityType::Body(_) - | EntityType::Surface(_) - ) { - let color = self.render_style(entity).0; - crate::entities::solid3d::tessellate_volume(entity, color, facet_res, isolines) - .map(offset_mesh_lod_set) - } else { - None - }; - (hatch_seed, image_seed, mesh_seed) + (hatch_seed, image_seed) } }; self.hatches.remove(&handle); @@ -423,8 +408,78 @@ impl Scene { if let Some(model) = image_seed { self.images.insert(handle, model); } - if let Some(model) = mesh_seed { - self.meshes.insert(handle, model); + self.refresh_meshes_for_handles(&[handle]); + } + + /// Re-tessellate only the named ACIS entities. The former edit path + /// cleared and rebuilt every solid in the drawing when one selected solid + /// moved or was copied. + pub fn refresh_meshes_for_handles(&mut self, handles: &[Handle]) { + if handles.is_empty() { + return; + } + let layout_blocks: std::collections::HashSet = self + .document + .objects + .values() + .filter_map(|o| match o { + acadrust::objects::ObjectType::Layout(l) if !l.block_record.is_null() => { + Some(l.block_record) + } + _ => None, + }) + .collect(); + let entries: Vec<(Handle, std::sync::Arc, [f32; 4], bool)> = handles + .iter() + .filter_map(|&handle| { + let entity = self.document.get_entity_arc(handle)?; + if !matches!( + entity.as_ref(), + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Body(_) + | EntityType::Surface(_) + ) { + return None; + } + let color = self.render_style(entity.as_ref()).0; + let top_level = layout_blocks.contains(&entity.common().owner_handle); + Some((handle, entity, color, top_level)) + }) + .collect(); + for handle in handles { + self.meshes.remove(handle); + self.block_meshes.remove(handle); + self.solid_models.remove(handle); + } + let facet_res = self.document.header.facet_resolution; + let isolines = self.document.header.isolines.max(0) as usize; + use crate::par::prelude::*; + let built: Vec<(Handle, MeshLodSet, bool)> = entries + .into_par_iter() + .filter_map(|(handle, entity, color, top_level)| { + crate::entities::solid3d::tessellate_volume( + entity.as_ref(), + color, + facet_res, + isolines, + ) + .map(|mesh| { + let mesh = if top_level { + offset_mesh_lod_set(mesh) + } else { + mesh + }; + (handle, mesh, top_level) + }) + }) + .collect(); + for (handle, mesh, top_level) in built { + if top_level { + self.meshes.insert(handle, mesh); + } else { + self.block_meshes.insert(handle, mesh); + } } } @@ -1834,6 +1889,11 @@ impl Scene { /// Decode and cache all RasterImage entities from the current document. /// Silently skips images whose files cannot be read. pub fn populate_images_from_document(&mut self) { + self.populate_images_from_document_unbumped(); + self.bump_geometry(); + } + + fn populate_images_from_document_unbumped(&mut self) { self.images.clear(); let entries: Vec<(Handle, acadrust::entities::RasterImage)> = self .document @@ -1851,7 +1911,6 @@ impl Scene { self.images.insert(handle, model); } } - self.bump_geometry(); } /// Rebuild the cached fill model (hatch / DXF SOLID) for `handle` after @@ -1876,6 +1935,11 @@ impl Scene { } pub fn populate_hatches_from_document(&mut self) { + self.populate_hatches_from_document_unbumped(); + self.bump_geometry(); + } + + fn populate_hatches_from_document_unbumped(&mut self) { self.hatches.clear(); let entries: Vec<(Handle, EntityType)> = self @@ -1907,8 +1971,6 @@ impl Scene { model.map(|m| (handle, m)) }) .collect(); - - self.bump_geometry(); } /// Tessellate all `Solid3D` entities in the current document into @@ -1932,7 +1994,7 @@ impl Scene { } pub fn populate_meshes_from_document(&mut self) { - self.populate_meshes_impl(false); + self.populate_meshes_impl(false, true); } /// Like [`populate_meshes_from_document`] but tessellates only solids @@ -1946,10 +2008,10 @@ impl Scene { /// only the newly merged xref solids" — the dominant cost when a drawing /// attaches several large xrefs. (#203) pub fn populate_missing_meshes_from_document(&mut self) { - self.populate_meshes_impl(true); + self.populate_meshes_impl(true, true); } - fn populate_meshes_impl(&mut self, incremental: bool) { + fn populate_meshes_impl(&mut self, incremental: bool, bump: bool) { if !incremental { self.meshes.clear(); self.block_meshes.clear(); @@ -2016,15 +2078,18 @@ impl Scene { } } - self.bump_geometry(); + if bump { + self.bump_geometry(); + } } /// Rebuild hatch / image / mesh caches after the document is modified /// outside the normal `add_entity` path (e.g. REFCLOSE SAVE). pub fn rebuild_derived_caches(&mut self) { - self.populate_hatches_from_document(); - self.populate_images_from_document(); - self.populate_meshes_from_document(); + self.populate_hatches_from_document_unbumped(); + self.populate_images_from_document_unbumped(); + self.populate_meshes_impl(false, false); + self.bump_geometry(); } /// Build a solid-fill HatchModel for a DXF Solid entity. @@ -2218,13 +2283,17 @@ impl Scene { } pub fn clear(&mut self) { + self.document.record_all_entities_for_transaction(); self.document = CadDocument::new(); self.selected = HashSet::default(); self.preview_wires = vec![]; self.preview_text = vec![]; self.current_layout = "Model".to_string(); self.hatches = HashMap::default(); + self.images = HashMap::default(); self.meshes = HashMap::default(); + self.block_meshes = HashMap::default(); + self.solid_models = HashMap::default(); *self.camera.borrow_mut() = Camera::default(); self.camera_generation += 1; self.bump_geometry(); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 5fbd0521..0ba751db 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -177,7 +177,7 @@ pub struct UndoRecording { /// so repeated touches within one command keep the true pre-command state). /// A value of `None` marks an entity *added* by the command (no prior /// state). `order` preserves first-touch order for a deterministic delta. - before: HashMap>, + before: HashMap>>, order: Vec, poisoned: bool, } @@ -197,7 +197,7 @@ impl UndoRecording { /// order. A `None` before-image means the entity was added by the command. /// The app pairs each with the entity's current (after) state to build the /// invertible delta entry. - pub fn into_before_images(mut self) -> Vec<(Handle, Option)> { + pub fn into_before_images(mut self) -> Vec<(Handle, Option>)> { self.order .drain(..) .map(|h| (h, self.before.remove(&h).flatten())) @@ -983,6 +983,16 @@ pub struct Scene { Arc, )>, >, + /// Large resident interaction index kept as an immutable base across small + /// entity edits. Geometry-journal handles form a tombstone/delta overlay; + /// the exact index is rebuilt only after the overlay grows too large. + interaction_base_index_cache: RefCell< + Option<( + u64, + u64, + Arc, + )>, + >, /// Auxiliary broad phase for objects that have no wire representation: /// hatch-only and solid-only block instances. interaction_handle_index_cache: RefCell< @@ -1286,6 +1296,7 @@ impl Scene { selection_generation: 0, wire_cache: RefCell::new(None), interaction_index_cache: RefCell::new(Vec::new()), + interaction_base_index_cache: RefCell::new(None), interaction_handle_index_cache: RefCell::new(None), interaction_aux_work_cache: std::cell::Cell::new(None), sort_cache: RefCell::new(None), @@ -1585,7 +1596,7 @@ impl Scene { /// is `None` for a freshly added entity. No-op when not recording — the /// callers guard with [`Scene::is_recording_undo`] so the clone is skipped /// entirely on the common (no-recording) path. - pub(crate) fn record_undo_before(&mut self, handle: Handle, before: Option) { + pub(crate) fn record_undo_before(&mut self, handle: Handle, before: Option>) { if let Some(rec) = self.undo_recording.as_mut() { if !rec.before.contains_key(&handle) { rec.order.push(handle); @@ -1606,35 +1617,14 @@ impl Scene { /// Re-apply one side of an entity delta to the document. For each /// `(handle, before, after)`, install the chosen `target` — `before` when /// `undo`, `after` on redo: overwrite the entity in place, re-insert it with - /// its original handle, or remove it — reseeding the per-handle derived - /// caches so fills/meshes follow. Returns the exact per-handle change list - /// for the caller to report via [`Scene::bump_entities`]; it does not bump, - /// touch the selection, or rebuild any whole-document cache. - /// - /// `remove_entity` leaves a handle dangling in its owner block record's - /// `entity_handles` (harmless — a render/save lookup miss is skipped), so a - /// re-insert's `add_entity` push would make it appear twice and emit the - /// entity twice on save. Every to-be-re-inserted handle is therefore - /// stripped from the block records in one pass first, leaving exactly one - /// entry after the re-insert. + /// its original handle, or remove it. Returns the exact per-handle change + /// list; derived-cache reseeding and the geometry bump are deferred so a + /// multi-step undo/redo can process each final handle only once. pub(crate) fn apply_entity_delta( &mut self, - entities: &[(Handle, Option, Option)], + entities: &[(Handle, Option>, Option>)], undo: bool, ) -> Vec<(Handle, ChangeKind)> { - let reinsert: HashSet = entities - .iter() - .filter_map(|(h, before, after)| { - let target = if undo { before } else { after }; - (target.is_some() && self.document.get_entity(*h).is_none()).then_some(*h) - }) - .collect(); - if !reinsert.is_empty() { - for br in self.document.block_records.iter_mut() { - br.entity_handles.retain(|h| !reinsert.contains(h)); - } - } - let mut changes: Vec<(Handle, ChangeKind)> = Vec::with_capacity(entities.len()); for (h, before, after) in entities { let target = if undo { before } else { after }; @@ -1642,52 +1632,25 @@ impl Scene { match target { Some(ent) => { if existed { - if let Some(slot) = self.document.get_entity_mut(*h) { - *slot = ent.clone(); - } + let _ = self.document.replace_entity_arc(*h, Arc::clone(ent)); changes.push((*h, ChangeKind::Modified)); } else { - // Re-insert with the original handle: the stored image - // keeps it, and document.add_entity honours a preset, - // non-null handle (routing to its owner block record). - let _ = self.document.add_entity(ent.clone()); + // Removal keeps the original block membership in place; + // restoring only the flat storage avoids an O(all block + // members) scan and cannot duplicate the owner link. + let _ = self.document.restore_entity_arc(Arc::clone(ent)); changes.push((*h, ChangeKind::Added)); } - self.reseed_derived_caches(*h); } None => { if existed { - self.document.remove_entity(*h); - // Drops the now-absent entity's hatch/image/mesh caches. - self.reseed_derived_caches(*h); + self.document.remove_entity_arc(*h); changes.push((*h, ChangeKind::Removed)); } } } } - // Integrity net: no re-inserted handle may appear more than once across - // the block records (a duplicate would emit the entity twice on save). - // Debug-only, and skipped entirely when nothing was re-inserted (the - // common transform/property-edit case). - #[cfg(debug_assertions)] - if !reinsert.is_empty() { - let mut counts: HashMap = HashMap::default(); - for br in self.document.block_records.iter() { - for h in &br.entity_handles { - if reinsert.contains(h) { - *counts.entry(*h).or_default() += 1; - } - } - } - for (h, c) in counts { - debug_assert!( - c <= 1, - "delta re-insert duplicated handle {h:?} in block records ({c}×)" - ); - } - } - changes } @@ -1853,6 +1816,41 @@ impl Scene { } } + /// Recolour only the named cached solids after a property edit. + pub fn recolor_meshes_for_handles(&mut self, handles: &[Handle]) { + let bg = self.bg_color; + let colors: HashMap = handles + .iter() + .filter_map(|&handle| { + self.document.get_entity(handle).map(|entity| { + let mut color = self.render_style(entity).0; + if self + .refedit_keep + .as_ref() + .is_some_and(|keep| !keep.contains(&handle)) + { + color = crate::scene::cache::block_cache::fade_toward_bg(color, bg); + } + (handle, color) + }) + }) + .collect(); + for handle in handles { + let Some(color) = colors.get(handle) else { + continue; + }; + if let Some(set) = self.meshes.get_mut(handle) { + for lod in &mut set.lods { + lod.color = *color; + } + } else if let Some(set) = self.block_meshes.get_mut(handle) { + for lod in &mut set.lods { + lod.color = *color; + } + } + } + } + /// Enter / leave the REFEDIT fade. `keep` holds the edited entities (left /// bright); everything else renders faded. Re-tessellates wires and /// recolours solids so the change shows immediately. (#136) @@ -4572,6 +4570,13 @@ impl Scene { let entry = cache.remove(position); let index = Arc::clone(&entry.3); cache.push(entry); + if self.current_layout == "Model" { + *self.interaction_base_index_cache.borrow_mut() = Some(( + self.geometry_epoch, + self.interaction_space_key(), + Arc::clone(&index), + )); + } return index; } } @@ -4586,9 +4591,118 @@ impl Scene { if cache.len() > MAX_CACHED_SOURCES { cache.remove(0); } + if self.current_layout == "Model" { + *self.interaction_base_index_cache.borrow_mut() = Some(( + self.geometry_epoch, + self.interaction_space_key(), + Arc::clone(&index), + )); + } index } + const INTERACTION_OVERLAY_MAX_HANDLES: usize = 2_048; + + fn interaction_overlay_base( + &self, + ) -> Option<( + Arc, + Vec<(Handle, ChangeKind)>, + )> { + let space_key = self.interaction_space_key(); + let (epoch, index) = { + let cache = self.interaction_base_index_cache.borrow(); + let (epoch, cached_space, index) = cache.as_ref()?; + if *epoch == self.geometry_epoch || *cached_space != space_key { + return None; + } + (*epoch, Arc::clone(index)) + }; + let changes = self.replay_since(epoch)?; + (changes.len() <= Self::INTERACTION_OVERLAY_MAX_HANDLES).then_some((index, changes)) + } + + fn interaction_overlay_wires( + &self, + base_handles: impl IntoIterator, + changes: &[(Handle, ChangeKind)], + ) -> Arc> { + let changed: HashSet = changes.iter().map(|(handle, _)| *handle).collect(); + let mut handles: HashSet = base_handles + .into_iter() + .map(Handle::new) + .filter(|handle| !changed.contains(handle)) + .collect(); + handles.extend(changes.iter().filter_map(|(handle, kind)| { + (!matches!(kind, ChangeKind::Removed)).then_some(*handle) + })); + + let memo = self.resident_tess_memo.borrow(); + let mut wires = Vec::new(); + let mut misses = Vec::new(); + for handle in handles { + if self.document.get_entity(handle).is_none() { + continue; + } + if let Some(entity_wires) = memo.get(&handle) { + wires.extend(entity_wires.iter().cloned()); + } else { + misses.push(handle); + } + } + drop(memo); + if !misses.is_empty() { + wires.extend(self.wire_models_for(&misses)); + } + Arc::new(wires) + } + + fn indexed_interaction_candidates_xy( + &self, + wires: Arc>, + aabb: [f64; 4], + ) -> crate::scene::pick::interaction_index::InteractionCandidates { + if let Some((base, changes)) = self.interaction_overlay_base() { + let handles = base.query_wire_handles_xy(aabb); + let local = self.interaction_overlay_wires(handles, &changes); + let local_index = + crate::scene::pick::interaction_index::InteractionIndex::build(&local); + return local_index.query_xy(local, aabb); + } + self.interaction_index(&wires).query_xy(wires, aabb) + } + + fn indexed_interaction_candidates_screen( + &self, + wires: Arc>, + screen_rect: [f32; 4], + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + ) -> crate::scene::pick::interaction_index::InteractionCandidates { + if let Some((base, changes)) = self.interaction_overlay_base() { + let handles = base.query_wire_handles_screen(screen_rect, view_rot, eye, bounds); + let local = self.interaction_overlay_wires(handles, &changes); + let local_index = + crate::scene::pick::interaction_index::InteractionIndex::build(&local); + return local_index.query_screen(local, screen_rect, view_rot, eye, bounds); + } + self.interaction_index(&wires) + .query_screen(wires, screen_rect, view_rot, eye, bounds) + } + + fn indexed_interaction_pick_radius( + &self, + wires: &Arc>, + base_radius_px: f32, + ) -> f32 { + if let Some((base, _)) = self.interaction_overlay_base() { + base.pick_radius_px(base_radius_px) + } else { + self.interaction_index(wires).pick_radius_px(base_radius_px) + } + } + fn interaction_source_is_resident( &self, wires: &Arc>, @@ -4611,11 +4725,33 @@ impl Scene { ) -> Arc { let space_key = self.interaction_space_key(); { - let cache = self.interaction_handle_index_cache.borrow(); - if let Some((epoch, cached_space, index)) = cache.as_ref() { - if *epoch == self.geometry_epoch && *cached_space == space_key { - return Arc::clone(index); + let reuse = { + let cache = self.interaction_handle_index_cache.borrow(); + match cache.as_ref() { + Some((epoch, cached_space, index)) + if *cached_space == space_key + && self.category_cache_valid(*epoch, |handle| { + self.hatches.contains_key(&handle) + || self.meshes.contains_key(&handle) + || self.block_meshes.contains_key(&handle) + || matches!( + self.document.get_entity(handle), + Some(EntityType::Insert(_)) + ) + }) => + { + Some(Arc::clone(index)) + } + _ => None, } + }; + if let Some(index) = reuse { + if let Some((epoch, _, _)) = + self.interaction_handle_index_cache.borrow_mut().as_mut() + { + *epoch = self.geometry_epoch; + } + return index; } } let mut entries: Vec<(u64, [f64; 6])> = Vec::new(); @@ -4708,9 +4844,8 @@ impl Scene { { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); } - let index = self.interaction_index(&wires); let radius_px = if include_line_weight { - index.pick_radius_px(radius_px) + self.indexed_interaction_pick_radius(&wires, radius_px) } else { radius_px }; @@ -4728,7 +4863,7 @@ impl Scene { (1.0 - ndc.y) * 0.5 * bounds.height, ); let radius = radius_px.max(0.0); - return index.query_screen( + return self.indexed_interaction_candidates_screen( wires, [ screen.x - radius, @@ -4758,7 +4893,7 @@ impl Scene { cursor.x + radius, cursor.y + radius, ]; - index.query_xy(wires, query) + self.indexed_interaction_candidates_xy(wires, query) } /// Shared rectangular broad phase for box/lasso/fence and command windows. @@ -4782,10 +4917,9 @@ impl Scene { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); } if flat_ortho { - self.interaction_index(&wires).query_xy(wires, aabb) + self.indexed_interaction_candidates_xy(wires, aabb) } else { - self.interaction_index(&wires) - .query_screen(wires, screen_rect, view_rot, eye, bounds) + self.indexed_interaction_candidates_screen(wires, screen_rect, view_rot, eye, bounds) } } @@ -4820,7 +4954,7 @@ impl Scene { pub fn interaction_handles_in_world_aabb(&self, aabb: [f64; 4]) -> HashSet { let wires = self.hit_test_wires(); - let candidates = self.interaction_index(&wires).query_xy(wires, aabb); + let candidates = self.indexed_interaction_candidates_xy(wires, aabb); let mut handles: HashSet = candidates .iter() .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) @@ -6179,11 +6313,11 @@ mod delta_undo_tests { fn build_delta( scene: &Scene, rec: UndoRecording, - ) -> Vec<(Handle, Option, Option)> { + ) -> Vec<(Handle, Option>, Option>)> { rec.into_before_images() .into_iter() .map(|(h, before)| { - let after = scene.document.get_entity(h).cloned(); + let after = scene.document.get_entity_arc(h); (h, before, after) }) .collect() diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 98a51fbc..b53b4ead 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -77,6 +77,39 @@ fn mirror_true_text_flags(e: &mut EntityType) { } impl Scene { + /// Invalidate a dimension's baked block while capturing every removed + /// sub-entity for an active history transaction. + pub fn invalidate_dim_block_recorded(&mut self, handle: Handle) { + if self.is_recording_undo() { + let owned: Vec = self + .document + .get_entity(handle) + .and_then(|entity| match entity { + EntityType::Dimension(d) => { + let name = d.base().block_name.clone(); + self.document.block_records.get(&name).map(|record| { + let mut handles = record.entity_handles.clone(); + handles.push(record.block_entity_handle); + handles.push(record.block_end_handle); + handles + }) + } + _ => None, + }) + .unwrap_or_default(); + if let Some(before) = self.document.get_entity_arc(handle) { + self.record_undo_before(handle, Some(before)); + } + for owned_handle in owned { + if let Some(before) = self.document.get_entity_arc(owned_handle) { + self.record_undo_before(owned_handle, Some(before)); + } + } + self.poison_undo_recording(); + } + crate::modules::draw::modify::explode::invalidate_dim_block(&mut self.document, handle); + } + // ── Modify (transform / copy) ───────────────────────────────────────── pub fn transform_entities(&mut self, handles: &[Handle], t: &EntityTransform) { @@ -137,7 +170,7 @@ impl Scene { for &h in handles { // Delta-undo: capture the pre-transform image before mutating. if self.is_recording_undo() { - let before = self.document.get_entity(h).cloned(); + let before = self.document.get_entity_arc(h); self.record_undo_before(h, before); } if let Some(entity) = self.document.get_entity_mut(h) { @@ -172,7 +205,7 @@ impl Scene { // must capture their before-images here or a dimension move won't undo. for h in &dim_block_subs { if self.is_recording_undo() { - let before = self.document.get_entity(*h).cloned(); + let before = self.document.get_entity_arc(*h); self.record_undo_before(*h, before); } if let Some(entity) = self.document.get_entity_mut(*h) { @@ -409,7 +442,7 @@ impl Scene { // the grip only moved a definition point, so the baked graphics are // stale — drop them so tessellation falls back to the live geometry // and the next save re-bakes (no-op for non-dimensions). (#398) - crate::modules::draw::modify::explode::invalidate_dim_block(&mut self.document, handle); + self.invalidate_dim_block_recorded(handle); // Translate MeshModel vertices by the same delta the grip applied. if let Some(old) = old_por { diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 6a3cf44b..afae31f1 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -455,6 +455,10 @@ impl SpatialSet { pub struct InteractionIndex { wires: SpatialSet, + /// Stable entity handle for each source wire. Lets a stale-but-valid base + /// index feed an incremental overlay after small edits without retaining + /// the old heavyweight wire set or trusting shifted vector indices. + wire_handles: Vec>, segments: SpatialSet, snap_points: SpatialSet, key_vertices: SpatialSet, @@ -518,6 +522,10 @@ impl InteractionIndex { } pub fn build(wires: &[WireModel]) -> Self { + let wire_handles: Vec> = wires + .iter() + .map(|wire| wire.name.parse::().ok()) + .collect(); let mut wire_entries = Vec::with_capacity(wires.len()); let mut segment_entries = Vec::new(); let mut snap_point_entries = Vec::new(); @@ -692,6 +700,7 @@ impl InteractionIndex { Self { wires, + wire_handles, segments, snap_points, key_vertices, @@ -708,6 +717,31 @@ impl InteractionIndex { base_radius_px.max(self.max_line_half_width_px) } + fn queried_wire_handles(&self, mut indices: Vec) -> Vec { + indices.extend_from_slice(&self.unbounded_wires); + let mut handles: Vec = indices + .into_iter() + .filter_map(|index| self.wire_handles.get(index as usize).copied().flatten()) + .collect(); + handles.sort_unstable(); + handles.dedup(); + handles + } + + pub fn query_wire_handles_xy(&self, aabb: [f64; 4]) -> Vec { + self.queried_wire_handles(self.wires.query_xy(aabb)) + } + + pub fn query_wire_handles_screen( + &self, + screen_rect: [f32; 4], + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, + ) -> Vec { + self.queried_wire_handles(self.wires.query_screen(screen_rect, view_rot, eye, bounds)) + } + pub fn query_xy(&self, wires: Arc>, aabb: [f64; 4]) -> InteractionCandidates { let mut wire_indices = self.wires.query_xy(aabb); wire_indices.extend_from_slice(&self.unbounded_wires); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index de0f342f..15ef14d8 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -217,6 +217,19 @@ pub struct Pipeline { gpu_face3d_fill: Option, gpu_face3d_edges: Vec, pub viewcube: ViewCubePipeline, + /// Strong source guards for category-specific GPU uploads. Holding the old + /// Arc makes pointer identity ABA-safe: an unchanged category reuses the + /// same Arc even when an unrelated entity advances `geometry_epoch`. + pub cached_hatch_source: Option>>, + pub cached_wipeout_source: Option>>, + pub cached_image_source: Option>>, + pub cached_text_source: Option>>, + pub cached_mesh_source: Option>>, + pub cached_face3d_source: Option>>, + pub cached_face3d_wire_source: Option>>, + pub cached_face3d_depth_source: + Option>>, + pub cached_fill_mode: bool, /// Last `(geometry_epoch, camera_generation)` value for which GPU buffers /// were uploaded. We re-upload when either side changes — pan/zoom bumps /// camera_generation, which triggers re-culling and a fresh upload. @@ -1458,6 +1471,15 @@ impl Pipeline { gpu_face3d_fill: None, gpu_face3d_edges: vec![], viewcube, + cached_hatch_source: None, + cached_wipeout_source: None, + cached_image_source: None, + cached_text_source: None, + cached_mesh_source: None, + cached_face3d_source: None, + cached_face3d_wire_source: None, + cached_face3d_depth_source: None, + cached_fill_mode: false, cached_epoch: (u64::MAX, u64::MAX, u64::MAX), cached_wire_id: u64::MAX, cached_selection: (u64::MAX, u64::MAX), diff --git a/src/scene/selection.rs b/src/scene/selection.rs index d9bb9fd3..acadea77 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -330,6 +330,7 @@ impl Scene { // ── Erase ───────────────────────────────────────────────────────────── pub fn erase_entities(&mut self, handles: &[Handle]) { + let handle_set: HashSet = handles.iter().copied().collect(); let mut erased: Vec<(Handle, ChangeKind)> = Vec::new(); for &h in handles { // Objects on a locked layer can't be erased. @@ -338,13 +339,15 @@ impl Scene { } // Delta-undo: capture the removed entity so an undo can re-insert it. if self.is_recording_undo() { - let before = self.document.get_entity(h).cloned(); + let before = self.document.get_entity_arc(h); self.record_undo_before(h, before); } - self.document.remove_entity(h); + self.document.remove_entity_arc(h); self.selected.remove(&h); self.hatches.remove(&h); + self.images.remove(&h); self.meshes.remove(&h); + self.block_meshes.remove(&h); self.solid_models.remove(&h); erased.push((h, ChangeKind::Removed)); } @@ -358,7 +361,7 @@ impl Scene { .filter_map(|obj| match obj { ObjectType::Group(g) => { let before = g.entities.len(); - g.entities.retain(|h| !handles.contains(h)); + g.entities.retain(|h| !handle_set.contains(h)); if g.entities.len() != before { groups_changed = true; } @@ -390,4 +393,29 @@ impl Scene { // from the tessellation memos too). self.bump_entities(&erased); } + + /// Restore erased Arc-backed entities without re-linking their still-present + /// block-record handles. Used by OOPS and history replay. + pub fn restore_erased_entities(&mut self, entities: Vec>) -> Vec { + let mut restored = Vec::with_capacity(entities.len()); + let mut changes = Vec::with_capacity(entities.len()); + for entity in entities { + let handle = entity.common().handle; + if handle.is_null() || self.document.get_entity(handle).is_some() { + continue; + } + if self.is_recording_undo() { + self.record_undo_before(handle, None); + } + if self.document.restore_entity_arc(entity).is_some() { + self.reseed_derived_caches(handle); + restored.push(handle); + changes.push((handle, ChangeKind::Added)); + } + } + if !changes.is_empty() { + self.bump_entities(&changes); + } + restored + } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 9a75f046..d3c54760 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -262,6 +262,14 @@ impl shader::Primitive for Primitive { inner.cached_selection = (u64::MAX, u64::MAX); inner.cached_mesh_key = (u64::MAX, u64::MAX); inner.cached_face3d_key = (u64::MAX, false); + inner.cached_hatch_source = None; + inner.cached_wipeout_source = None; + inner.cached_image_source = None; + inner.cached_text_source = None; + inner.cached_mesh_source = None; + inner.cached_face3d_source = None; + inner.cached_face3d_wire_source = None; + inner.cached_face3d_depth_source = None; inner.render_sig = u64::MAX; } // The MSAA / depth / resolve textures are always sized to the @@ -328,35 +336,70 @@ impl shader::Primitive for Primitive { // the view toggle so 2D fills stay on even when the user picks // the Wireframe overlay style. let face3d_fill_active = fill_mode && !vp.view_wireframe; - if cur_key != inner.cached_epoch { - // Hatches carry a selected-tint, so re-upload on a geometry OR - // a selection change (issue #71); images / meshes only need a - // geometry change. - let geo_changed = vp.geometry_epoch != inner.cached_epoch.0; - let sel_changed = vp.selected_sig != inner.cached_epoch.2; - if geo_changed || sel_changed { - if fill_mode { - inner.upload_hatches(device, queue, &vp.hatches[..]); - inner.upload_wipeouts(device, &vp.wipeout_hatches[..]); - } else { - inner.upload_hatches(device, queue, &[]); - inner.upload_wipeouts(device, &[]); - } - } - if geo_changed { - inner.upload_images(device, queue, &vp.images[..]); - inner.upload_text(device, queue, &vp.text_verts[..]); - } - inner.cached_epoch = cur_key; + let fill_changed = inner.cached_fill_mode != fill_mode; + let hatch_changed = inner + .cached_hatch_source + .as_ref() + .map_or(true, |source| !Arc::ptr_eq(source, &vp.hatches)); + let wipeout_changed = inner + .cached_wipeout_source + .as_ref() + .map_or(true, |source| !Arc::ptr_eq(source, &vp.wipeout_hatches)); + if hatch_changed || fill_changed { + inner.upload_hatches( + device, + queue, + if fill_mode { &vp.hatches[..] } else { &[] }, + ); + inner.cached_hatch_source = Some(Arc::clone(&vp.hatches)); } + if wipeout_changed || fill_changed { + inner.upload_wipeouts( + device, + if fill_mode { + &vp.wipeout_hatches[..] + } else { + &[] + }, + ); + inner.cached_wipeout_source = Some(Arc::clone(&vp.wipeout_hatches)); + } + if inner + .cached_image_source + .as_ref() + .map_or(true, |source| !Arc::ptr_eq(source, &vp.images)) + { + inner.upload_images(device, queue, &vp.images[..]); + inner.cached_image_source = Some(Arc::clone(&vp.images)); + } + if inner + .cached_text_source + .as_ref() + .map_or(true, |source| !Arc::ptr_eq(source, &vp.text_verts)) + { + inner.upload_text(device, queue, &vp.text_verts[..]); + inner.cached_text_source = Some(Arc::clone(&vp.text_verts)); + } + inner.cached_fill_mode = fill_mode; + inner.cached_epoch = cur_key; // Face3D edge/fill buffers are world-space and selection-independent // (upload_face3d takes no selection input), so they only change with // the geometry or the 3D-fill toggle — never on a pan/orbit. Gating - // here on `(geometry_epoch, face3d_fill_active)` instead of inside the - // `cur_key` block (which carries `camera_generation`) stops a camera - // move from re-walking every wire to rebuild the Face3D fill buffer. - let face3d_key = (vp.geometry_epoch, face3d_fill_active); - if face3d_key != inner.cached_face3d_key { + // on its three source Arcs avoids rebuilding it when another entity + // category alone advances `geometry_epoch`. + let face3d_changed = inner + .cached_face3d_source + .as_ref() + .map_or(true, |source| !Arc::ptr_eq(source, &vp.face3d_wires)) + || inner + .cached_face3d_wire_source + .as_ref() + .map_or(true, |source| !Arc::ptr_eq(source, &vp.wires)) + || inner + .cached_face3d_depth_source + .as_ref() + .map_or(true, |source| !Arc::ptr_eq(source, &vp.draw_depths)); + if face3d_changed || face3d_fill_active != inner.cached_face3d_key.1 { inner.upload_face3d( device, &vp.face3d_wires[..], @@ -364,7 +407,10 @@ impl shader::Primitive for Primitive { !face3d_fill_active, &vp.draw_depths, ); - inner.cached_face3d_key = face3d_key; + inner.cached_face3d_source = Some(Arc::clone(&vp.face3d_wires)); + inner.cached_face3d_wire_source = Some(Arc::clone(&vp.wires)); + inner.cached_face3d_depth_source = Some(Arc::clone(&vp.draw_depths)); + inner.cached_face3d_key = (vp.geometry_epoch, face3d_fill_active); } // Wire buffers are world-space, so a camera move alone doesn't // change them — only the view_proj uniform (uploaded every frame). @@ -531,17 +577,24 @@ impl shader::Primitive for Primitive { ); inner.cached_selection = sel_key; } - // Batched solid meshes — geometry-only, so they ride the geometry - // epoch alone and stay resident across camera moves and selection / - // hover changes (no per-pick rebuild of the whole solid set). - if vp.geometry_epoch != inner.cached_mesh_batch_epoch { + // Batched solid meshes stay resident while unrelated entity + // categories change. + if inner + .cached_mesh_source + .as_ref() + .map_or(true, |source| !Arc::ptr_eq(source, &vp.meshes)) + { inner.upload_mesh_batch(device, &vp.meshes[..]); + inner.cached_mesh_source = Some(Arc::clone(&vp.meshes)); inner.cached_mesh_batch_epoch = vp.geometry_epoch; } // Selection / hover highlight overlay — tinted copies of just the // picked solids, rebuilt only when the highlight set (or geometry) // changes. Drawn over the static batch so the base never re-packs. - let hl_key = (vp.geometry_epoch, vp.selection_generation); + let hl_key = ( + Arc::as_ptr(&vp.meshes) as usize as u64, + vp.selection_generation, + ); if hl_key != inner.cached_highlight_key { inner.upload_mesh_highlight( device,