From 05b044f36754ca784a9ae34d4dca16c6b57e7125 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 28 Jul 2026 17:16:27 +0300 Subject: [PATCH] perf(scene): make geometry edits incremental Avoid full document, cache, and GPU rebuilds for entity deltas so add, delete, history, selection, and live polyline edits stay responsive on large drawings. --- src/app/command_driver.rs | 79 +- src/app/commands/blocks.rs | 4 +- src/app/commands/dim.rs | 24 +- src/app/commands/display.rs | 23 +- src/app/commands/inquiry.rs | 4 +- src/app/commands/layerprops.rs | 11 +- src/app/commands/layers.rs | 5 +- src/app/commands/styleprops.rs | 14 +- src/app/commands/view.rs | 4 +- src/app/history.rs | 139 ++-- src/app/mtext_editor.rs | 8 +- src/app/properties.rs | 11 +- src/app/style_ops.rs | 7 +- src/app/text_inline.rs | 4 +- src/app/update/command.rs | 38 +- src/app/update/file.rs | 6 +- src/app/update/mod.rs | 37 +- src/app/update/viewport.rs | 10 +- src/app/view/mod.rs | 2 +- src/command.rs | 4 + src/modules/draw/draw/polyline.rs | 34 +- src/perf.rs | 34 + src/scene/entity.rs | 92 ++- src/scene/group_layer.rs | 9 + src/scene/mod.rs | 1205 ++++++++++++++++++++++++----- src/scene/modify.rs | 9 +- src/scene/paper.rs | 45 +- src/scene/pipeline/mod.rs | 126 ++- src/scene/pipeline/wire_arena.rs | 219 ++++-- src/scene/pipeline/wire_gpu.rs | 78 ++ src/scene/selection.rs | 49 +- src/scene/view/render.rs | 118 ++- 32 files changed, 1936 insertions(+), 516 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 10fed2b34..57db4801c 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -351,9 +351,9 @@ impl OpenCADStudio { self.update_cont_anchor(&entity); } let label = self.history_label_from_active_cmd(i, "ENTITY"); - // A plain drawable added on an existing layer touches only the - // one new entity; a block/image/dimension/viewport add also - // mutates layers/objects/blocks → keep the full snapshot. + // Ordinary drawables, viewports and raster images use targeted + // entity/object deltas. Block sentinels and novel layers retain + // the structure snapshot fallback. let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); self.commit_entity(entity); @@ -401,7 +401,7 @@ impl OpenCADStudio { ) }); let label = self.history_label_from_active_cmd(i, "MVIEW"); - let pending = self.begin_undo(i, label, 1, false); + let pending = self.begin_undo(i, label, 1, true); let handle = self.commit_entity_handle( acadrust::EntityType::Viewport(viewport), ); @@ -475,7 +475,7 @@ impl OpenCADStudio { let created_boundary = boundary.is_some(); let touched = 2; let label = self.history_label_from_active_cmd(i, "MVIEW"); - let pending = self.begin_undo(i, label, touched, false); + let pending = self.begin_undo(i, label, touched, true); let clip_handle = match boundary { Some(mut boundary) => { // A non-rectangular viewport owns a helper boundary @@ -1126,6 +1126,16 @@ impl OpenCADStudio { } } } + CmdResult::FinalizeLiveEntity(handle) => { + // The live geometry already matches the command's committed + // vertices. Close the deferred history image and UI state + // without publishing a redundant Modified delta. + self.finish_live_entity_history(i, handle); + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + } CmdResult::RemoveLiveEntity(handle) => { // The command backed off below a valid entity (PLINE Undo at // one remaining vertex): take the live entity out of the @@ -1289,9 +1299,14 @@ impl OpenCADStudio { } self.tabs[i].dirty = true; // Color / linetype / lineweight are baked into the cached - // wires at tessellation time; bump the geometry epoch so the - // matched objects repaint instead of holding their old look. - self.tabs[i].scene.bump_geometry(); + // wires at tessellation time; re-tessellate only the matched + // objects instead of rebuilding a large drawing. + let changes: Vec<_> = dest + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); self.refresh_properties(); self.command_line .push_info(&format!("Properties matched to {} object(s).", dest.len())); @@ -1661,9 +1676,10 @@ impl OpenCADStudio { if changed { self.tabs[i].dirty = true; // Repaint immediately (wide-band fill included). - self.tabs[i].scene.mark_entity_dirty(handle); self.tabs[i].scene.refresh_fill_model(handle); - self.tabs[i].scene.bump_geometry_no_blocks(); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); self.command_line.push_output("PEDIT: applied."); self.refresh_properties(); } else { @@ -2093,15 +2109,11 @@ 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 { - 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); - } + 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; @@ -2928,9 +2940,15 @@ impl OpenCADStudio { } } } - // The wires were tessellated before the filters existed; force a rebuild - // so the clip is applied to the freshly-pasted, now-filtered inserts. - self.tabs[i].scene.bump_geometry(); + // The wires were tessellated before the filters existed; refresh only + // the freshly-pasted entities whose clip object graph was attached. + let changes: Vec<_> = by_index + .iter() + .copied() + .filter(|handle| !handle.is_null()) + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); } /// Recreate the captured xdictionary subtrees in this document (fresh @@ -3098,9 +3116,11 @@ fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) { }; let effective_spacing = if spacing <= 0.0 { 10.0 } else { spacing }; + let mut changes = Vec::new(); for (idx, &hv) in other_vals.iter().enumerate() { let h = acadrust::Handle::from(hv); let target = base_coord + effective_spacing * (idx + 1) as f64; + let mut changed = false; if let Some(acadrust::EntityType::Dimension(dim)) = scene.document.get_entity_mut(h) { // Slide this dim's definition point along perp so its perpendicular // coordinate equals `target`; update both the struct field (render) @@ -3115,19 +3135,26 @@ fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) { Dimension::Linear(d) => { slide(&mut d.definition_point); d.base.definition_point = d.definition_point; + changed = true; } Dimension::Aligned(d) => { slide(&mut d.definition_point); d.base.definition_point = d.definition_point; + changed = true; } _ => {} } } - // 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) - scene.invalidate_dim_block_recorded(h); + if changed { + // The dimension line moved, so its baked *D block is stale — drop it + // so the next save re-bakes it. (#181) + scene.invalidate_dim_block_recorded(h); + changes.push((h, crate::scene::ChangeKind::Modified)); + } + } + if !changes.is_empty() { + scene.bump_entities(&changes); } - scene.bump_geometry(); } // ── MLEADERALIGN helper ─────────────────────────────────────────────────────── diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 0abc2e227..30b249ab0 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -370,6 +370,7 @@ impl OpenCADStudio { .unwrap_or_default(); self.push_undo_snapshot(i, "ATTSYNC"); let mut synced = 0usize; + let mut changes = Vec::new(); for e in self.tabs[i].scene.document.entities_mut() { if let acadrust::EntityType::Insert(ins) = e { if ins.block_name.eq_ignore_ascii_case(&block) { @@ -390,10 +391,11 @@ impl OpenCADStudio { } } synced += 1; + changes.push((ins.common.handle, crate::scene::ChangeKind::Modified)); } } } - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.bump_entities(&changes); self.tabs[i].dirty = true; self.command_line.push_output(&format!( "ATTSYNC: synchronised {synced} insert(s) of \"{block}\" against {} attribute definition(s).", diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 0ed5ad2eb..6c507b43c 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -169,7 +169,11 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); + let changes: Vec<_> = handles + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); self.command_line .push_output(&format!("JUSTIFYTEXT: updated {n} text object(s).")); } @@ -253,7 +257,11 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); + let changes: Vec<_> = handles + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); self.command_line .push_output(&format!("TCASE: updated {n} text object(s).")); } @@ -397,7 +405,11 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); + let changes: Vec<_> = handles + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); self.command_line.push_output(&format!( "TEXTFIT: fitted {n} text object(s) to width {target}." )); @@ -459,7 +471,11 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); + let changes: Vec<_> = texts + .iter() + .map(|(handle, _, _)| (*handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); self.command_line.push_output(&format!( "TCOUNT: numbered {} text object(s) from {start}.", texts.len() diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 84bdd6353..caf11f247 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -686,7 +686,11 @@ impl OpenCADStudio { n += 1; } } - self.tabs[i].scene.bump_geometry(); + let changes: Vec<_> = handles + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); self.tabs[i].dirty = true; self.command_line.push_output(&format!( "OBJECTSCALE: marked {n} object(s) annotative (they scale with the annotation scale)." @@ -776,6 +780,7 @@ impl OpenCADStudio { let v = v.min(100); self.push_undo_snapshot(i, "ADJUST"); let mut changed = 0usize; + let mut changed_handles = Vec::new(); for h in &handles { if let Some(acadrust::EntityType::RasterImage(img)) = self.tabs[i] .scene @@ -787,14 +792,17 @@ impl OpenCADStudio { "BRIGHTNESS" => { img.brightness = v; changed += 1; + changed_handles.push(*h); } "CONTRAST" => { img.contrast = v; changed += 1; + changed_handles.push(*h); } "FADE" => { img.fade = v; changed += 1; + changed_handles.push(*h); } _ => {} } @@ -802,7 +810,14 @@ impl OpenCADStudio { } if changed > 0 { self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); + for &handle in &changed_handles { + self.tabs[i].scene.reseed_derived_caches(handle); + } + let changes: Vec<_> = changed_handles + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); self.command_line .push_output(&format!("ADJUST: {action} = {v} on {changed} image(s).")); } else { @@ -860,7 +875,7 @@ impl OpenCADStudio { let hdr = &mut self.tabs[i].scene.document.header; hdr.current_annotation_scale = arg.clone(); hdr.annotation_scale_value = 1.0 / v as f64; - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.invalidate_annotation_dependencies(); self.tabs[i].dirty = true; self.command_line .push_output(&format!("Annotation scale: {arg}")); @@ -1014,7 +1029,6 @@ impl OpenCADStudio { self.tabs[i] .scene .add_entity_clone(acadrust::EntityType::Table(table)); - self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; self.command_line.push_output(&format!( "DATALINK: imported {nrows}×{ncols} cells into a table at the origin." @@ -1061,7 +1075,6 @@ impl OpenCADStudio { .scene .add_entity_clone(acadrust::EntityType::Point(p)); } - self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; self.command_line.push_output(&format!( "LANDXMLIMPORT: imported {} survey point(s). Use ZOOM EXTENTS to view.", diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index cff87a408..7415ce451 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -453,7 +453,9 @@ impl OpenCADStudio { self.grip_hover = None; self.grip_popup = None; self.visibility_popup = None; - self.tabs[i].scene.bump_geometry(); + // Entering BEDIT changes which block is assembled, not the + // cached geometry of that block's entities. + self.tabs[i].scene.bump_geometry_no_blocks(); // Frame the camera on the block's own geometry (block-local, near // origin) — fit_all() goes through current_layout_block_handle so // it already scopes to the edited block. Without this the view diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 858e6f653..7ce640f44 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -129,9 +129,14 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "LAYER COLOR"); self.tabs[i].dirty = true; // By-layer colour is baked into every wire on - // this layer — re-tessellate so they repaint - // immediately (issue #231 class). - self.tabs[i].scene.bump_geometry(); + // this layer. The dependency index maps block + // children back to their top-level INSERTs, so + // unrelated entities stay warm. + self.tabs[i] + .scene + .invalidate_layer_dependencies(std::slice::from_ref( + &layer_name, + )); self.command_line.push_output(&format!( "LAYER: '{}' color set to ACI {}.", layer_name, idx diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index d17746a07..dcc68200c 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -200,7 +200,10 @@ impl OpenCADStudio { } } self.tabs[i].scene.document.layers.remove(&src); - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.invalidate_dependency_index(); + self.tabs[i] + .scene + .invalidate_layer_dependencies(std::slice::from_ref(&dst)); self.tabs[i].dirty = true; self.refresh_layer_panel(); self.command_line.push_output(&format!( diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 7848fa9ac..08cd3eb63 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -725,7 +725,11 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); + let changes: Vec<_> = handles + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); self.command_line.push_output(&format!( "SETBYLAYER: reset {changed} entity/entities to ByLayer." )); @@ -1593,7 +1597,9 @@ impl OpenCADStudio { // TEXTFILL reset the glyph atlas; re-tessellate so text picks // up the re-baked filled / hollow tiles. if name == "TEXTFILL" { - self.tabs[i].scene.bump_geometry(); + self.tabs[i] + .scene + .invalidate_text_geometry_dependencies(); } // ORTHOMODE / OSMODE set the header directly; mirror them into // the live Ortho / running OSNAP so the constraint + status @@ -1902,7 +1908,7 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "PDMODE"); self.tabs[i].scene.document.header.point_display_mode = v; // Point glyphs are built at tessellation time — rebuild them. - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.invalidate_point_dependencies(); self.tabs[i].dirty = true; self.command_line.push_output(&format!("PDMODE set to {v}")); } else { @@ -2046,7 +2052,7 @@ impl OpenCADStudio { } else if let Ok(v) = val_str.parse::() { self.push_undo_snapshot(i, "PDSIZE"); self.tabs[i].scene.document.header.point_display_size = v; - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.invalidate_point_dependencies(); self.tabs[i].dirty = true; self.command_line .push_output(&format!("PDSIZE set to {v:.4}")); diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 0ebe151d9..bb41e3940 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -956,7 +956,9 @@ impl OpenCADStudio { // render-side `sort_cache` rebuilds per geometry epoch. // Bump it so the new draw order shows immediately // instead of waiting for an unrelated geometry change. - self.tabs[i].scene.bump_geometry(); + // Draw order changes submission order only; all + // per-entity tessellation remains valid. + self.tabs[i].scene.bump_geometry_no_blocks(); self.tabs[i].dirty = true; } else { self.command_line.push_info( diff --git a/src/app/history.rs b/src/app/history.rs index b2e687a34..4f36ad6f0 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -623,52 +623,30 @@ impl OpenCADStudio { self.push_undo_entry(i, HistorySnapshot::Delta(delta)); } - /// Copy is delta-safe only when no target is a Dimension and no complete - /// group is copied: dimensions clone fresh anonymous `*D` block records, - /// and complete group copies add Group objects / dictionary entries. Both - /// are non-entity state a pure-entity delta can't restore. + /// Dimension copies still need a full snapshot because they clone a fresh + /// anonymous `*D` block record. Complete group copies record their exact + /// Group/dictionary object entries and remain delta-safe. 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 + !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| handle_set.contains(h)) - } - _ => false, - }); - !copies_dimension && !copies_complete_group + .any(|h| matches!(doc.get_entity(*h), Some(EntityType::Dimension(_)))) } - /// Erase is delta-safe only when no target belongs to a group: erasing a - /// grouped entity rewrites the group's membership in `document.objects`. - 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| handle_set.contains(h)), - _ => false, - }) + /// Erase records any affected Group/object dictionary entries alongside the + /// entity before-images, so it never needs a whole-structure snapshot. + pub(super) fn delta_erase_safe(&self, _i: usize, _handles: &[Handle]) -> bool { + true } - /// Add is delta-safe only for a plain drawable on an already-existing layer: - /// a block / image add also creates block records, image definitions or - /// layers (non-entity state). INSERT only references an existing block and - /// is entity-delta safe. + /// Add is delta-safe on an already-existing layer unless it creates block + /// records. RasterImage definitions are captured as exact object-map deltas; + /// Viewport commits explicitly record their added entity handle. pub(super) fn delta_add_safe(&self, i: usize, entity: &EntityType) -> bool { if matches!( entity, EntityType::Block(_) | EntityType::BlockEnd(_) - | EntityType::RasterImage(_) - // A viewport commit routes through add_entity_to_layout + - // bump_geometry_no_blocks, bypassing the recorded Scene::add_entity - // — nothing would be captured, so keep the full snapshot. - | EntityType::Viewport(_) ) { return false; } @@ -706,14 +684,25 @@ impl OpenCADStudio { pending.label ); } - let entities: Vec<(Handle, Option>, Option>)> = rec - .into_before_images() + let (entity_before, object_before) = rec.into_recorded_images(); + let entities: Vec<(Handle, Option>, Option>)> = entity_before .into_iter() .map(|(h, before)| { let after = self.tabs[i].scene.document.get_entity_arc(h); (h, before, after) }) .collect(); + let objects: Vec = object_before + .into_iter() + .filter_map(|(handle, before)| { + let after = self.tabs[i].scene.document.objects.get(&handle).cloned(); + (before != after).then_some(ObjectEntryDelta { + handle, + 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.map(StructureSnapshot::Full); @@ -722,20 +711,23 @@ impl OpenCADStudio { 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; - } + .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; + } } } + if structure.is_none() && !objects.is_empty() { + structure = Some(StructureSnapshot::Objects(objects)); + } let delta = DeltaSnapshot { entities, current_layout_before: pending.current_layout, @@ -849,7 +841,6 @@ impl OpenCADStudio { self.tabs[i].scene.document.objects.remove(&entry.handle); } } - self.tabs[i].scene.invalidate_dependency_index(); } StructureSnapshot::Styles { before, @@ -909,13 +900,21 @@ impl OpenCADStudio { (&snapshot.after, &snapshot.selected_after) }; let scene = &mut self.tabs[i].scene; + let mut affected = scene.object_isolation.hidden.clone(); + affected.extend(state.hidden.iter().copied()); scene.object_isolation = state.clone(); scene.selected = selected .iter() .copied() .filter(|handle| scene.document.get_entity(*handle).is_some()) .collect(); - scene.bump_geometry_no_blocks(); + let changes: Vec<_> = affected + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + if !changes.is_empty() { + scene.bump_entities(&changes); + } } /// Perform the one expensive cache/UI synchronization required after a @@ -925,7 +924,7 @@ impl OpenCADStudio { &mut self, i: usize, had_full: bool, - structure_changed: bool, + layer_panel_changed: bool, changes: &[(Handle, crate::scene::ChangeKind)], ) { self.tabs[i].edit_revision = self.tabs[i].edit_revision.wrapping_add(1); @@ -970,7 +969,7 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].active_grip = None; - if structure_changed { + if layer_panel_changed { let doc_layers = self.tabs[i].scene.document.layers.clone(); let vp_info = self.tabs[i].scene.viewport_list(); self.tabs[i] @@ -1001,7 +1000,7 @@ impl OpenCADStudio { let mut last_label = String::new(); let mut had_full = false; - let mut structure_changed = false; + let mut layer_panel_changed = false; let mut changes = Vec::new(); for _ in 0..steps { let Some(snapshot) = self.tabs[i].history.undo_stack.pop() else { @@ -1013,7 +1012,19 @@ impl OpenCADStudio { // 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. - structure_changed |= d.structure.is_some(); + layer_panel_changed |= d.structure.as_ref().is_some_and(|structure| { + matches!( + structure, + StructureSnapshot::Full(_) | StructureSnapshot::Layers(_) + ) + }) || d.entities.iter().any(|(_, before, after)| { + before + .as_deref() + .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) + || after + .as_deref() + .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) + }); had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full); changes.extend(self.apply_delta_state(i, &mut d, true)); self.tabs[i] @@ -1030,7 +1041,7 @@ impl OpenCADStudio { } } } - self.finish_history_apply(i, had_full, structure_changed, &changes); + self.finish_history_apply(i, had_full, layer_panel_changed, &changes); self.command_line .push_output(&format!("Undo: {last_label}")); } @@ -1047,7 +1058,7 @@ impl OpenCADStudio { let mut last_label = String::new(); let mut had_full = false; - let mut structure_changed = false; + let mut layer_panel_changed = false; let mut changes = Vec::new(); for _ in 0..steps { let Some(snapshot) = self.tabs[i].history.redo_stack.pop() else { @@ -1056,7 +1067,19 @@ impl OpenCADStudio { last_label = snapshot.label().to_string(); match snapshot { HistorySnapshot::Delta(mut d) => { - structure_changed |= d.structure.is_some(); + layer_panel_changed |= d.structure.as_ref().is_some_and(|structure| { + matches!( + structure, + StructureSnapshot::Full(_) | StructureSnapshot::Layers(_) + ) + }) || d.entities.iter().any(|(_, before, after)| { + before + .as_deref() + .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) + || after + .as_deref() + .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) + }); had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full); changes.extend(self.apply_delta_state(i, &mut d, false)); self.tabs[i] @@ -1073,7 +1096,7 @@ impl OpenCADStudio { } } } - self.finish_history_apply(i, had_full, structure_changed, &changes); + self.finish_history_apply(i, had_full, layer_panel_changed, &changes); self.command_line .push_output(&format!("Redo: {last_label}")); } diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index 11076c6df..f3a27cd73 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -1089,7 +1089,9 @@ impl super::OpenCADStudio { } _ => {} } - self.tabs[i].scene.bump_geometry(); + self.tabs[i] + .scene + .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; } else { // Align new MText to the active UCS (text runs along the UCS X axis). @@ -1140,7 +1142,9 @@ impl super::OpenCADStudio { } _ => {} } - self.tabs[i].scene.bump_geometry(); + self.tabs[i] + .scene + .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; } else { mt.rotation = self.tabs[i].ucs_rotation_angle(); diff --git a/src/app/properties.rs b/src/app/properties.rs index e46f91231..1f9c50bfc 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1965,15 +1965,14 @@ impl OpenCADStudio { 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 - // invalidates the wire-tessellation cache. Without this the - // new viewport's border isn't tessellated until the next - // zoom/pan forces a rebuild. - self.tabs[i].scene.bump_geometry_no_blocks(); + // bypasses Scene::add_entity; publish the exact new handle + // so only its border is tessellated. + self.tabs[i] + .scene + .bump_entities(&[(new_handle, crate::scene::ChangeKind::Added)]); Some(new_handle) } Err(e) => { diff --git a/src/app/style_ops.rs b/src/app/style_ops.rs index 74b0ed681..d3ae43a91 100644 --- a/src/app/style_ops.rs +++ b/src/app/style_ops.rs @@ -581,10 +581,7 @@ impl OpenCADStudio { pub(super) fn style_stage_commit(&mut self) { let i = self.active_tab; let Some(stage) = self.style_stage.take() else { - // No active transaction — still refresh the drawing so a bare Apply - // is harmless. - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); + // No staged edits: a bare Apply has no geometry work to publish. self.sync_ribbon_styles(); return; }; @@ -820,7 +817,7 @@ impl OpenCADStudio { if !resolves { self.tabs[i].scene.document.header.current_annotation_scale = snap.current.clone(); } - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.invalidate_annotation_dependencies(); } /// Begin a staging transaction for a freshly-opened scale manager. diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index 9eff2311e..5e7bf9d2b 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -206,7 +206,9 @@ impl super::OpenCADStudio { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(h) { write_text_field(entity, ed.field, ed.value.clone()); } - self.tabs[i].scene.bump_geometry(); + self.tabs[i] + .scene + .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; } else { let mut t = Text::with_value( diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 4376ad2ae..a5d446383 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -199,7 +199,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i] .scene .invalidate_dim_block_recorded(pending.handle); - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.bump_entities(&[( + pending.handle, + crate::scene::ChangeKind::Modified, + )]); self.tabs[i].dirty = true; self.refresh_selected_grips(); self.refresh_properties(); @@ -534,7 +537,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::scene::model::object::GripMenuAction::RemoveLeader, ); } - self.tabs[i].scene.bump_geometry(); + self.tabs[i] + .scene + .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); self.refresh_selected_grips(); } // Cancel an in-progress grip drag: restore the edited @@ -649,7 +654,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i] .layers .sync_with_viewports(&doc_layers, vp_info); - self.tabs[i].scene.bump_geometry(); + // Per-viewport layer visibility changes the resident + // assembly, not any entity's tessellated geometry. + self.tabs[i].scene.bump_geometry_no_blocks(); self.tabs[i].dirty = true; } } @@ -1022,7 +1029,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i] .scene .invalidate_dim_block_recorded(popup.handle); - self.tabs[i].scene.bump_geometry(); + self.tabs[i] + .scene + .bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; self.refresh_selected_grips(); self.refresh_properties(); @@ -1534,7 +1543,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .get(&value) .map(|br| br.name.clone()); if let Some(canon) = canon { - let mut changed = false; + let mut changes = Vec::new(); for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { continue; @@ -1544,14 +1553,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { if ins.block_name != canon { ins.block_name = canon.clone(); - changed = true; + changes.push(( + handle, + crate::scene::ChangeKind::Modified, + )); } } } - if changed { - // The picked block may not be in the defn cache - // yet — rebuild block definitions too. - self.tabs[i].scene.bump_geometry(); + if !changes.is_empty() { + self.tabs[i].scene.bump_entities(&changes); } } } else if field == "plot_style" { @@ -1804,7 +1814,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if protected { return; } - let mut changed = false; + let mut changes = Vec::new(); for &handle in handles { if self.tabs[i].scene.is_layer_locked(handle) { continue; @@ -1814,12 +1824,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { if ins.block_name != canon { ins.block_name = canon.clone(); - changed = true; + changes.push((handle, crate::scene::ChangeKind::Modified)); } } } - if changed { - self.tabs[i].scene.bump_geometry(); + if !changes.is_empty() { + self.tabs[i].scene.bump_entities(&changes); } return; } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index ebe9e0b87..8cb7cd456 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1922,9 +1922,9 @@ pub(super) fn on_open_file(&mut self) -> Task { } self.tabs[i].dirty = true; - // The paper sheet fill is cached; bump geometry so the new - // sheet size re-tessellates and shows immediately. - self.tabs[i].scene.bump_geometry(); + // The paper sheet fill is cached by epoch, while document + // entity tessellation is unaffected by the paper size. + self.tabs[i].scene.bump_geometry_no_blocks(); self.command_line.push_info(&format!( "Page setup: {w:.1}×{h:.1} mm area={plot_area} \ center={center} rot={rotation}°" diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ed11ad25b..b13eec8a5 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -39,6 +39,22 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool { ) } +fn perf_message_label(msg: &Message) -> &'static str { + match msg { + Message::ViewportLeftPress | Message::PanePress(_) => "pointer-down", + Message::ViewportLeftRelease | Message::PaneRelease(_) => "pointer-up", + Message::ViewportMove(_) | Message::PaneMove(_, _) => "pointer-move", + Message::CommandFinalize => "command-finalize", + Message::CommandEscape => "command-escape", + Message::Undo | Message::UndoMany(_) => "undo", + Message::Redo | Message::RedoMany(_) => "redo", + Message::DeleteSelected => "delete", + Message::HoverDwellTick => "hover-dwell", + Message::InteractionIndexReady { .. } => "interaction-index-ready", + _ => "other", + } +} + const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX; fn format_size(bytes: u64) -> String { @@ -139,6 +155,8 @@ impl OpenCADStudio { } pub fn update(&mut self, msg: Message) -> Task { + let perf_started = crate::perf::enabled().then(Instant::now); + let perf_label = perf_message_label(&msg); // A modal dialog must capture the keyboard the same way it already // captures the mouse. Otherwise keystrokes from the global key // subscription leak past the modal into the command line and fire as @@ -185,6 +203,15 @@ impl OpenCADStudio { self.snapper.clear_tracking(); self.otrack_active = None; } + if let Some(started) = perf_started { + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + if elapsed_ms >= 5.0 { + crate::perf_record!( + "[perf] update {:>7.1}ms message={perf_label}", + elapsed_ms, + ); + } + } task } @@ -223,7 +250,7 @@ impl OpenCADStudio { crate::scene::text::web_font::insert(script, Some(bytes)); crate::scene::text::ttf_glyph::clear_fallback_cache(); for tab in self.tabs.iter_mut() { - tab.scene.bump_geometry(); + tab.scene.invalidate_text_geometry_dependencies(); } } Err(e) => { @@ -965,7 +992,9 @@ impl OpenCADStudio { &self.tabs[idx].scene.document.header.code_page, ) { crate::scene::text::ttf_glyph::clear_fallback_cache(); - self.tabs[idx].scene.bump_geometry(); + self.tabs[idx] + .scene + .invalidate_text_geometry_dependencies(); } } Task::none() @@ -2103,7 +2132,7 @@ impl OpenCADStudio { if let Some(tab) = self.tabs.get_mut(self.active_tab) { tab.scene.annotation_scale = scale; util::sync_annotation_scale_header(&mut tab.scene); - tab.scene.bump_geometry(); + tab.scene.invalidate_annotation_dependencies(); } Task::none() } @@ -2341,7 +2370,7 @@ impl OpenCADStudio { self.tabs[i].scene.document.header.annotation_scale_value = p / d; } } - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.invalidate_annotation_dependencies(); } Task::none() } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index ff2ae6c88..8980cbbe3 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -95,7 +95,7 @@ impl OpenCADStudio { let next = if self.point_size_relative { -mag } else { mag }; self.push_undo_snapshot(i, "PDSIZE"); self.tabs[i].scene.document.header.point_display_size = next; - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.invalidate_point_dependencies(); self.tabs[i].dirty = true; } @@ -110,7 +110,7 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "PDMODE"); self.tabs[i].scene.document.header.point_display_mode = next; - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.invalidate_point_dependencies(); self.tabs[i].dirty = true; } @@ -161,7 +161,7 @@ impl OpenCADStudio { self.tabs[i].wireframe = wf; self.ribbon.set_wireframe(wf); self.tabs[i].visual_style = label.into(); - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.bump_geometry_no_blocks(); } /// Project a pane-local cursor onto the active drawing plane and return a @@ -405,7 +405,7 @@ impl OpenCADStudio { // viewport, the picker drives that viewport entity's own // render mode; the model-layout tab style is untouched. if self.tabs[i].scene.set_active_viewport_render_mode(mode) { - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.bump_geometry_no_blocks(); self.command_line .push_output(&format!("Viewport visual style: {label}")); return Task::none(); @@ -423,7 +423,7 @@ impl OpenCADStudio { self.tabs[i].visual_style = label.into(); // Re-upload face3d fills on the next frame — the render // pipeline keys its upload cache off `geometry_epoch`. - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.bump_geometry_no_blocks(); self.command_line .push_output(&format!("Visual style: {label}")); Task::none() diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 59e3d7c16..a80def45c 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1116,7 +1116,7 @@ impl OpenCADStudio { s.last_tess_wires.get(), s.geometry_epoch, ); - let trace = crate::perf::snapshot_text(); + let trace = crate::perf::snapshot_tail_text(80); let trace = if trace.is_empty() { "No samples yet".to_string() } else { diff --git a/src/command.rs b/src/command.rs index d84335673..3c069fdd5 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1020,6 +1020,10 @@ pub enum CmdResult { entity: EntityType, finish: bool, }, + /// End a command-owned live entity without replacing its already-current + /// document geometry. PLINE uses this for Enter/Escape after the latest + /// vertex was published, avoiding one redundant geometry epoch/GPU patch. + FinalizeLiveEntity(Handle), /// Remove the live entity from the document but keep the command running — /// PLINE's Undo popping back below the two vertices an entity needs. RemoveLiveEntity(Handle), diff --git a/src/modules/draw/draw/polyline.rs b/src/modules/draw/draw/polyline.rs index e0ff065d1..4b4311af1 100644 --- a/src/modules/draw/draw/polyline.rs +++ b/src/modules/draw/draw/polyline.rs @@ -345,12 +345,21 @@ impl CadCommand for PlineCommand { } fn on_enter(&mut self) -> CmdResult { - // The committed vertices already live in the document; finalise it. - self.sync_live(false, true) + // Every landed vertex is already published into the live document + // entity. Finishing must only close its history/command state; replacing + // the identical polyline again would advance geometry_epoch and make + // large drawings do another cache/GPU patch for no visual change. + match self.live_handle { + Some(handle) => CmdResult::FinalizeLiveEntity(handle), + None => CmdResult::Cancel, + } } fn on_escape(&mut self) -> CmdResult { - self.sync_live(false, true) + match self.live_handle { + Some(handle) => CmdResult::FinalizeLiveEntity(handle), + None => CmdResult::Cancel, + } } fn wants_text_input(&self) -> bool { @@ -483,4 +492,23 @@ mod tests { _ => panic!("later segments should update the same live polyline"), } } + + #[test] + fn finishing_live_polyline_does_not_publish_duplicate_update() { + let handle = Handle::new(42); + + let mut enter = PlineCommand::new(); + enter.live_handle = Some(handle); + assert!(matches!( + enter.on_enter(), + CmdResult::FinalizeLiveEntity(h) if h == handle + )); + + let mut escape = PlineCommand::new(); + escape.live_handle = Some(handle); + assert!(matches!( + escape.on_escape(), + CmdResult::FinalizeLiveEntity(h) if h == handle + )); + } } diff --git a/src/perf.rs b/src/perf.rs index 0f78b5f7b..1e3197c2f 100644 --- a/src/perf.rs +++ b/src/perf.rs @@ -10,6 +10,8 @@ const MAX_LINES: usize = 500; static UI_ENABLED: AtomicBool = AtomicBool::new(false); static ENV_ENABLED: OnceLock = OnceLock::new(); static LINES: OnceLock>> = OnceLock::new(); +#[cfg(not(target_arch = "wasm32"))] +static STDERR_TX: OnceLock> = OnceLock::new(); fn lines() -> &'static Mutex> { LINES.get_or_init(|| Mutex::new(VecDeque::with_capacity(MAX_LINES))) @@ -32,6 +34,24 @@ pub fn record(args: fmt::Arguments<'_>) { return; } let line = args.to_string(); + // Terminal writes can block the UI/render thread when its pipe is slow. + // PERF is diagnostic and must not create the hitch it is measuring. + #[cfg(not(target_arch = "wasm32"))] + { + let tx = STDERR_TX.get_or_init(|| { + let (tx, rx) = std::sync::mpsc::sync_channel::(1024); + let _ = std::thread::Builder::new() + .name("ocs-perf-log".to_string()) + .spawn(move || { + while let Ok(line) = rx.recv() { + eprintln!("{line}"); + } + }); + tx + }); + let _ = tx.try_send(line.clone()); + } + #[cfg(target_arch = "wasm32")] eprintln!("{line}"); let mut entries = lines().lock().unwrap_or_else(|e| e.into_inner()); if entries.len() == MAX_LINES { @@ -50,6 +70,20 @@ pub fn snapshot_text() -> String { .join("\n") } +/// A short tail for the live HUD. The full 500-line history remains available +/// to Copy, while rendering a large monolithic text widget on every mouse event +/// is avoided. +pub fn snapshot_tail_text(max_lines: usize) -> String { + let entries = lines().lock().unwrap_or_else(|e| e.into_inner()); + let skip = entries.len().saturating_sub(max_lines); + entries + .iter() + .skip(skip) + .map(String::as_str) + .collect::>() + .join("\n") +} + pub fn clear() { lines().lock().unwrap_or_else(|e| e.into_inner()).clear(); } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 56d5dfd36..1d53346c1 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -112,13 +112,12 @@ impl Scene { } pub fn add_entity(&mut self, mut entity: EntityType) -> Handle { - // Only Insert / Block entities can introduce or reference a block - // definition that the block cache must (re)build. Adding a plain - // top-level entity (line, arc, text, …) leaves every block defn intact, - // so it keeps the cache and skips the all-blocks re-tessellation. + // Only block sentinels mutate a block definition and require rebuilding + // the block cache. A top-level INSERT merely references an existing + // definition, so adding it can patch just that new render handle. let affects_blocks = matches!( &entity, - EntityType::Insert(_) | EntityType::Block(_) | EntityType::BlockEnd(_) + EntityType::Block(_) | EntityType::BlockEnd(_) ); // INSERT invalidates rendered block instances, but it does not mutate // the referenced block definition. Only block sentinels require a @@ -161,6 +160,9 @@ impl Scene { if img.definition_handle.is_none() { use acadrust::objects::{ImageDefinition, ObjectType}; let def_handle = Handle::new(self.document.next_handle()); + if self.is_recording_undo() { + self.record_undo_object_before(def_handle, None); + } let mut img_def = ImageDefinition::with_dimensions( &img.file_path, img.size.x as u32, @@ -184,7 +186,6 @@ impl Scene { // 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: @@ -238,11 +239,12 @@ impl Scene { } // 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. + // state (a new layer / block) so the app knows a pure-entity delta + // would be incomplete. Raster image definitions are captured as + // targeted object before-images above. if self.is_recording_undo() { self.record_undo_before(handle, None); - if creates_layer || mutates_block_structure || is_image { + if creates_layer || mutates_block_structure { self.poison_undo_recording(); } } @@ -323,14 +325,15 @@ impl Scene { // The caller edited a snapshot copy; keep the live entity in its block. entity.common_mut().owner_handle = existing.common().owner_handle; - // Replacing (or becoming) a block entity forces a full block-cache - // rebuild; a plain entity only needs its own wires re-tessellated. + // Replacing (or becoming) a block sentinel forces a full block-cache + // rebuild. INSERT edits (including retargeting to another existing + // definition) only change that top-level render handle. let affects_blocks = matches!( existing, - EntityType::Insert(_) | EntityType::Block(_) | EntityType::BlockEnd(_) + EntityType::Block(_) | EntityType::BlockEnd(_) ) || matches!( &entity, - EntityType::Insert(_) | EntityType::Block(_) | EntityType::BlockEnd(_) + EntityType::Block(_) | EntityType::BlockEnd(_) ); // A plugin edit may retarget the entity to a novel layer; register it @@ -481,6 +484,34 @@ impl Scene { if handles.is_empty() { return; } + let mesh_entities: Vec<(Handle, std::sync::Arc)> = handles + .iter() + .filter_map(|&handle| { + let entity = self.document.get_entity_arc(handle)?; + matches!( + entity.as_ref(), + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Body(_) + | EntityType::Surface(_) + | EntityType::Mesh(_) + | EntityType::PolygonMesh(_) + | EntityType::PolyfaceMesh(_) + ) + .then_some((handle, entity)) + }) + .collect(); + for handle in handles { + self.meshes.remove(handle); + self.block_meshes.remove(handle); + self.solid_models.remove(handle); + } + // The overwhelmingly common Undo/Redo target is 2-D geometry. Return + // before scanning every Layout object just to discover there is no mesh + // to rebuild. + if mesh_entities.is_empty() { + return; + } let layout_blocks: std::collections::HashSet = self .document .objects @@ -492,32 +523,15 @@ impl Scene { _ => 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(_) - | EntityType::Mesh(_) - | EntityType::PolygonMesh(_) - | EntityType::PolyfaceMesh(_) - ) { - 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 entries: Vec<(Handle, std::sync::Arc, [f32; 4], bool)> = + mesh_entities + .into_iter() + .map(|(handle, entity)| { + let color = self.render_style(entity.as_ref()).0; + let top_level = layout_blocks.contains(&entity.common().owner_handle); + (handle, entity, color, top_level) + }) + .collect(); let facet_res = self.document.header.facet_resolution; let isolines = self.document.header.isolines.max(0) as usize; use crate::par::prelude::*; diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index 58b5a514b..6c007f8af 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -89,6 +89,7 @@ impl Scene { ) -> usize { let group_dict_handle = self.document.header.acad_group_dict_handle; let mut copied = 0; + let mut dictionary_recorded = false; for source in sources { let entities: Vec = source .entities @@ -105,6 +106,14 @@ impl Scene { group.name = name.clone(); group.entities = entities; let gh = group.handle; + if self.is_recording_undo() { + if !dictionary_recorded { + let before = self.document.objects.get(&group_dict_handle).cloned(); + self.record_undo_object_before(group_dict_handle, before); + dictionary_recorded = true; + } + self.record_undo_object_before(gh, None); + } self.document.objects.insert(gh, ObjectType::Group(group)); if let Some(ObjectType::Dictionary(dict)) = self.document.objects.get_mut(&group_dict_handle) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 1b01c1a2d..30a86a4f5 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -46,7 +46,7 @@ pub(super) struct EntityIndex { pub unbounded_handles: Vec, } -#[derive(Default)] +#[derive(Clone, Default)] struct DependencyTargets { render_handles: HashSet, source_handles: HashSet, @@ -59,6 +59,9 @@ struct SceneDependencyIndex { text_styles: HashMap, dim_styles: HashMap, object_styles: HashMap, + points: DependencyTargets, + text_geometry: DependencyTargets, + annotation_geometry: DependencyTargets, } fn hatch_interaction_aabb(hatch: &model::hatch_model::HatchModel) -> Option<[f64; 4]> { @@ -205,11 +208,11 @@ pub enum ChangeKind { /// While an entity-only undoable command runs, this captures the pre-mutation /// image of every entity the five mutation primitives (add / update / erase / /// transform / copy) touch, so the app can build a cheap **delta**-undo entry -/// instead of cloning the whole ~800k-entity document (~46 ms). `poisoned` is -/// set when a primitive also mutated non-entity state (a brand-new layer, a -/// group, or a `*D` block record) that a pure-entity delta cannot restore — the -/// app's per-command predicate keeps that from happening, and a debug assertion -/// fires if it ever does. +/// instead of cloning the whole ~800k-entity document (~46 ms). The small set +/// of object-map entries changed by ordinary entity operations (groups and +/// raster-image definitions) is captured alongside the entities. `poisoned` +/// remains the fallback for broader structure changes such as a brand-new +/// layer or a `*D` block record. #[derive(Default)] pub struct UndoRecording { /// First-touch before-image per handle (the first write for a handle wins, @@ -218,6 +221,10 @@ pub struct UndoRecording { /// state). `order` preserves first-touch order for a deterministic delta. before: HashMap>>, order: Vec, + /// First-touch before-images for the exact document.objects entries changed + /// by the command. `None` denotes a newly-created object. + object_before: HashMap>, + object_order: Vec, poisoned: bool, } @@ -227,20 +234,36 @@ impl UndoRecording { self.poisoned } - /// No entity was recorded (nothing to undo). + /// No entity or object entry was recorded (nothing to undo). pub fn is_empty(&self) -> bool { - self.order.is_empty() + self.order.is_empty() && self.object_order.is_empty() } - /// Consume the recording into `(handle, before-image)` pairs in first-touch - /// 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>)> { - self.order + /// Consume both recording directories in deterministic first-touch order. + /// A `None` image means the entity/object was added by the command. + pub fn into_recorded_images( + mut self, + ) -> ( + Vec<(Handle, Option>)>, + Vec<(Handle, Option)>, + ) { + let entities = self + .order .drain(..) .map(|h| (h, self.before.remove(&h).flatten())) - .collect() + .collect(); + let objects = self + .object_order + .drain(..) + .map(|h| (h, self.object_before.remove(&h).flatten())) + .collect(); + (entities, objects) + } + + /// Entity-only convenience used by the focused Scene delta tests. + #[cfg(test)] + pub fn into_before_images(self) -> Vec<(Handle, Option>)> { + self.into_recorded_images().0 } } @@ -252,9 +275,23 @@ impl UndoRecording { struct GeometryDelta { epoch: u64, changes: Vec<(Handle, ChangeKind)>, + /// Cache-category membership captured immediately before a Removed entity + /// leaves the document. Presence with value 0 means "known unrelated"; + /// absence means the caller removed it outside the tracked primitives and + /// consumers must remain conservative. + removed_categories: HashMap, full: bool, } +const CACHE_CATEGORY_ANNOTATIVE: u16 = 1 << 0; +const CACHE_CATEGORY_HATCH: u16 = 1 << 1; +const CACHE_CATEGORY_WIPEOUT: u16 = 1 << 2; +const CACHE_CATEGORY_IMAGE: u16 = 1 << 3; +const CACHE_CATEGORY_MESH: u16 = 1 << 4; +const CACHE_CATEGORY_INTERACTION: u16 = 1 << 5; +const CACHE_CATEGORY_TEXT: u16 = 1 << 6; +const CACHE_CATEGORY_INSERT_HATCH: u16 = 1 << 7; + /// Mutable assembly metadata for one resident wire set. Keeping entity ranges /// beside the flat render Vec lets a one-entity edit splice that run directly; /// rebuilding this directory by grouping every WireModel was the dominant @@ -272,6 +309,12 @@ struct ResidentWireLayout { order: Vec, /// Flat wire range `(start, len)` for each currently visible entity. ranges: HashMap, + /// Blanked ranges retained in the flat Vec. Undo/Redo and grip hide/show can + /// restore a same-shaped entity in place without shifting every later run. + vacant: HashMap, + /// Number of blank WireModel slots currently retained. Excessive waste + /// triggers the normal full-build compaction fallback. + tombstoned_wires: usize, /// First synthesized marker wire. Markers have no entity handle and remain /// at the tail while entity runs are inserted/removed before them. marker_start: usize, @@ -310,14 +353,102 @@ pub(crate) struct WireIndexEdit { pub(crate) start: usize, pub(crate) old_len: usize, pub(crate) new_len: usize, + /// Whether the handle should exist in the selection/text slot index after + /// the physical edit. Tombstoning keeps `old_len == new_len` but removes it + /// from the index; restoring the vacant range adds it back without a shift. + pub(crate) visible: bool, +} + +#[derive(Clone, Copy)] +struct DrawDepthEntry { + handle: u64, + effective: u64, + label: i32, + half_label: i32, +} + +// Stable order-maintenance labels. Initial builds occupy the middle half of +// this space, leaving 200 million units at either end. New CAD handles are +// monotonic, so ordinary Add extends with the existing label stride without +// changing any sibling's clip-z bias. The large integer domain preserves enough +// f32 separation for nested block children even in very large drawings. +// Deleted entries remain as tombstones so Undo/Redo restores the same slot. +const DRAW_DEPTH_LABEL_MIN: i32 = -1_000_000_000; +const DRAW_DEPTH_LABEL_MAX: i32 = 1_000_000_000; +const DRAW_DEPTH_INITIAL_MIN: i32 = -800_000_000; +const DRAW_DEPTH_INITIAL_MAX: i32 = 800_000_000; +const DRAW_DEPTH_LABEL_SCALE: f32 = 1.0 / 1_073_741_824.0; + +fn draw_depth_value(label: i32, half_label: i32) -> [f32; 2] { + [ + label as f32 * DRAW_DEPTH_LABEL_SCALE, + half_label as f32 * DRAW_DEPTH_LABEL_SCALE, + ] +} + +fn seed_draw_depth_entries(order: Vec<(u64, u64)>) -> Vec { + let count = order.len(); + let middle_capacity = + (DRAW_DEPTH_INITIAL_MAX as i64 - DRAW_DEPTH_INITIAL_MIN as i64 - 1) as usize; + let (low, high) = if count <= middle_capacity { + (DRAW_DEPTH_INITIAL_MIN, DRAW_DEPTH_INITIAL_MAX) + } else { + (DRAW_DEPTH_LABEL_MIN, DRAW_DEPTH_LABEL_MAX) + }; + let span = high as i64 - low as i64; + let step = (span / (count as i64 + 1)).max(1); + order + .into_iter() + .enumerate() + .map(|(index, (handle, effective))| DrawDepthEntry { + handle, + effective, + label: (low as i64 + step * (index as i64 + 1)) as i32, + half_label: (step / 2).max(1) as i32, + }) + .collect() +} + +fn inserted_draw_depth_label(order: &[DrawDepthEntry], position: usize) -> Option<(i32, i32)> { + match ( + position.checked_sub(1).and_then(|index| order.get(index)), + order.get(position), + ) { + (None, None) => Some((0, 1)), + (Some(left), None) => { + let stride = order + .get(position.saturating_sub(2)) + .map(|previous| left.label - previous.label) + .unwrap_or(left.half_label.saturating_mul(2)) + .max(1); + let label = left.label.checked_add(stride)?; + (label <= DRAW_DEPTH_LABEL_MAX).then_some((label, (stride / 2).max(1))) + } + (None, Some(right)) => { + let stride = order + .get(1) + .map(|next| next.label - right.label) + .unwrap_or(right.half_label.saturating_mul(2)) + .max(1); + let label = right.label.checked_sub(stride)?; + (label >= DRAW_DEPTH_LABEL_MIN).then_some((label, (stride / 2).max(1))) + } + (Some(left), Some(right)) if right.label - left.label > 1 => { + let label = left.label + (right.label - left.label) / 2; + let nearest = (label - left.label).min(right.label - label); + Some((label, (nearest / 2).max(1))) + } + _ => None, + } } struct DrawDepthCache { epoch: u64, depths: Arc>, - /// Per-block handles already sorted by effective draw-order key. - blocks: HashMap>, - /// Reverse owner lookup retained so Removed deltas need no document scan. + /// Per-block stable order labels, including deleted tombstones retained for + /// symmetric Undo/Redo. + blocks: HashMap>, + /// Reverse owner lookup retained across removal for tombstone restoration. owners: HashMap, } @@ -1484,18 +1615,11 @@ pub struct Scene { /// Maps block_handle → (entity_handle.value() → sort_handle.value()). /// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup. sort_cache: RefCell>)>>, - /// Per-entity normalized draw-order depth in (0,1), keyed by - /// entity_handle.value(). Higher = drawn on top. Built once per - /// geometry epoch by ranking every entity within its owning block by - /// effective sort key (SortEntitiesTable override or own handle), then - /// fed to the 2D pipelines as a small clip-z bias so entities of - /// *different* types order correctly against each other. 3D meshes are - /// excluded (they keep real geometric depth). Each value is - /// `[depth, half]`: `depth` = the entity's signed rank in (-1,1), `half` = - /// half the gap to the neighbouring ranks (`1/(block_count+1)`) — the - /// sub-range a block INSERT's children may occupy without crossing the - /// insert's siblings. Fill explosion and band wires compose child depths - /// as `depth + child_rank * half`. + /// Per-entity stable draw-order depth keyed by entity handle. Initial order + /// is assigned sparse integer labels; Add/Delete never renormalizes existing + /// siblings, so a structural edit updates only the changed entity's GPU + /// constants. `[depth, half]` retains a fixed child sub-range for block + /// composition. Full sort/layout/block changes rebuild the labels. draw_depth_cache: RefCell>, /// Cached hatch fill models, keyed by geometry_epoch. View culling /// is handled at draw time via `hatch_skip_flags` in the pipeline, @@ -1729,6 +1853,9 @@ pub struct Scene { /// entries past its last-synced epoch and patches per-handle instead of /// re-walking the whole document. See [`Scene::replay_since`]. geometry_deltas: RefCell>, + /// Before-category hints staged by erase/history immediately before the + /// entity disappears, then consumed by the matching geometry delta. + pending_removed_categories: RefCell>, /// Highest epoch whose delta has been evicted from the ring. A consumer /// synced older than this can't be caught up incrementally → full rebuild. geometry_journal_floor: std::cell::Cell, @@ -1850,6 +1977,7 @@ impl Scene { resident_tess_memo: RefCell::new(HashMap::default()), resident_tess_guard: std::cell::Cell::new(0), geometry_deltas: RefCell::new(std::collections::VecDeque::new()), + pending_removed_categories: RefCell::new(HashMap::default()), geometry_journal_floor: std::cell::Cell::new(0), resident_patch_hits: std::cell::Cell::new(0), model_wire_gpu_patch: RefCell::new(None), @@ -1961,10 +2089,26 @@ impl Scene { /// rebuild. Every `geometry_epoch` bump must call this exactly once so the /// journal never has a gap (a gap would let a consumer serve stale geometry). fn push_geometry_delta(&self, epoch: u64, changes: Vec<(Handle, ChangeKind)>, full: bool) { + let pending_categories = + std::mem::take(&mut *self.pending_removed_categories.borrow_mut()); + let removed_categories = if full { + HashMap::default() + } else { + let mut removed = HashMap::default(); + for &(handle, kind) in &changes { + if let Some(&bits) = pending_categories.get(&handle) { + if matches!(kind, ChangeKind::Removed) { + removed.insert(handle, bits); + } + } + } + removed + }; let mut ring = self.geometry_deltas.borrow_mut(); ring.push_back(GeometryDelta { epoch, changes, + removed_categories, full, }); while ring.len() > GEOMETRY_JOURNAL_CAP { @@ -2021,28 +2165,103 @@ impl Scene { Some(state.into_iter().collect()) } - /// True when a per-category derived cache (hatches / images / meshes / - /// wipeouts) synced at `cached_epoch` is still valid because nothing in its - /// category changed since. `in_category(h)` reports membership against the - /// live seed map. A removal is treated conservatively as possibly-relevant - /// (the handle is already gone from the seed map, so its category can't be - /// re-checked), and a `full`/overflow replay always invalidates. This lets a - /// plain edit — drawing or moving a line — keep every unrelated category - /// cache warm instead of rebuilding all hatch / image / mesh models. + fn entity_affects_text_cache(&self, entity: &EntityType) -> bool { + if matches!( + entity, + EntityType::Text(_) + | EntityType::MText(_) + | EntityType::Dimension(_) + | EntityType::MultiLeader(_) + | EntityType::Leader(_) + | EntityType::Table(_) + | EntityType::Tolerance(_) + | EntityType::AttributeEntity(_) + | EntityType::AttributeDefinition(_) + | EntityType::Insert(_) + ) { + return true; + } + // Complex-linetype glyphs ride the host entity's wire. + let lt = crate::scene::view::render::linetype_name_for(&self.document, entity); + crate::io::linetypes::resolve_complex_lt(&self.document, <).is_some() + } + + /// Stage the cache categories an entity belongs to before erase removes the + /// only cheap way to classify it. The next Removed geometry delta consumes + /// this entry; even a zero mask is meaningful ("known plain geometry"). + fn remember_removed_cache_categories(&self, handle: Handle) { + let Some(entity) = self.document.get_entity(handle) else { + return; + }; + let mut bits = 0u16; + if crate::scene::annotative::is_annotative(&self.document, entity) { + bits |= CACHE_CATEGORY_ANNOTATIVE; + } + if self.hatches.contains_key(&handle) { + bits |= CACHE_CATEGORY_HATCH | CACHE_CATEGORY_INTERACTION; + } + if matches!(entity, EntityType::Wipeout(_)) { + bits |= CACHE_CATEGORY_WIPEOUT; + } + if self.images.contains_key(&handle) { + bits |= CACHE_CATEGORY_IMAGE; + } + let is_insert = matches!(entity, EntityType::Insert(_)); + if self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle) || is_insert + { + bits |= CACHE_CATEGORY_MESH | CACHE_CATEGORY_INTERACTION; + } + if is_insert { + bits |= CACHE_CATEGORY_INSERT_HATCH; + } + if self.entity_affects_text_cache(entity) { + bits |= CACHE_CATEGORY_TEXT; + } + self.pending_removed_categories + .borrow_mut() + .insert(handle, bits); + } + + /// True when a per-category derived cache synced at `cached_epoch` remains + /// valid. Adds/edits are classified from live state; removals use the tiny + /// pre-erase category mask stored in the journal. Thus deleting a LINE no + /// longer rebuilds every hatch/image/mesh/text cache merely because its type + /// became unknowable after removal. fn category_cache_valid( &self, cached_epoch: u64, + category: u16, in_category: impl Fn(Handle) -> bool, ) -> bool { if cached_epoch == self.geometry_epoch { return true; } - match self.replay_since(cached_epoch) { - None => false, - Some(deltas) => deltas - .iter() - .all(|&(h, k)| k != ChangeKind::Removed && !in_category(h)), + if cached_epoch < self.geometry_journal_floor.get() { + return false; } + let ring = self.geometry_deltas.borrow(); + for delta in ring.iter().filter(|delta| delta.epoch > cached_epoch) { + if delta.full { + return false; + } + for &(handle, kind) in &delta.changes { + match kind { + ChangeKind::Removed => { + let Some(bits) = delta.removed_categories.get(&handle) else { + return false; + }; + if bits & category != 0 { + return false; + } + } + ChangeKind::Added | ChangeKind::Modified if in_category(handle) => { + return false; + } + ChangeKind::Added | ChangeKind::Modified => {} + } + } + } + true } /// True when no text-bearing entity changed since `last_epoch`, so cached SDF @@ -2051,49 +2270,20 @@ impl Scene { /// references (their baked text moves with the instance) — an edit to any of /// those, or any removal, invalidates it; a plain line / arc / polyline edit /// does not. - /// Whether the per-entity draw-order ranks are unchanged since - /// `last_epoch`: every delta is a Modify (an Add / Remove changes the rank - /// denominator and shifts every sibling's bias — same rule as the - /// `draw_depth_map` cache). + /// Whether the per-entity draw-order labels can be replayed since + /// `last_epoch`. Add/Remove keep every existing label stable; only a full + /// structural delta (DRAWORDER, file/layout/block rebuild, journal overflow) + /// requires consumers to regenerate their depth-bearing data. pub(super) fn draw_ranks_stable(&self, last_epoch: u64) -> bool { - match self.replay_since(last_epoch) { - Some(deltas) => deltas.iter().all(|&(_, k)| k == ChangeKind::Modified), - None => false, - } + self.replay_since(last_epoch).is_some() } fn text_unchanged(&self, last_epoch: u64) -> bool { - match self.replay_since(last_epoch) { - Some(deltas) => deltas.iter().all(|&(h, k)| { - if k == ChangeKind::Removed { - return false; - } - let Some(e) = self.document.get_entity(h) else { - return true; - }; - if matches!( - e, - EntityType::Text(_) - | EntityType::MText(_) - | EntityType::Dimension(_) - | EntityType::MultiLeader(_) - | EntityType::Leader(_) - | EntityType::Table(_) - | EntityType::Tolerance(_) - | EntityType::AttributeEntity(_) - | EntityType::AttributeDefinition(_) - | EntityType::Insert(_) - ) { - return false; - } - // Complex-linetype glyphs ride the host entity's own wire, so a - // plain line/polyline edit still moves text when its linetype - // embeds text or shapes. - let lt = crate::scene::view::render::linetype_name_for(&self.document, e); - crate::io::linetypes::resolve_complex_lt(&self.document, <).is_none() - }), - None => false, - } + self.category_cache_valid(last_epoch, CACHE_CATEGORY_TEXT, |handle| { + self.document + .get_entity(handle) + .is_some_and(|entity| self.entity_affects_text_cache(entity)) + }) } /// Report the exact handles a mutation changed. Folds the memo drop and the @@ -2134,6 +2324,22 @@ impl Scene { } } + /// Record one document.objects entry before its first mutation. This keeps + /// Group erase and RasterImage add on targeted history rather than forcing + /// a clone of all document structure. + pub(crate) fn record_undo_object_before( + &mut self, + handle: Handle, + before: Option, + ) { + if let Some(rec) = self.undo_recording.as_mut() { + if !rec.object_before.contains_key(&handle) { + rec.object_order.push(handle); + rec.object_before.insert(handle, before); + } + } + } + /// Flag the open recording as touching non-entity state (a new layer, a /// group edit, a `*D` block record) that a pure-entity delta cannot /// restore. The app's per-command predicate keeps this from firing. @@ -2183,6 +2389,7 @@ impl Scene { } None => { if existed { + self.remember_removed_cache_categories(*h); self.document.remove_entity_arc(*h); changes.push((*h, ChangeKind::Removed)); } @@ -2198,6 +2405,13 @@ impl Scene { } pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) { + if !changes.is_empty() { + // A Modified delta can change layer/style/block references as well + // as coordinates. Rebuild the reverse dependency map lazily on its + // next use so later targeted global-property updates never follow + // stale ownership. + self.invalidate_dependency_index(); + } let cached_light_changed = self.lighting_cache.borrow().as_ref().is_some_and(|lights| { changes .iter() @@ -2516,7 +2730,9 @@ impl Scene { pub fn set_refedit_keep(&mut self, keep: Option>) { self.refedit_keep = keep; self.recolor_meshes(); - self.bump_geometry(); + // Fading is applied after the raw per-entity tessellation memo is read; + // keep those expensive wires and only rebuild the resident assembly. + self.bump_geometry_no_blocks(); } /// Fade the colours of wires that belong to entities outside the REFEDIT @@ -3556,9 +3772,16 @@ impl Scene { }) .map(|entity| entity.common().handle) .collect(); + let changes: Vec<_> = hide + .iter() + .copied() + .map(|handle| (handle, ChangeKind::Modified)) + .collect(); self.object_isolation.hidden.extend(hide); self.object_isolation.keep = Some(keep); - self.bump_geometry_no_blocks(); + if !changes.is_empty() { + self.bump_entities(&changes); + } } /// Temporarily hide the current selection without changing any entity @@ -3567,11 +3790,17 @@ impl Scene { if self.selected.is_empty() { return; } + let changes: Vec<_> = self + .selected + .iter() + .copied() + .map(|handle| (handle, ChangeKind::Modified)) + .collect(); self.object_isolation .hidden .extend(self.selected.iter().copied()); self.selected.clear(); - self.bump_geometry_no_blocks(); + self.bump_entities(&changes); } /// Clear every session-only object visibility filter. @@ -3579,8 +3808,17 @@ impl Scene { if !self.object_isolation.is_active() { return; } + let changes: Vec<_> = self + .object_isolation + .hidden + .iter() + .copied() + .map(|handle| (handle, ChangeKind::Modified)) + .collect(); self.object_isolation = ObjectIsolationState::default(); - self.bump_geometry_no_blocks(); + if !changes.is_empty() { + self.bump_entities(&changes); + } } /// A newly opened/replaced document starts with no session visibility. @@ -3771,7 +4009,7 @@ impl Scene { // Skip the whole-document annotative scan when nothing annotative // changed since (PSLTSCALE toggles route through a full delta, which // fails this check and forces the recompute below). - if self.category_cache_valid(epoch, |h| { + if self.category_cache_valid(epoch, CACHE_CATEGORY_ANNOTATIVE, |h| { self.document .get_entity(h) .map(|e| crate::scene::annotative::is_annotative(&self.document, e)) @@ -3965,6 +4203,8 @@ impl Scene { Some(ResidentWireLayout { order, ranges, + vacant: HashMap::default(), + tombstoned_wires: 0, marker_start, }) } @@ -4072,6 +4312,21 @@ impl Scene { memo.insert(h, a); } } + // Capture whether any replaced/removed OLD run fed the Face3D/fill + // pass before blanking its resident slots. Treating every removal as a + // face change made deleting an ordinary line rescan/re-upload the whole + // fill pass on large drawings. + let old_face_pass_changed = deltas.iter().any(|(handle, _)| { + layout + .ranges + .get(handle) + .is_some_and(|&(start, len)| { + start + len <= layout.marker_start + && owned[start..start + len] + .iter() + .any(|wire| !wire.fill_tris.is_empty()) + }) + }); // Keep the submission-order directory current. Adds are inserted by // the same effective SortEntitiesTable key used by the full builder; @@ -4090,7 +4345,8 @@ impl Scene { }; for &(handle, kind) in &deltas { if matches!(kind, ChangeKind::Removed) { - layout.order.retain(|&h| h != handle); + // Keep the order entry as a tombstone. Undo/Redo can then + // restore the exact physical slot and submission position. continue; } if visible_changed.contains(&handle) && !layout.order.contains(&handle) { @@ -4103,9 +4359,10 @@ impl Scene { } } - // Splice only changed runs into the uniquely-owned flat Vec. Same-size - // edits overwrite in place. Grip hide/show shifts shallow WireModel - // structs once, instead of cloning/grouping all nested geometry. + // Patch changed runs without shifting the resident Vec. Delete/hide + // blanks the old slots; same-shaped Undo/Redo restores them in place. + // Only a true tail add/resize touches Vec length (and shifts the handful + // of synthesized marker wires, never the drawing's entity runs). let mut gpu_runs: HashMap>> = HashMap::default(); let mut index_edits = Vec::new(); for &(handle, kind) in &deltas { @@ -4117,54 +4374,126 @@ impl Scene { gpu_runs.insert(handle, Arc::new(new_run.clone())); let new_len = new_run.len(); let old_range = layout.ranges.remove(&handle); - if old_range.is_none() && new_len == 0 { + + if new_len == 0 { + if let Some((start, old_len)) = old_range { + if start + old_len > layout.marker_start { + return None; + } + for slot in &mut owned[start..start + old_len] { + *slot = WireModel::solid( + String::new(), + Vec::new(), + [0.0; 4], + false, + ); + slot.aabb = [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ]; + } + layout.vacant.insert(handle, (start, old_len)); + layout.tombstoned_wires = + layout.tombstoned_wires.saturating_add(old_len); + index_edits.push(WireIndexEdit { + handle, + start, + old_len, + new_len: old_len, + visible: false, + }); + } continue; } - let (start, old_len) = match old_range { - Some(range) => range, - None => { - let order_index = layout.order.iter().position(|&h| h == handle)?; - let start = layout.order[order_index + 1..] - .iter() - .find_map(|next| layout.ranges.get(next).map(|range| range.0)) - .unwrap_or(layout.marker_start); - (start, 0) - } - }; - if start + old_len > layout.marker_start || layout.marker_start > owned.len() { - return None; - } - if old_len == new_len { - for (slot, wire) in owned[start..start + old_len] + if let Some((start, vacant_len)) = layout.vacant.remove(&handle) { + if vacant_len != new_len || start + vacant_len > layout.marker_start { + return None; + } + for (slot, wire) in owned[start..start + vacant_len] .iter_mut() .zip(new_run) { *slot = wire; } - } else { + layout.ranges.insert(handle, (start, new_len)); + layout.tombstoned_wires = + layout.tombstoned_wires.saturating_sub(vacant_len); + index_edits.push(WireIndexEdit { + handle, + start, + old_len: vacant_len, + new_len, + visible: true, + }); + continue; + } + + if let Some((start, old_len)) = old_range { + if start + old_len > layout.marker_start || layout.marker_start > owned.len() { + return None; + } + if old_len == new_len { + for (slot, wire) in owned[start..start + old_len] + .iter_mut() + .zip(new_run) + { + *slot = wire; + } + layout.ranges.insert(handle, (start, new_len)); + continue; + } + // Shape-changing edits are relocation-free only for the + // terminal entity (the live-polyline case). A non-terminal + // resize takes the clean full-build fallback instead of + // shifting every following range. + if start + old_len != layout.marker_start { + return None; + } index_edits.push(WireIndexEdit { handle, start, old_len, new_len, + visible: true, }); owned.splice(start..start + old_len, new_run); let delta = new_len as isize - old_len as isize; - for range in layout.ranges.values_mut() { - if range.0 >= start + old_len { - range.0 = (range.0 as isize + delta) as usize; - } - } layout.marker_start = (layout.marker_start as isize + delta) as usize; - } - if new_len != 0 { layout.ranges.insert(handle, (start, new_len)); + continue; } + + // A genuinely new handle must append after all live entity runs. + // Monotonic document handles make this the ordinary Add path. A + // middle insertion (custom sort key) falls back to a full rebuild. + let order_index = layout.order.iter().position(|&h| h == handle)?; + let insertion = layout.order[order_index + 1..] + .iter() + .find_map(|next| layout.ranges.get(next).map(|range| range.0)) + .unwrap_or(layout.marker_start); + if insertion != layout.marker_start { + return None; + } + index_edits.push(WireIndexEdit { + handle, + start: insertion, + old_len: 0, + new_len, + visible: true, + }); + owned.splice(insertion..insertion, new_run); + layout.marker_start += new_len; + layout.ranges.insert(handle, (insertion, new_len)); } if !new_runs.is_empty() { return None; } + if layout.tombstoned_wires > layout.marker_start / 2 { + return None; + } let added: HashSet = deltas .iter() .filter_map(|&(handle, kind)| { @@ -4181,13 +4510,13 @@ impl Scene { break; } } - let face_pass_changed = deltas.iter().any(|&(handle, kind)| { - matches!(kind, ChangeKind::Removed) - || matches!(self.document.get_entity(handle), Some(EntityType::Face3D(_))) + let face_pass_changed = old_face_pass_changed + || deltas.iter().any(|&(handle, _)| { + matches!(self.document.get_entity(handle), Some(EntityType::Face3D(_))) || gpu_runs.get(&handle).is_some_and(|run| { run.iter().any(|wire| !wire.fill_tris.is_empty()) }) - }); + }); let arc = Arc::new(owned); self.last_tess_wires.set(arc.len()); @@ -4324,11 +4653,10 @@ impl Scene { self.entity_wires_arc() } - /// Per-entity normalized draw-order depth, keyed by entity handle value. - /// Built (and cached per geometry epoch) by ranking every entity within - /// its owning block by effective sort key (SortEntitiesTable override or - /// own handle). The result feeds the 2D pipelines as a clip-z bias so - /// entities of different types order correctly against each other. + /// Per-entity stable draw-order depth, keyed by entity handle value. + /// A full build assigns sparse labels in effective draw order. Incremental + /// Add/Remove then changes only the named handle: existing siblings retain + /// their depth, avoiding an O(all entities) map rewrite and GPU const upload. pub(super) fn draw_depth_map(&self) -> Arc> { { let cache = self.draw_depth_cache.borrow(); @@ -4341,8 +4669,9 @@ impl Scene { let perf = crate::perf::enabled(); let t_depth = iced::time::Instant::now(); - // Replay Add/Remove into the retained block order. This avoids rescanning - // every document entity and re-sorting every block for one new LINE. + // Replay Add/Remove into the retained sparse order directory. Removed + // entries stay in `blocks`/`owners` as tombstones, so Undo/Redo restores + // their exact prior label without shifting a sibling. let stale_cache = self.draw_depth_cache.borrow_mut().take(); if let Some(mut cache) = stale_cache { if let Some(deltas) = self.replay_since(cache.epoch) { @@ -4356,19 +4685,13 @@ impl Scene { return arc; } - let mut depths = (*cache.depths).clone(); - let mut affected: HashSet = HashSet::default(); + let depths = Arc::make_mut(&mut cache.depths); let ms = self.model_space_block_handle(); + let mut incremental_ok = true; for &(handle, kind) in &deltas { match kind { ChangeKind::Modified => {} ChangeKind::Removed => { - if let Some(block) = cache.owners.remove(&handle.value()) { - if let Some(order) = cache.blocks.get_mut(&block) { - order.retain(|(value, _)| *value != handle.value()); - } - affected.insert(block); - } depths.remove(&handle.value()); } ChangeKind::Added => { @@ -4394,42 +4717,72 @@ impl Scene { common.owner_handle }; let value = handle.value(); + if let Some(&known_block) = cache.owners.get(&value) { + if known_block != block { + incremental_ok = false; + break; + } + let restored = cache + .blocks + .get(&block) + .and_then(|order| { + order.iter().find(|entry| entry.handle == value) + }); + let Some(entry) = restored else { + incremental_ok = false; + break; + }; + depths.insert( + value, + draw_depth_value(entry.label, entry.half_label), + ); + continue; + } // A newly allocated handle cannot already have a // SortEntitiesTable override; its effective key is // therefore its own handle. let order = cache.blocks.entry(block).or_default(); - let position = - order.partition_point(|(_, effective)| *effective <= value); - order.insert(position, (value, value)); + let position = order.partition_point(|entry| { + (entry.effective, entry.handle) <= (value, value) + }); + let Some((label, half_label)) = + inserted_draw_depth_label(order, position) + else { + // Pathological middle insertion exhausted the + // reserved label gap. A full rebuild is the safe + // rare fallback (ordinary monotonic adds use the + // two-million-slot tail reserve). + incremental_ok = false; + break; + }; + order.insert( + position, + DrawDepthEntry { + handle: value, + effective: value, + label, + half_label, + }, + ); cache.owners.insert(value, block); - affected.insert(block); + depths.insert(value, draw_depth_value(label, half_label)); } } } - for block in affected { - let Some(order) = cache.blocks.get(&block) else { - continue; - }; - let denom = order.len() as f32 + 1.0; - let half = 1.0 / denom; - for (rank, (value, _)) in order.iter().enumerate() { - let norm = (rank as f32 + 1.0) / denom; - depths.insert(*value, [(norm - 0.5) * 2.0, half]); + if incremental_ok { + cache.epoch = self.geometry_epoch; + let arc = Arc::clone(&cache.depths); + *self.draw_depth_cache.borrow_mut() = Some(cache); + if perf { + crate::perf_record!( + "[perf] draw-depth-patch {:>7.1}ms entries={} changes={}", + t_depth.elapsed().as_secs_f64() * 1000.0, + arc.len(), + deltas.len(), + ); } + return arc; } - let arc = Arc::new(depths); - cache.epoch = self.geometry_epoch; - cache.depths = Arc::clone(&arc); - *self.draw_depth_cache.borrow_mut() = Some(cache); - if perf { - crate::perf_record!( - "[perf] draw-depth-patch {:>7.1}ms entries={} changes={}", - t_depth.elapsed().as_secs_f64() * 1000.0, - arc.len(), - deltas.len(), - ); - } - return arc; } } @@ -4480,31 +4833,26 @@ impl Scene { .unwrap_or(hv); by_block.entry(block).or_default().push((hv, eff)); } - for order in by_block.values_mut() { - order.sort_by_key(|(_, effective)| *effective); - } let mut depth_map: HashMap = HashMap::default(); let mut owners: HashMap = HashMap::default(); - for (&block, order) in &by_block { - let denom = (order.len() as f32) + 1.0; - // Adjacent ranks are 2/denom apart; a child sub-range of - // ±half = ±1/denom around the parent depth never crosses them. - let half = 1.0 / denom; - for (rank, (hv, _)) in order.iter().enumerate() { - // Signed (-1,1): back ranks → negative, front → positive, - // mid → ~0. The shader applies `z -= draw_depth * BIAS`, so a - // default/unranked 0.0 means "no bias" (neutral) — which keeps - // 3D mesh faces and transient wires at their real depth. - let norm = (rank as f32 + 1.0) / denom; // (0,1) - depth_map.insert(*hv, [(norm - 0.5) * 2.0, half]); - owners.insert(*hv, block); + let mut blocks: HashMap> = HashMap::default(); + for (block, mut order) in by_block { + order.sort_by_key(|(handle, effective)| (*effective, *handle)); + let entries = seed_draw_depth_entries(order); + for entry in &entries { + depth_map.insert( + entry.handle, + draw_depth_value(entry.label, entry.half_label), + ); + owners.insert(entry.handle, block); } + blocks.insert(block, entries); } let arc = Arc::new(depth_map); *self.draw_depth_cache.borrow_mut() = Some(DrawDepthCache { epoch: self.geometry_epoch, depths: Arc::clone(&arc), - blocks: by_block, + blocks, owners, }); if perf { @@ -4524,7 +4872,7 @@ impl Scene { // rebuild every hatch model: an O(N-hatch) stutter on hatch-heavy // drawings. Key on a signature of `selected` instead, so hover (which // never changes `selected`) keeps the cache warm. - let sel_sig = self.selected_set_sig(); + let sel_sig = self.selected_hatch_sig(); let space = self.current_layout.clone(); { let reuse = { @@ -4534,9 +4882,13 @@ impl Scene { // match; category = a changed handle that is a hatch/solid fill. Some((cached_epoch, cached_sel, arc)) if *cached_sel == sel_sig - && self.category_cache_valid(*cached_epoch, |h| { + && self.category_cache_valid( + *cached_epoch, + CACHE_CATEGORY_HATCH, + |h| { self.hatches.contains_key(&h) - }) => + }, + ) => { Some(Arc::clone(arc)) } @@ -4569,6 +4921,20 @@ impl Scene { sig } + fn selected_hatch_sig(&self) -> u64 { + let mut sig = 0u64; + let mut count = 0u64; + for h in self + .selected + .iter() + .filter(|handle| self.hatches.contains_key(handle)) + { + count += 1; + sig ^= h.value().wrapping_mul(0x9E37_79B9_7F4A_7C15); + } + sig ^ count + } + pub(super) fn wipeout_models_arc(&self) -> Arc> { let space = self.current_layout.clone(); { @@ -4578,12 +4944,16 @@ impl Scene { Some((cached_epoch, arc)) // wipeout_models scans the whole document for Wipeout // entities; relevance = the changed handle is a Wipeout. - if self.category_cache_valid(*cached_epoch, |h| { - matches!( - self.document.get_entity(h), - Some(EntityType::Wipeout(_)) - ) - }) => + if self.category_cache_valid( + *cached_epoch, + CACHE_CATEGORY_WIPEOUT, + |h| { + matches!( + self.document.get_entity(h), + Some(EntityType::Wipeout(_)) + ) + }, + ) => { Some(Arc::clone(arc)) } @@ -4610,9 +4980,11 @@ impl Scene { let cache = self.image_cache.borrow(); match *cache { Some((cached_epoch, ref arc)) - if self.category_cache_valid(cached_epoch, |h| { - self.images.contains_key(&h) - }) => + if self.category_cache_valid( + cached_epoch, + CACHE_CATEGORY_IMAGE, + |h| self.images.contains_key(&h), + ) => { Some(Arc::clone(arc)) } @@ -4694,13 +5066,17 @@ impl Scene { // also invalidate. Block-definition edits route through // bump_geometry (a full delta) and invalidate regardless. Some((cached_epoch, arc)) - if self.category_cache_valid(*cached_epoch, |h| { - self.meshes.contains_key(&h) - || matches!( - self.document.get_entity(h), - Some(EntityType::Insert(_)) - ) - }) => + if self.category_cache_valid( + *cached_epoch, + CACHE_CATEGORY_MESH, + |h| { + self.meshes.contains_key(&h) + || matches!( + self.document.get_entity(h), + Some(EntityType::Insert(_)) + ) + }, + ) => { Some(Arc::clone(arc)) } @@ -4930,7 +5306,7 @@ impl Scene { } let sig = Self::frozen_layers_sig(frozen); let key = (self.current_layout.clone(), sig); - let sel = self.selected_set_sig(); + let sel = self.selected_hatch_sig(); if let Some((e, s, arc)) = self.frozen_hatch_cache.borrow().get(&key) { if *e == self.geometry_epoch && *s == sel { return Arc::clone(arc); @@ -5501,8 +5877,26 @@ impl Scene { { let c = self.insert_hatch_cache.borrow(); if let Some((epoch, cached_space, ref arc)) = *c { - if epoch == self.geometry_epoch && cached_space == space_key { - return Arc::clone(arc); + if cached_space == space_key + && self.category_cache_valid( + epoch, + CACHE_CATEGORY_INSERT_HATCH, + |handle| { + matches!( + self.document.get_entity(handle), + Some(EntityType::Insert(_)) + ) + }, + ) + { + let arc = Arc::clone(arc); + drop(c); + if let Some((cached_epoch, _, _)) = + self.insert_hatch_cache.borrow_mut().as_mut() + { + *cached_epoch = self.geometry_epoch; + } + return arc; } } } @@ -6016,7 +6410,10 @@ impl Scene { match cache.as_ref() { Some((epoch, cached_space, index)) if *cached_space == space_key - && self.category_cache_valid(*epoch, |handle| { + && self.category_cache_valid( + *epoch, + CACHE_CATEGORY_INTERACTION, + |handle| { self.hatches.contains_key(&handle) || self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle) @@ -6024,7 +6421,8 @@ impl Scene { self.document.get_entity(handle), Some(EntityType::Insert(_)) ) - }) => + }, + ) => { Some(Arc::clone(index)) } @@ -7166,6 +7564,35 @@ impl Scene { } else { std::iter::once(common.handle).collect() }; + let extend_category = |target: &mut DependencyTargets| { + target.render_handles.extend(render_handles.iter().copied()); + target.source_handles.insert(common.handle); + target.touches_block_definition |= inside_block; + }; + if matches!(entity, EntityType::Point(_)) { + extend_category(&mut index.points); + } + if matches!( + entity, + EntityType::Text(_) + | EntityType::MText(_) + | EntityType::AttributeDefinition(_) + | EntityType::AttributeEntity(_) + | EntityType::Dimension(_) + | EntityType::Leader(_) + | EntityType::Tolerance(_) + | EntityType::MultiLeader(_) + | EntityType::Table(_) + | EntityType::Shape(_) + ) || matches!(entity, EntityType::Insert(insert) if !insert.attributes.is_empty()) + { + extend_category(&mut index.text_geometry); + } + if matches!(entity, EntityType::Hatch(_)) + || crate::scene::annotative::is_annotative(&self.document, entity) + { + extend_category(&mut index.annotation_geometry); + } let add = |map: &mut HashMap, name: &str| { let target = map.entry(name.to_ascii_uppercase()).or_default(); target.render_handles.extend(render_handles.iter().copied()); @@ -7303,6 +7730,35 @@ impl Scene { self.invalidate_dependency_targets(combined); } + fn invalidate_dependency_category( + &mut self, + select: impl FnOnce(&SceneDependencyIndex) -> &DependencyTargets, + ) { + if self.dependency_index_cache.borrow().is_none() { + *self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index()); + } + let targets = self + .dependency_index_cache + .borrow() + .as_ref() + .map(select) + .cloned() + .unwrap_or_default(); + self.invalidate_dependency_targets(targets); + } + + pub fn invalidate_point_dependencies(&mut self) { + self.invalidate_dependency_category(|index| &index.points); + } + + pub fn invalidate_text_geometry_dependencies(&mut self) { + self.invalidate_dependency_category(|index| &index.text_geometry); + } + + pub fn invalidate_annotation_dependencies(&mut self) { + self.invalidate_dependency_category(|index| &index.annotation_geometry); + } + pub(crate) fn invalidate_dependency_index(&self) { self.dependency_index_cache.borrow_mut().take(); } @@ -7759,6 +8215,110 @@ mod journal_tests { assert!(s.replay_since(e0).is_none(), "a spanned full delta ⇒ None"); } + #[test] + fn insert_add_and_update_publish_targeted_deltas() { + use acadrust::entities::Insert; + use acadrust::types::Vector3; + + let mut s = Scene::new(); + let before_add = s.geometry_epoch; + let handle = s.add_entity(EntityType::Insert(Insert::new( + "EXISTING_BLOCK", + Vector3::new(0.0, 0.0, 0.0), + ))); + assert_eq!( + as_map(s.replay_since(before_add)).unwrap().get(&handle), + Some(&ChangeKind::Added), + "adding an INSERT must not force a whole-drawing rebuild" + ); + + let before_update = s.geometry_epoch; + let mut edited = s.document.get_entity(handle).cloned().unwrap(); + if let EntityType::Insert(insert) = &mut edited { + insert.insert_point.x = 25.0; + } + assert!(s.update_entity(edited)); + assert_eq!( + as_map(s.replay_since(before_update)).unwrap().get(&handle), + Some(&ChangeKind::Modified), + "retargeting or moving an INSERT changes only its render handle" + ); + } + + #[test] + fn dependency_categories_invalidate_only_affected_render_handles() { + use acadrust::entities::{Line, MText, Point, Text}; + use acadrust::types::Vector3; + + let mut s = Scene::new(); + let line = s.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ))); + let point = s.add_entity(EntityType::Point(Point::new())); + let text = s.add_entity(EntityType::Text(Text::with_value( + "plain", + Vector3::new(0.0, 2.0, 0.0), + ))); + let mut annotative = MText::new(); + annotative.value = "annotative".to_string(); + annotative.is_annotative = true; + let annotative = s.add_entity(EntityType::MText(annotative)); + + let before_points = s.geometry_epoch; + s.invalidate_point_dependencies(); + let points = as_map(s.replay_since(before_points)).unwrap(); + assert_eq!(points.len(), 1); + assert_eq!(points.get(&point), Some(&ChangeKind::Modified)); + + let before_text = s.geometry_epoch; + s.invalidate_text_geometry_dependencies(); + let texts = as_map(s.replay_since(before_text)).unwrap(); + assert_eq!(texts.get(&text), Some(&ChangeKind::Modified)); + assert_eq!(texts.get(&annotative), Some(&ChangeKind::Modified)); + assert!(!texts.contains_key(&line)); + assert!(!texts.contains_key(&point)); + + let before_annotation = s.geometry_epoch; + s.invalidate_annotation_dependencies(); + let annotation = as_map(s.replay_since(before_annotation)).unwrap(); + assert_eq!(annotation.len(), 1); + assert_eq!( + annotation.get(&annotative), + Some(&ChangeKind::Modified) + ); + } + + #[test] + fn object_visibility_uses_entity_deltas() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + let mut s = Scene::new(); + let hidden = s.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + let untouched = s.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 1.0, 0.0), + Vector3::new(1.0, 1.0, 0.0), + ))); + + s.selected.insert(hidden); + let before_hide = s.geometry_epoch; + s.hide_selected(); + let hide = as_map(s.replay_since(before_hide)).unwrap(); + assert_eq!(hide.len(), 1); + assert_eq!(hide.get(&hidden), Some(&ChangeKind::Modified)); + assert!(!hide.contains_key(&untouched)); + + let before_show = s.geometry_epoch; + s.end_isolation(); + let show = as_map(s.replay_since(before_show)).unwrap(); + assert_eq!(show.len(), 1); + assert_eq!(show.get(&hidden), Some(&ChangeKind::Modified)); + } + #[test] fn ring_overflow_forces_rebuild() { let mut s = Scene::new(); @@ -7846,11 +8406,16 @@ mod journal_tests { Vector3::new(b.0, b.1, 0.0), )) } - // Wire names, in order — captures entity attribution + draw order + count. + // Visible wire names, in order. Resident erase deliberately retains + // blank tombstone slots, which are not part of a from-scratch build. fn resident_names(s: &Scene) -> Vec { let cam = Camera::default(); let arc = s.model_tile_wires_arc(0, &cam, 1.0, 1.0); - let names: Vec = arc.iter().map(|w| w.name.clone()).collect(); + let names: Vec = arc + .iter() + .filter(|wire| Scene::handle_from_wire_name(&wire.name).is_some()) + .map(|wire| wire.name.clone()) + .collect(); drop(arc); // release so the next patch can move the wires out names } @@ -7862,6 +8427,7 @@ mod journal_tests { let mut s = Scene::new(); let _h1 = s.add_entity(line((0.0, 0.0), (10.0, 10.0))); let h2 = s.add_entity(line((5.0, 5.0), (20.0, 3.0))); + let h2_image = s.document.get_entity_arc(h2).unwrap(); let _ = resident_names(&s); // prime the resident cache let hits0 = s.resident_patch_hits.get(); @@ -7880,17 +8446,101 @@ mod journal_tests { "resident after modify" ); + let original_h2_start = s + .resident_wire_sets + .borrow() + .values() + .find_map(|set| { + set.layout + .as_ref() + .and_then(|layout| layout.ranges.get(&h2).map(|range| range.0)) + }) + .unwrap(); + let before_erase_names = resident_names(&s); s.erase_entities(&[h2]); - assert_eq!(resident_names(&s), from_scratch(&s), "resident after erase"); + let patched = resident_names(&s); + let erase_gen = s.last_model_wire_gen.get(); + assert!( + s.model_wire_patch_for(erase_gen) + .is_some_and(|(_, patch)| !patch.face_pass_changed), + "removing a plain line must keep the unrelated Face3D/fill pass warm" + ); + assert!( + s.resident_wire_sets.borrow().values().any(|set| { + set.layout + .as_ref() + .is_some_and(|layout| layout.vacant.contains_key(&h2)) + }), + "erase should retain h2's physical range as a tombstone" + ); + let expected_erased: Vec<_> = before_erase_names + .iter() + .filter(|name| *name != &h2.value().to_string()) + .cloned() + .collect(); + assert_eq!(patched, expected_erased, "resident after erase"); + + // Replay the same Added delta history Undo emits. The entity must return + // to its original physical range instead of appending/shifting the set. + let restored = s.apply_entity_delta( + &[(h2, Some(Arc::clone(&h2_image)), None)], + true, + ); + for &(handle, _) in &restored { + s.reseed_derived_caches(handle); + } + s.bump_entities(&restored); + let restored_names = resident_names(&s); + assert_eq!(restored_names, before_erase_names); + assert!(s.resident_wire_sets.borrow().values().any(|set| { + set.layout.as_ref().is_some_and(|layout| { + layout.ranges.get(&h2).map(|range| range.0) + == Some(original_h2_start) + && !layout.vacant.contains_key(&h2) + }) + })); assert_eq!( s.resident_patch_hits.get(), - hits0 + 3, - "every one of the add / modify / erase edits must use the incremental \ + hits0 + 4, + "every add / modify / erase / restore edit must use the incremental \ patch, not silently fall back to a full rebuild" ); } + #[test] + fn draw_depth_add_and_erase_keep_existing_labels_stable() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + let line = |y: f64| { + EntityType::Line(Line::from_points( + Vector3::new(0.0, y, 0.0), + Vector3::new(10.0, y, 0.0), + )) + }; + let mut s = Scene::new(); + let h1 = s.add_entity(line(1.0)); + let h2 = s.add_entity(line(2.0)); + let initial = s.draw_depth_map(); + let d1 = initial[&h1.value()]; + let d2 = initial[&h2.value()]; + drop(initial); + + let h3 = s.add_entity(line(3.0)); + let after_add = s.draw_depth_map(); + assert_eq!(after_add[&h1.value()], d1); + assert_eq!(after_add[&h2.value()], d2); + let d3 = after_add[&h3.value()]; + drop(after_add); + + s.erase_entities(&[h2]); + let after_erase = s.draw_depth_map(); + assert_eq!(after_erase[&h1.value()], d1); + assert_eq!(after_erase[&h3.value()], d3); + assert!(!after_erase.contains_key(&h2.value())); + } + #[test] fn category_gate_keeps_unrelated_caches_warm() { let mut s = Scene::new(); @@ -7898,16 +8548,94 @@ mod journal_tests { // Editing handles NOT in the (empty) hatch category leaves it valid. s.bump_entities(&[(h(40), ChangeKind::Modified)]); assert!( - s.category_cache_valid(e0, |hh| s.hatches.contains_key(&hh)), + s.category_cache_valid(e0, CACHE_CATEGORY_HATCH, |hh| { + s.hatches.contains_key(&hh) + }), "an edit outside the category must keep it warm" ); - // A removal is conservatively treated as possibly-relevant. + // A caller that bypasses the tracked erase primitive supplies no + // before-category hint, so removal remains conservatively invalid. s.bump_entities(&[(h(41), ChangeKind::Removed)]); assert!( - !s.category_cache_valid(e0, |hh| s.hatches.contains_key(&hh)), - "a removal must invalidate (category unknowable post-erase)" + !s.category_cache_valid(e0, CACHE_CATEGORY_HATCH, |hh| { + s.hatches.contains_key(&hh) + }), + "an unclassified removal must invalidate" ); } + + #[test] + fn plain_geometry_keeps_insert_hatch_cache_warm() { + use acadrust::entities::{Insert, Line}; + use acadrust::types::Vector3; + + let mut s = Scene::new(); + let initial = s.insert_hatches_for_click(); + s.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + let after_line = s.insert_hatches_for_click(); + assert!( + Arc::ptr_eq(&initial, &after_line), + "a line edit must not rescan and explode every block insert" + ); + + let insert = s.add_entity(EntityType::Insert(Insert::new( + "EXISTING_BLOCK", + Vector3::new(0.0, 0.0, 0.0), + ))); + let after_insert = s.insert_hatches_for_click(); + assert!(!Arc::ptr_eq(&after_line, &after_insert)); + + s.erase_entities(&[insert]); + let after_erase = s.insert_hatches_for_click(); + assert!(!Arc::ptr_eq(&after_insert, &after_erase)); + } + + #[test] + fn selecting_plain_geometry_keeps_hatch_models_warm() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + let mut s = Scene::new(); + let line = s.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + let before = s.hatch_models_arc(); + s.select_entity(line, false); + let after = s.hatch_models_arc(); + assert!( + Arc::ptr_eq(&before, &after), + "selecting a non-hatch must not rebuild every hatch model" + ); + } + + #[test] + fn plain_line_erase_keeps_unrelated_category_and_text_caches_warm() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + let mut s = Scene::new(); + let line = s.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + let cached_epoch = s.geometry_epoch; + s.erase_entities(&[line]); + + assert!(s.category_cache_valid(cached_epoch, CACHE_CATEGORY_HATCH, |h| { + s.hatches.contains_key(&h) + })); + assert!(s.category_cache_valid(cached_epoch, CACHE_CATEGORY_IMAGE, |h| { + s.images.contains_key(&h) + })); + assert!(s.category_cache_valid(cached_epoch, CACHE_CATEGORY_MESH, |h| { + s.meshes.contains_key(&h) + })); + assert!(s.text_unchanged(cached_epoch)); + } } /// Delta-undo round-trips: a recorded entity-only edit must be exactly @@ -8043,6 +8771,61 @@ mod delta_undo_tests { assert_eq!(ms_occurrences(&scene, h), 1); } + #[test] + fn raster_add_records_only_its_image_definition_object() { + use acadrust::entities::RasterImage; + use acadrust::objects::ObjectType; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + let image = RasterImage::with_size( + "test.png", + Vector3::new(0.0, 0.0, 0.0), + 16.0, + 16.0, + 10.0, + 10.0, + ); + scene.begin_undo_recording(); + let handle = scene.add_entity(EntityType::RasterImage(image)); + let rec = scene.take_undo_recording().unwrap(); + assert!(!rec.is_poisoned()); + let (entities, objects) = rec.into_recorded_images(); + assert_eq!(entities.len(), 1); + assert_eq!(entities[0].0, handle); + assert_eq!(objects.len(), 1); + assert!(objects[0].1.is_none()); + assert!(matches!( + scene.document.objects.get(&objects[0].0), + Some(ObjectType::ImageDefinition(_)) + )); + } + + #[test] + fn grouped_erase_records_group_object_without_poisoning() { + use acadrust::objects::ObjectType; + + let mut scene = Scene::new(); + let h1 = scene.add_entity(line(0.0, 0.0, 1.0, 0.0)); + let h2 = scene.add_entity(line(2.0, 0.0, 3.0, 0.0)); + let group = scene.create_group("G".to_string(), vec![h1, h2]); + + scene.begin_undo_recording(); + scene.erase_entities(&[h1]); + let rec = scene.take_undo_recording().unwrap(); + assert!(!rec.is_poisoned()); + let (entities, objects) = rec.into_recorded_images(); + assert_eq!(entities.len(), 1); + assert_eq!(entities[0].0, h1); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].0, group); + assert!(matches!(objects[0].1, Some(ObjectType::Group(_)))); + assert!(matches!( + scene.document.objects.get(&group), + Some(ObjectType::Group(current)) if current.entities == vec![h2] + )); + } + #[test] fn copy_delta_round_trips() { let mut scene = Scene::new(); diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 17b20299c..5fa778e91 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -410,12 +410,9 @@ impl Scene { } } - let copied_groups = self.copy_complete_groups(&handle_map); - if copied_groups > 0 && self.is_recording_undo() { - // Group copies add Group objects / dictionary entries, which a pure - // entity delta cannot restore. - self.poison_undo_recording(); - } + // Complete group copies record their new Group objects and dictionary + // entry as targeted object deltas inside copy_complete_groups. + self.copy_complete_groups(&handle_map); // The copies are new handles (natural memo misses, tessellated fresh) // and reference only already-cached blocks — no block defn changes. // Report them as additions so derived caches patch in exactly the copies. diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 4b72b5f54..218b58efb 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -196,22 +196,55 @@ impl Scene { Arc>, Arc>, ) { - let selected = self.selected_set_sig(); - { + let selected = self.selected_hatch_sig(); + let reuse = { let cache = self.paper_sheet_render_cache.borrow(); if let Some(cache) = cache.get(&self.current_layout) { - if cache.epoch == self.geometry_epoch - && cache.layout == self.current_layout + if cache.layout == self.current_layout && cache.selected == selected && cache.paper_bg == self.paper_bg_color + && self.category_cache_valid( + cache.epoch, + super::CACHE_CATEGORY_HATCH, + |handle| self.hatches.contains_key(&handle), + ) + && self.category_cache_valid( + cache.epoch, + super::CACHE_CATEGORY_WIPEOUT, + |handle| { + matches!( + self.document.get_entity(handle), + Some(EntityType::Wipeout(_)) + ) + }, + ) + && self.category_cache_valid( + cache.epoch, + super::CACHE_CATEGORY_IMAGE, + |handle| self.images.contains_key(&handle), + ) { - return ( + Some(( Arc::clone(&cache.hatches), Arc::clone(&cache.wipeouts), Arc::clone(&cache.images), - ); + )) + } else { + None } + } else { + None } + }; + if let Some(models) = reuse { + if let Some(cache) = self + .paper_sheet_render_cache + .borrow_mut() + .get_mut(&self.current_layout) + { + cache.epoch = self.geometry_epoch; + } + return models; } let mut hatches = Vec::new(); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index fb7c16989..0c84cc171 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -232,6 +232,13 @@ pub struct Pipeline { /// ranges from the resident arena. #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_cull_key: (u64, u64, u32, u32), + /// View/source keys for CPU visibility passes. A plain entity edit changes + /// the scene render signature, but it must not rescan every unchanged hatch, + /// wipeout, or mesh AABB when the camera and corresponding source stayed + /// identical. + pub(crate) hatch_lod_key: (usize, u64, u32, u32, bool), + pub(crate) wipeout_lod_key: (usize, u64, u32, u32, bool), + pub(crate) mesh_lod_key: (usize, u64, u32, u32), /// This content viewport's non-rectangular clip boundary as a triangle-fan /// vertex buffer in the render target's normalized device coords (`None` = /// rectangular / unclipped, where the viewport's own render rectangle does @@ -292,7 +299,7 @@ pub struct Pipeline { pub cached_mesh_source: Option>>, pub cached_face3d_source: Option>>, pub cached_face3d_depth_source: - Option>>, + 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 @@ -1812,6 +1819,9 @@ impl Pipeline { wire_arena_id: u64::MAX, #[cfg(not(target_arch = "wasm32"))] wire_cull_key: (u64::MAX, u64::MAX, 0, 0), + hatch_lod_key: (usize::MAX, u64::MAX, 0, 0, false), + wipeout_lod_key: (usize::MAX, u64::MAX, 0, 0, false), + mesh_lod_key: (usize::MAX, u64::MAX, 0, 0), clip_boundary: None, gpu_selected_wires: vec![], gpu_preview_wires: vec![], @@ -1928,36 +1938,64 @@ impl Pipeline { hover: Option, depth_map: &rustc_hash::FxHashMap, ) { + let perf_started = crate::perf::enabled().then(iced::time::Instant::now); let hover = hover.filter(|h| !selected.contains(h)); if selected.is_empty() && hover.is_none() { self.gpu_selected_wires = vec![]; return; } - // Gather the highlighted entities' wires via the prebuilt index — - // O(highlighted), no per-wire string parse. Selection recolours blue, - // hover orange. Drawn on top (depth-compare Always) over the base pass. - let mut out: Vec = Vec::new(); - let push = |handle_val: u64, color: [f32; 4], wires: &[WireModel], out: &mut Vec| { - if let Some(idxs) = self.wire_handle_index.get(&handle_val) { + // Gather borrowed highlighted wires via the prebuilt index — + // O(highlighted), no per-wire string parse or deep geometry clone. + let mut selected_wires: Vec<&WireModel> = Vec::new(); + let mut hover_wires: Vec<&WireModel> = Vec::new(); + for h in selected { + if let Some(idxs) = self.wire_handle_index.get(&h.value()) { let mut slots = idxs.clone(); slots.sort_unstable(); for &i in &slots { if let Some(w) = wires.get(i as usize) { - let mut c = w.clone(); - c.color = color; - out.push(c); + selected_wires.push(w); } } } - }; - for h in selected { - push(h.value(), WireModel::SELECTED, wires, &mut out); } if let Some(h) = hover { - push(h.value(), WireModel::HOVER, wires, &mut out); + if let Some(idxs) = self.wire_handle_index.get(&h.value()) { + let mut slots = idxs.clone(); + slots.sort_unstable(); + for &i in &slots { + if let Some(w) = wires.get(i as usize) { + hover_wires.push(w); + } + } + } + } + let mut gpu = WireGpu::from_highlight_refs( + device, + &selected_wires, + WireModel::SELECTED, + depth_map, + self.wire_const_bgl.as_ref(), + ); + gpu.extend(WireGpu::from_highlight_refs( + device, + &hover_wires, + WireModel::HOVER, + depth_map, + self.wire_const_bgl.as_ref(), + )); + self.gpu_selected_wires = gpu; + if let Some(started) = perf_started { + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + if elapsed_ms >= 1.0 { + crate::perf_record!( + "[perf] wire-highlight {:>7.1}ms handles={} wires={}", + elapsed_ms, + selected.len() + usize::from(hover.is_some()), + selected_wires.len() + hover_wires.len(), + ); + } } - self.gpu_selected_wires = - WireGpu::from_run(device, &out, depth_map, false, self.wire_const_bgl.as_ref()); } /// Build the text-highlight overlay: the glyph quads of just the selected / @@ -1971,6 +2009,7 @@ impl Pipeline { selected: &rustc_hash::FxHashSet, hover: Option, ) { + let perf_started = crate::perf::enabled().then(iced::time::Instant::now); let hover = hover.filter(|h| !selected.contains(h)); if selected.is_empty() && hover.is_none() { self.text_highlight_vbuf = None; @@ -2001,6 +2040,16 @@ impl Pipeline { } self.text_highlight_vcount = out.len() as u32; self.text_highlight_vbuf = text_gpu::upload_vertices(device, &out); + if let Some(started) = perf_started { + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + if elapsed_ms >= 1.0 { + crate::perf_record!( + "[perf] text-highlight {:>7.1}ms vertices={}", + elapsed_ms, + out.len(), + ); + } + } } /// Upload the live overlay (command preview / interim / grip-drag) wires. @@ -2297,6 +2346,7 @@ impl Pipeline { clip_w: u32, clip_h: u32, ) { + let perf_started = crate::perf::enabled().then(iced::time::Instant::now); let Some(batch) = &mut self.gpu_hatch else { return; }; @@ -2306,6 +2356,16 @@ impl Pipeline { batch.visibility[i] = if skip { 0 } else { 1 }; } batch.upload_visibility(queue); + if let Some(started) = perf_started { + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + if elapsed_ms >= 1.0 { + crate::perf_record!( + "[perf] hatch-lod {:>7.1}ms instances={}", + elapsed_ms, + batch.instance_aabbs.len(), + ); + } + } } /// Per-frame wipeout frustum-skip flag (Phase 2.3). Mirrors @@ -2331,6 +2391,7 @@ impl Pipeline { wireframe_only: bool, depth_map: &rustc_hash::FxHashMap, ) { + let perf_started = crate::perf::enabled().then(iced::time::Instant::now); // Edge buffer is always built from `face3d_wires`, so 3DFACE // outlines stay on the screen regardless of mode. self.gpu_face3d_edges = @@ -2359,6 +2420,17 @@ impl Pipeline { depth_map, )); } + if let Some(started) = perf_started { + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + if elapsed_ms >= 1.0 { + crate::perf_record!( + "[perf] face3d-upload {:>7.1}ms face-wires={} all-wires={}", + elapsed_ms, + face3d_wires.len(), + all_wires.len(), + ); + } + } } /// Build the batched mesh buffers (a few large buffers for the whole solid @@ -2773,6 +2845,7 @@ impl Pipeline { queue: &wgpu::Queue, hatches: &[HatchModel], ) { + let perf_started = crate::perf::enabled().then(iced::time::Instant::now); if let Some(bgl1) = &self.hatch_compat_bgl1 { self.gpu_hatches_compat = hatches .iter() @@ -2792,6 +2865,16 @@ impl Pipeline { let renderable: Vec = hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect(); self.gpu_hatch = hatch_gpu::HatchGpu::build(device, bgl1, &renderable); + if let Some(started) = perf_started { + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + if elapsed_ms >= 1.0 { + crate::perf_record!( + "[perf] hatch-upload {:>7.1}ms models={}", + elapsed_ms, + renderable.len(), + ); + } + } } pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) { @@ -2823,6 +2906,7 @@ impl Pipeline { queue: &wgpu::Queue, verts: &[text_gpu::TextVertex], ) { + let perf_started = crate::perf::enabled().then(iced::time::Instant::now); if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() { if self.text_atlas_gpu.is_none() || atlas.is_dirty() { self.text_atlas_gpu = Some(text_gpu::TextAtlasGpu::upload( @@ -2836,6 +2920,16 @@ impl Pipeline { } self.text_vbuf = text_gpu::upload_vertices(device, verts); self.text_vcount = verts.len() as u32; + if let Some(started) = perf_started { + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + if elapsed_ms >= 1.0 { + crate::perf_record!( + "[perf] text-upload {:>7.1}ms vertices={}", + elapsed_ms, + verts.len(), + ); + } + } } pub fn upload_uniforms(&self, queue: &wgpu::Queue, uniforms: &Uniforms) { diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index cd1960707..56263be64 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -10,16 +10,15 @@ // * Modify in place — a move / rotate / scale / colour change keeps the // entity's segment count, so its slab is overwritten where it sits. // * Add — bump-allocate a fresh slab at the tail. -// * A Modify whose segment count changed — reject the patch before touching -// GPU state, so the caller can rebuild this wire arena cleanly. +// * A tail Modify whose segment count changed — resize its terminal slab in +// place. A non-tail shape change rejects the patch before touching GPU state +// so the caller can rebuild this wire arena cleanly. // * Erase — tombstone the slab (blank instances that render nothing). // // Two correctness points make add/remove safe: -// * draw_depth_map normalises each entity's draw-order z-bias by the block's -// entity count, so ANY add/remove re-scales EVERY entity's bias. We keep a -// CPU mirror of the WireConst array and, on a structural change, refresh every -// slab's draw_depth and re-upload the (small, ~1 MB) const buffer — the huge -// instance buffer is untouched. +// * draw_depth_map uses stable sparse labels, so Add/Remove changes only the +// named entity's depth. Existing slabs retain their WireConst values and no +// whole-const-buffer upload is needed. // * A tail-appended entity draws last, which only mis-orders alpha-blended / // coincident wires. So when the set contains ANY transparent wire we bail to a // full rebuild instead of appending. Opaque overlap resolves by the z-bias, so @@ -80,6 +79,23 @@ struct PreparedPatchRun { order_sensitive: bool, } +fn can_resize_terminal_slab( + slab: &Slab, + inst_tail: u32, + const_tail: u32, + inst_cap: u32, + const_cap: u32, + new_inst_len: u32, + new_const_len: u32, + change_count: usize, +) -> bool { + change_count == 1 + && slab.inst_off + slab.inst_len == inst_tail + && slab.const_off + slab.const_len == const_tail + && slab.inst_off + new_inst_len <= inst_cap + && slab.const_off + new_const_len <= const_cap +} + pub struct WireArena { inst_buf: wgpu::Buffer, inst_cap: u32, @@ -88,8 +104,7 @@ pub struct WireArena { const_bind_group: std::sync::Arc, const_cap: u32, const_tail: u32, - /// CPU mirror of the const buffer so a structural edit can refresh every - /// slab's draw_depth (denominator change) without re-emitting geometry. + /// CPU mirror of the const buffer, used for in-place tail resize/patches. consts_cpu: Vec, slabs: FxHashMap, /// Temporarily hidden Modified slabs. Grip drag blanks these but keeps their @@ -166,7 +181,7 @@ pub(crate) fn patch_handle_index( } } } - if edit.new_len != 0 { + if edit.visible && edit.new_len != 0 { index.insert( edit.handle.value(), (edit.start..edit.start + edit.new_len) @@ -504,17 +519,14 @@ impl WireArena { new_handles_are_suffix: bool, depth_map: &FxHashMap, ) -> bool { - let depth_structural = changes - .iter() - .any(|(_, kind)| !matches!(kind, ChangeKind::Modified)); - // Prepare and validate every visible run before mutating either GPU // buffer. Growing/shrinking a Modified slab used to tombstone the old // range and append a new one. Repeated live-polyline updates could then // leave the arena half-patched when a later guard requested a rebuild, - // producing alternating old/new submissions. A shape change now takes - // the clean full-arena fallback, while the scene itself still - // re-tessellates only the named entity. + // producing alternating old/new submissions. The terminal slab can + // grow/shrink without relocation (the common live-polyline case); + // other shape changes take the clean full-arena fallback while the + // scene itself still re-tessellates only the named entity. let mut prepared: FxHashMap = FxHashMap::default(); for &(h, kind) in changes { let run = runs.get(&h).map(Vec::as_slice).unwrap_or(&[]); @@ -538,16 +550,25 @@ impl WireArena { let inst_len = insts.len() as u32; let const_len = csts.len() as u32; - if matches!(kind, ChangeKind::Modified) - && self - .slabs - .get(&h) - .or_else(|| self.vacant.get(&h)) - .is_some_and(|slab| { - slab.inst_len != inst_len || slab.const_len != const_len - }) - { - return false; + if matches!(kind, ChangeKind::Modified) { + let known = self.slabs.get(&h).or_else(|| self.vacant.get(&h)); + let shape_changed = known + .is_some_and(|slab| slab.inst_len != inst_len || slab.const_len != const_len); + let can_resize_tail = self.slabs.get(&h).is_some_and(|slab| { + can_resize_terminal_slab( + slab, + self.inst_tail, + self.const_tail, + self.inst_cap, + self.const_cap, + inst_len, + const_len, + changes.len(), + ) + }); + if shape_changed && !can_resize_tail { + return false; + } } prepared.insert( @@ -642,6 +663,50 @@ impl WireArena { continue; } + // A live entity is normally the newest handle and therefore owns + // the terminal slab. Resize that slab at the same offsets instead + // of tombstoning it and rebuilding the whole arena as its segment + // count grows on every click. + let can_resize_tail = self.slabs.get(&h).is_some_and(|slab| { + can_resize_terminal_slab( + slab, + self.inst_tail, + self.const_tail, + self.inst_cap, + self.const_cap, + inst_len, + const_len, + changes.len(), + ) + }); + if can_resize_tail { + let (inst_off, const_off) = { + let slab = self.slabs.get(&h).unwrap(); + (slab.inst_off, slab.const_off) + }; + for instance in &mut insts { + instance.wire_id += const_off; + } + self.write_insts(queue, inst_off, &insts); + self.consts_cpu.truncate(const_off as usize); + self.consts_cpu.extend(csts.iter().copied()); + let csz = std::mem::size_of::() as u64; + queue.write_buffer( + &self.const_buf, + const_off as u64 * csz, + bytemuck::cast_slice(&csts), + ); + self.inst_tail = inst_off + inst_len; + self.const_tail = const_off + const_len; + let slab = self.slabs.get_mut(&h).unwrap(); + slab.inst_len = inst_len; + slab.const_len = const_len; + slab.base_depth = base_depth; + slab.aabb = aabb; + self.order_sensitive |= run_order_sensitive; + continue; + } + // Layout changed ⇒ append at the tail. Unsafe to relocate when the set // resolves overlap by submission order: transparency, a wire with no // draw-order depth, or the mesh-edge arena (all its wires are forced @@ -674,6 +739,16 @@ impl WireArena { for c in &csts { self.consts_cpu.push(*c); } + // Appended instances immediately reference these constant slots. + // Keep the GPU buffer in sync without relying on a full-scene upload. + if !csts.is_empty() { + let const_size = std::mem::size_of::() as u64; + queue.write_buffer( + &self.const_buf, + const_off as u64 * const_size, + bytemuck::cast_slice(&csts), + ); + } self.inst_tail += inst_len; self.const_tail += const_len; self.slabs.insert( @@ -690,30 +765,6 @@ impl WireArena { self.order_sensitive |= run_order_sensitive; } - if depth_structural { - // The entity count changed ⇒ draw_depth_map re-normalised every - // entity's z-bias. Refresh each live slab's draw_depth from the new - // depth map and re-upload the whole (small) const buffer; the instance - // buffer is untouched. - // Preserve block-local depth composition. A slab may contain many - // different child offsets around its entity base; shifting every - // const by the base delta keeps those offsets intact. - for (h, slab) in &mut self.slabs { - // Mesh-edge wires keep depth 0 (no draw-order bias); regular wires - // take the re-normalised map value. - let dd = if self.mesh_edge { - 0.0 - } else { - depth_map.get(&h.value()).map_or(0.0, |d| d[0]) - }; - let delta = dd - slab.base_depth; - for k in 0..slab.const_len { - self.consts_cpu[(slab.const_off + k) as usize].draw_depth += delta; - } - slab.base_depth = dd; - } - queue.write_buffer(&self.const_buf, 0, bytemuck::cast_slice(&self.consts_cpu)); - } if self.tombstoned > self.inst_tail / 2 { return false; } @@ -746,6 +797,8 @@ impl WireArena { clip_w: u32, clip_h: u32, ) -> Vec { + let perf = crate::perf::enabled(); + let perf_started = perf.then(iced::time::Instant::now); if self.inst_tail == 0 { return vec![]; } @@ -799,14 +852,18 @@ impl WireArena { .collect(); } - if crate::perf::enabled() { + if perf { let submitted: u64 = ranges .iter() .map(|(start, end)| (end - start) as u64) .sum(); - if submitted < self.inst_tail as u64 { + let elapsed_ms = perf_started + .map(|started| started.elapsed().as_secs_f64() * 1000.0) + .unwrap_or_default(); + if submitted < self.inst_tail as u64 || elapsed_ms >= 1.0 { crate::perf_record!( - "[perf] wire-cull submitted={} resident={} ranges={}", + "[perf] wire-cull {:>7.1}ms submitted={} resident={} ranges={}", + elapsed_ms, submitted, self.inst_tail, ranges.len(), @@ -826,3 +883,57 @@ impl WireArena { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn slab() -> Slab { + Slab { + inst_off: 100, + inst_len: 10, + const_off: 20, + const_len: 2, + aabb: [0.0; 4], + base_depth: 0.0, + } + } + + #[test] + fn terminal_slab_can_grow_without_relocating_the_arena() { + assert!(can_resize_terminal_slab( + &slab(), + 110, + 22, + 1000, + 100, + 18, + 3, + 1, + )); + } + + #[test] + fn non_terminal_or_batched_resize_uses_clean_fallback() { + assert!(!can_resize_terminal_slab( + &slab(), + 111, + 22, + 1000, + 100, + 18, + 3, + 1, + )); + assert!(!can_resize_terminal_slab( + &slab(), + 110, + 22, + 1000, + 100, + 18, + 3, + 2, + )); + } +} diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index a14ecc5d2..3d7c07f17 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -607,6 +607,84 @@ fn build_const_bind_group( } impl WireGpu { + /// Build a small selection/hover overlay from borrowed resident wires while + /// overriding their colour. Avoids deep-cloning every point/text/fill array + /// of a large selected polyline or block before packing the overlay. + pub fn from_highlight_refs( + device: &wgpu::Device, + wires: &[&WireModel], + color: [f32; 4], + depth_map: &rustc_hash::FxHashMap, + const_bgl: Option<&wgpu::BindGroupLayout>, + ) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if let Some(const_bgl) = const_bgl { + const MAX_INSTANCES: usize = + 268_435_456 / std::mem::size_of::(); + use crate::par::prelude::*; + let per: Vec<(Vec, WireConst)> = wires + .par_iter() + .enumerate() + .map(|(idx, &wire)| { + emit_wire_native(wire, idx as u32, color, wire_draw_depth(wire, depth_map)) + }) + .collect(); + let mut instances = + Vec::with_capacity(per.iter().map(|(items, _)| items.len()).sum()); + let mut consts = Vec::with_capacity(per.len()); + for (mut items, constant) in per { + instances.append(&mut items); + consts.push(constant); + } + if instances.is_empty() { + return Vec::new(); + } + let bind_group = build_const_bind_group(device, const_bgl, &consts); + return instances + .chunks(MAX_INSTANCES) + .map(|chunk| Self { + instance_buffer: instance_buffer_mapped( + device, + "wire.highlight.ibuf", + chunk, + ), + first_instance: 0, + instance_count: chunk.len() as u32, + is_3d_mesh_edge: false, + const_bind_group: Some(bind_group.clone()), + }) + .collect(); + } + + let _ = const_bgl; + const MAX_PACKED_INSTANCES: usize = + 268_435_456 / std::mem::size_of::(); + let per: Vec> = wires + .iter() + .map(|wire| { + emit_wire_packed(wire, color, wire_draw_depth(wire, depth_map)) + }) + .collect(); + let mut instances = Vec::with_capacity(per.iter().map(Vec::len).sum()); + for mut items in per { + instances.append(&mut items); + } + instances + .chunks(MAX_PACKED_INSTANCES) + .map(|chunk| Self { + instance_buffer: instance_buffer_mapped( + device, + "wire.highlight.compat.ibuf", + chunk, + ), + first_instance: 0, + instance_count: chunk.len() as u32, + is_3d_mesh_edge: false, + const_bind_group: None, + }) + .collect() + } + /// Native-only equivalent of [`from_run`] for an already partitioned set /// of borrowed wires. Used when one arena partition exceeds the 256 MB /// buffer limit: the compatible partition stays patchable while only the diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 08d417435..5ac8a2112 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -331,7 +331,7 @@ impl Scene { // ── Erase ───────────────────────────────────────────────────────────── pub fn erase_entities(&mut self, handles: &[Handle]) { - let handle_set: HashSet = handles.iter().copied().collect(); + let mut handle_set: HashSet = HashSet::default(); let mut erased: Vec<(Handle, ChangeKind)> = Vec::new(); for &h in handles { // Objects on a locked layer can't be erased. @@ -343,6 +343,7 @@ impl Scene { let before = self.document.get_entity_arc(h); self.record_undo_before(h, before); } + self.remember_removed_cache_categories(h); self.document.remove_entity_arc(h); self.selected.remove(&h); self.hatches.remove(&h); @@ -350,22 +351,53 @@ impl Scene { self.meshes.remove(&h); self.block_meshes.remove(&h); self.solid_models.remove(&h); + handle_set.insert(h); erased.push((h, ChangeKind::Removed)); } + // Capture exactly the group objects that this erase will rewrite, plus + // the group dictionary when an emptied group will be removed. Do this + // before taking mutable object-map borrows below. + if self.is_recording_undo() && !handle_set.is_empty() { + let affected_groups: Vec<(Handle, ObjectType)> = self + .document + .objects + .iter() + .filter_map(|(&handle, object)| match object { + ObjectType::Group(group) + if group.entities.iter().any(|h| handle_set.contains(h)) => + { + Some((handle, object.clone())) + } + _ => None, + }) + .collect(); + if !affected_groups.is_empty() { + let removes_group = affected_groups.iter().any(|(_, object)| { + matches!( + object, + ObjectType::Group(group) + if group.entities.iter().all(|h| handle_set.contains(h)) + ) + }); + if removes_group { + let dictionary = self.document.header.acad_group_dict_handle; + let dictionary_before = self.document.objects.get(&dictionary).cloned(); + self.record_undo_object_before(dictionary, dictionary_before); + } + for (handle, object) in affected_groups { + self.record_undo_object_before(handle, Some(object)); + } + } + } // 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 = self .document .objects .values_mut() .filter_map(|obj| match obj { ObjectType::Group(g) => { - let before = g.entities.len(); g.entities.retain(|h| !handle_set.contains(h)); - if g.entities.len() != before { - groups_changed = true; - } if g.entities.is_empty() { Some(g.handle) } else { @@ -375,11 +407,6 @@ 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) diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index af93772bf..8c4489837 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -66,7 +66,8 @@ pub struct ViewportData { /// by the wire / face3d pipelines as a clip-z bias. WireModels carry no /// depth field (84 construction sites); the bias is looked up by handle /// at GPU-upload time from this map instead. - pub(in crate::scene) draw_depths: Arc>, + pub(in crate::scene) draw_depths: + std::sync::Weak>, pub(in crate::scene) hatches: Arc>, /// Wipeout fills — rendered in a separate pass AFTER wires. pub(in crate::scene) wipeout_hatches: Arc>, @@ -277,6 +278,9 @@ impl shader::Primitive for Primitive { { inner.wire_cull_key = (u64::MAX, u64::MAX, 0, 0); } + inner.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false); + inner.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false); + inner.mesh_lod_key = (usize::MAX, u64::MAX, 0, 0); inner.render_sig = u64::MAX; } // The MSAA / depth / resolve textures are always sized to the @@ -337,6 +341,9 @@ impl shader::Primitive for Primitive { let Some(vp_wires) = vp.wires.upgrade() else { continue; }; + let Some(draw_depths) = vp.draw_depths.upgrade() else { + continue; + }; // Third component is the *selected-set* signature (not // selection_generation, which also bumps on hover) so a rollover // doesn't re-upload the static hatch / face3d buffers. @@ -411,7 +418,8 @@ impl shader::Primitive for Primitive { || inner .cached_face3d_depth_source .as_ref() - .map_or(true, |source| !Arc::ptr_eq(source, &vp.draw_depths)) + .and_then(std::sync::Weak::upgrade) + .map_or(true, |source| !Arc::ptr_eq(&source, &draw_depths)) || (inner.cached_face3d_key.0 != vp.wire_content_id && !face_pass_unchanged); if face3d_changed || face3d_fill_active != inner.cached_face3d_key.1 { @@ -420,10 +428,10 @@ impl shader::Primitive for Primitive { &vp.face3d_wires[..], &vp_wires[..], !face3d_fill_active, - &vp.draw_depths, + &draw_depths, ); inner.cached_face3d_source = Some(Arc::clone(&vp.face3d_wires)); - inner.cached_face3d_depth_source = Some(Arc::clone(&vp.draw_depths)); + inner.cached_face3d_depth_source = Some(Arc::downgrade(&draw_depths)); } inner.cached_face3d_key = (vp.wire_content_id, face3d_fill_active); // Wire buffers are world-space, so a camera move alone doesn't @@ -527,7 +535,7 @@ impl shader::Primitive for Primitive { &patch.changes, ®ular_changed, patch.new_handles_are_suffix, - &vp.draw_depths, + &draw_depths, ) } else { inner.wire_arena_fallback_kind == Some(false) @@ -541,7 +549,7 @@ impl shader::Primitive for Primitive { &patch.changes, &mesh_changed, patch.new_handles_are_suffix, - &vp.draw_depths, + &draw_depths, ) } else { inner.wire_arena_fallback_kind == Some(true) @@ -571,7 +579,7 @@ impl shader::Primitive for Primitive { device, queue, ®ular, - &vp.draw_depths, + &draw_depths, bgl, false, ); @@ -580,7 +588,7 @@ impl shader::Primitive for Primitive { crate::scene::pipeline::WireGpu::from_run_refs( device, ®ular, - &vp.draw_depths, + &draw_depths, false, bgl, ), @@ -607,7 +615,7 @@ impl shader::Primitive for Primitive { device, queue, &mesh, - &vp.draw_depths, + &draw_depths, bgl, true, ); @@ -616,7 +624,7 @@ impl shader::Primitive for Primitive { crate::scene::pipeline::WireGpu::from_run_refs( device, &mesh, - &vp.draw_depths, + &draw_depths, true, bgl, ), @@ -700,7 +708,7 @@ impl shader::Primitive for Primitive { Some(entry) => entry, None => { let entry = - inner.build_wire_buffers(device, &vp_wires[..], &vp.draw_depths); + inner.build_wire_buffers(device, &vp_wires[..], &draw_depths); pipeline .wire_buffer_cache .insert(vp.wire_content_id, entry.clone()); @@ -745,13 +753,26 @@ impl shader::Primitive for Primitive { // so this refreshes without re-tessellating or re-uploading the main // wire buffers. let sel_key = (vp.wire_content_id, vp.selection_generation); - if sel_key != inner.cached_selection { + let highlighted_geometry_unchanged = + vp.selected_handles.is_empty() && vp.hover_handle.is_none() + || vp.wire_patch.as_ref().is_some_and(|(previous, patch)| { + *previous == inner.cached_selection.0 + && patch.changes.iter().all(|(handle, _)| { + !vp.selected_handles.contains(handle) + && vp.hover_handle != Some(*handle) + }) + }); + let selection_changed = inner.cached_selection.1 != vp.selection_generation; + let highlighted_geometry_changed = inner.cached_selection.0 + != vp.wire_content_id + && !highlighted_geometry_unchanged; + if selection_changed || highlighted_geometry_changed { inner.upload_selected_wires( device, &vp_wires[..], &vp.selected_handles, vp.hover_handle, - &vp.draw_depths, + &draw_depths, ); // Text highlight rides the same selection key: a pick / rollover // recolours the selected / hovered glyphs without touching the @@ -762,8 +783,11 @@ impl shader::Primitive for Primitive { &vp.selected_handles, vp.hover_handle, ); - inner.cached_selection = sel_key; } + // Advance the content id even when an unrelated entity patch kept + // the existing overlay valid, so future deltas compare to the + // arena generation actually on screen. + inner.cached_selection = sel_key; // Batched solid meshes stay resident while unrelated entity // categories change. if inner @@ -800,7 +824,7 @@ impl shader::Primitive for Primitive { // Live overlay (command preview / interim / grip drag) — small and // refreshed every frame it's present, so a drag never re-uploads // the resident base wire buffer. - inner.upload_preview_wires(device, &vp.preview_wires[..], &vp.draw_depths); + inner.upload_preview_wires(device, &vp.preview_wires[..], &draw_depths); inner.upload_preview_text(device, queue, &vp.preview_text_verts[..]); // Cull / scissor / LOD project AABBs relative-to-eye (matching the // GPU's RTE path) so the math stays precise at UTM-scale coords. @@ -819,15 +843,44 @@ impl shader::Primitive for Primitive { inner.upload_silhouettes(device, &[], vp.view_dir); } inner.upload_clip_boundary(device, &vp.clip_boundary_ndc); - inner.compute_hatch_lod(queue, view_rot, eye, clip_size.width, clip_size.height); - inner.compute_wipeout_lod(view_rot, eye, clip_size.width, clip_size.height); - inner.compute_mesh_lod( - queue, - view_rot, - eye, + let hatch_lod_key = ( + Arc::as_ptr(&vp.hatches) as usize, + vp.camera_generation, + clip_size.width, + clip_size.height, + fill_mode, + ); + if inner.hatch_lod_key != hatch_lod_key { + inner.compute_hatch_lod(queue, view_rot, eye, clip_size.width, clip_size.height); + inner.hatch_lod_key = hatch_lod_key; + } + let wipeout_lod_key = ( + Arc::as_ptr(&vp.wipeout_hatches) as usize, + vp.camera_generation, + clip_size.width, + clip_size.height, + fill_mode, + ); + if inner.wipeout_lod_key != wipeout_lod_key { + inner.compute_wipeout_lod(view_rot, eye, clip_size.width, clip_size.height); + inner.wipeout_lod_key = wipeout_lod_key; + } + let mesh_lod_key = ( + Arc::as_ptr(&vp.meshes) as usize, + vp.camera_generation, clip_size.width, clip_size.height, ); + if inner.mesh_lod_key != mesh_lod_key { + inner.compute_mesh_lod( + queue, + view_rot, + eye, + clip_size.width, + clip_size.height, + ); + inner.mesh_lod_key = mesh_lod_key; + } #[cfg(not(target_arch = "wasm32"))] { let cull_key = ( @@ -881,6 +934,7 @@ impl shader::Primitive for Primitive { ); } } + let prepare_ms = nav_prepare_started.elapsed().as_secs_f64() * 1000.0; if let Some(sample) = self.nav_perf { crate::perf::record(format_args!( "[perf] nav-prepare op={} space={} mode={} input={:.2}ms build={:.2}ms prepare={:.2}ms elapsed={:.2}ms viewports={}", @@ -889,10 +943,16 @@ impl shader::Primitive for Primitive { sample.mode, sample.input_ms, sample.build_ms, - nav_prepare_started.elapsed().as_secs_f64() * 1000.0, + prepare_ms, sample.started.elapsed().as_secs_f64() * 1000.0, self.viewports.len(), )); + } else if crate::perf::enabled() && prepare_ms >= 5.0 { + crate::perf_record!( + "[perf] frame-prepare {:>7.1}ms viewports={}", + prepare_ms, + self.viewports.len(), + ); } } @@ -970,16 +1030,23 @@ impl shader::Primitive for Primitive { inner.viewcube.render(encoder, target, vp_clip); } } + let render_ms = nav_render_started.elapsed().as_secs_f64() * 1000.0; if let Some(sample) = self.nav_perf { crate::perf::record(format_args!( "[perf] nav-render op={} space={} mode={} encode={:.2}ms elapsed={:.2}ms viewports={}", sample.op.label(), sample.space, sample.mode, - nav_render_started.elapsed().as_secs_f64() * 1000.0, + render_ms, sample.started.elapsed().as_secs_f64() * 1000.0, self.viewports.len(), )); + } else if crate::perf::enabled() && render_ms >= 5.0 { + crate::perf_record!( + "[perf] frame-encode {:>7.1}ms viewports={}", + render_ms, + self.viewports.len(), + ); } } } @@ -2062,11 +2129,12 @@ impl Scene { } else { 0x3000_0000_0000_0000 | inst.handle.value() }; + let draw_depths = self.draw_depth_map(); let text_verts = self.gather_text_verts( &all_wires, wire_content_id, text_source_key, - &self.draw_depth_map(), + &draw_depths, ); // Grip-drag / command-preview glyphs, excluded from the epoch-cached base // gather above. Two sources, both tiny (one operation's worth) and walked @@ -2117,7 +2185,7 @@ impl Scene { face3d_wires, text_verts, preview_text_verts, - draw_depths: self.draw_depth_map(), + draw_depths: Arc::downgrade(&draw_depths), hatches, wipeout_hatches, images,