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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-15 11:11:17 +03:00
commit 1490fe4ea9
9 changed files with 761 additions and 26 deletions

View file

@ -106,6 +106,12 @@ impl Scene {
// (e.g. a plugin-supplied layer) so it survives a DWG save instead of
// collapsing to layer 0 in the reopened file (#252).
let layer = entity.common().layer.clone();
// Delta-undo poison inputs (captured before the mutations below): an
// add that also creates a new layer, adds a block, or inserts an image
// definition mutates non-entity state a pure-entity delta can't undo.
let creates_layer =
self.is_recording_undo() && !layer.trim().is_empty() && !self.document.layers.contains(&layer);
let is_image = matches!(&entity, EntityType::RasterImage(_));
self.ensure_layer(&layer);
// Route to the correct block based on current editing mode:
@ -135,6 +141,16 @@ impl Scene {
if let Some(model) = mesh_seed {
self.meshes.insert(handle, model);
}
// Delta-undo: the new handle's before-image is "nothing" (it did not
// exist). Poison the recording if this add also mutated non-entity
// state (a new layer / block / image definition) so the app knows a
// pure-entity delta would be incomplete.
if self.is_recording_undo() {
self.record_undo_before(handle, None);
if creates_layer || affects_blocks || is_image {
self.poison_undo_recording();
}
}
if affects_blocks {
self.bump_geometry();
} else {
@ -210,6 +226,9 @@ impl Scene {
// so the edited entity keeps that layer on save instead of collapsing
// to layer 0 in the reopened file (#252).
let new_layer = entity.common().layer.clone();
let creates_layer = self.is_recording_undo()
&& !new_layer.trim().is_empty()
&& !self.document.layers.contains(&new_layer);
self.ensure_layer(&new_layer);
// Rebuild the derived-model seeds from the new entity (as add_entity).
@ -239,6 +258,17 @@ impl Scene {
None
};
// Delta-undo: capture the entity's pre-edit image (before the slot is
// 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();
self.record_undo_before(handle, before);
if creates_layer || affects_blocks {
self.poison_undo_recording();
}
}
// Write the new entity into the live slot.
let Some(slot) = self.document.get_entity_mut(handle) else {
return false;
@ -273,6 +303,61 @@ impl Scene {
true
}
/// Rebuild the per-entity derived caches (hatch fill / raster image / solid
/// mesh) for a single handle from whatever entity currently lives at it —
/// or drop them all if the handle is now absent. Mirrors the reseed block in
/// [`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),
Some(entity) => {
let hatch_seed = if let EntityType::Hatch(dxf) = entity {
let color = self.render_style(entity).0;
Self::hatch_model_from_dxf(dxf, color)
} else if let EntityType::Solid(solid) = entity {
let color = self.render_style(entity).0;
Some(Self::solid_hatch_model(solid, color))
} else {
None
};
let image_seed = if let EntityType::RasterImage(img) = entity {
ImageModel::from_raster_image(img)
} else {
None
};
let facet_res = self.document.header.facet_resolution;
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)
.map(offset_mesh_lod_set)
} else {
None
};
(hatch_seed, image_seed, mesh_seed)
}
};
self.hatches.remove(&handle);
self.images.remove(&handle);
self.meshes.remove(&handle);
self.solid_models.remove(&handle);
if let Some(model) = hatch_seed {
self.hatches.insert(handle, model);
}
if let Some(model) = image_seed {
self.images.insert(handle, model);
}
if let Some(model) = mesh_seed {
self.meshes.insert(handle, model);
}
}
/// Returns the RGBA color for the given layer name.
pub fn layer_color(&self, layer: &str) -> [f32; 4] {
let layer_entry = self.document.layers.get(layer);