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.
This commit is contained in:
Hakan Seven committed 2026-07-24 23:25:33 +03:00
1 parent 90714d34b2
commit ea93e169ea
19 files changed
+979 -339

No files matched your search

+37 -28
View File
@@ -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<acadrust::Handle> = 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();
}
+1 -1
View File
@@ -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;
+5 -5
View File
@@ -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)."));
}
+1 -1
View File
@@ -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" {
+43 -23
View File
@@ -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<Handle>,
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<EntityType>, Option<EntityType>)>,
pub(super) current_layout: String,
pub(super) entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)>,
pub(super) current_layout_before: String,
pub(super) current_layout_after: String,
pub(super) selected_before: Vec<Handle>,
pub(super) selected_after: Vec<Handle>,
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<CadDocument>,
pub(super) label: String,
}
@@ -529,4 +539,14 @@ pub(super) struct DeltaSnapshot {
pub(super) struct HistoryState {
pub(super) undo_stack: Vec<HistorySnapshot>,
pub(super) redo_stack: Vec<HistorySnapshot>,
pub(super) pending: Option<PendingHistorySnapshot>,
}
pub(super) struct PendingHistorySnapshot {
pub(super) label: String,
pub(super) current_layout: String,
pub(super) selected_before: Vec<Handle>,
pub(super) dirty_before: bool,
pub(super) structure_before: CadDocument,
pub(super) recorder: Arc<acadrust::document::EntityChangeRecorder>,
}
+346 -107
View File
@@ -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::<usize>().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<HistorySnapshot>) {
if entries.is_empty() {
return;
}
use std::sync::{mpsc, OnceLock};
static TX: OnceLock<mpsc::Sender<Vec<HistorySnapshot>>> = OnceLock::new();
let tx = TX.get_or_init(|| {
let (tx, rx) = mpsc::channel::<Vec<HistorySnapshot>>();
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<HistorySnapshot>) {
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<Handle>,
dirty_before: bool,
structure_before: Option<acadrust::CadDocument>,
}
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<String>,
) -> 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<EntityType>,
) {
self.finish_pending_history(i);
let Some(after) = self.tabs[i].scene.document.get_entity_arc(handle) else {
return;
};
let selected: Vec<Handle> = 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<Handle> = 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<Handle> = 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<String>) {
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<String>,
touched: usize,
_touched: usize,
delta_safe: bool,
) -> Option<PendingDelta> {
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<Handle> = 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<Handle> = 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<EntityType>, Option<EntityType>)> = rec
let entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)> = 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<Handle> = 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::<HashSet<_>>();
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<Handle, crate::scene::ChangeKind> = 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}"));
}
+1 -1
View File
@@ -541,7 +541,7 @@ pub(super) struct OpenCADStudio {
/// In-memory clipboard: cloned entities waiting to be pasted.
clipboard: Vec<acadrust::EntityType>,
/// Entities removed by the most recent ERASE, kept so OOPS can restore them.
oops_cache: Vec<acadrust::EntityType>,
oops_cache: Vec<Arc<acadrust::EntityType>>,
/// 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.
+10 -3
View File
@@ -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
+7 -9
View File
@@ -168,10 +168,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
// 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<Message> {
}
// 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<Message> {
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()
+4 -1
View File
@@ -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;
+7 -16
View File
@@ -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;