perf(undo): record entity deltas instead of cloning the whole document

Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY,
draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on
every edit — the last big per-edit cost after the incremental-render work.

They now record only the touched entities' before/after images:

- Scene self-records through the five mutation primitives when a recording is
  open (UndoRecording + is_recording_undo guard, zero cost off-path).
- apply_entity_delta installs one side per handle (in-place / re-insert with
  the original handle / remove) plus a per-handle cache reseed, then a single
  bump_entities so the incremental caches patch instead of a full re-tess —
  undo/redo of a small edit is fast too, not just the edit.
- HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves
  between the undo/redo stacks without re-capturing. Commands that touch
  non-entity state (new layer, group, *D block, viewport) fall back to the
  full snapshot via per-command predicates, and a poison flag debug-asserts
  any mis-scope.

remove_entity leaves the handle dangling in its owner block record's
entity_handles (harmless on its own, a lookup miss is skipped), so a
re-insert strips stale entries first — otherwise add_entity would list the
handle twice and the DXF/DWG writer would emit the entity twice.

Round-trip tests cover transform/erase/add/copy (do->undo->redo equality,
handle preservation, no duplication).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-15 11:11:17 +03:00
1 parent aea1fd2caa
commit 1490fe4ea9
9 files changed
+761 -26

No files matched your search

+15
View File
@@ -335,6 +335,11 @@ impl Scene {
if self.is_layer_locked(h) {
continue;
}
// 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();
self.record_undo_before(h, before);
}
self.document.remove_entity(h);
self.selected.remove(&h);
self.hatches.remove(&h);
@@ -344,13 +349,18 @@ impl Scene {
}
// Remove erased handles from all groups; delete groups that become empty.
let group_dict_handle = self.document.header.acad_group_dict_handle;
let mut groups_changed = false;
let to_remove: Vec<Handle> = self
.document
.objects
.values_mut()
.filter_map(|obj| match obj {
ObjectType::Group(g) => {
let before = g.entities.len();
g.entities.retain(|h| !handles.contains(h));
if g.entities.len() != before {
groups_changed = true;
}
if g.entities.is_empty() {
Some(g.handle)
} else {
@@ -360,6 +370,11 @@ impl Scene {
_ => None,
})
.collect();
// Delta-undo: editing a group (or deleting an emptied one) mutates
// document.objects — non-entity state a pure-entity delta can't restore.
if groups_changed {
self.poison_undo_recording();
}
for gh in &to_remove {
if let Some(ObjectType::Dictionary(dict)) =
self.document.objects.get_mut(&group_dict_handle)