perf: eliminate dense edit stalls

Keep resident wires, draw depths, GPU slabs, and edit history incremental across entity changes. Defer rollover work to avoid cursor jumps on dense drawings.
This commit is contained in:
Hakan Seven committed 2026-07-25 01:06:00 +03:00
1 parent ea93e169ea
commit da34c6817a
11 files changed
+956 -296

No files matched your search

+56 -15
View File
@@ -435,13 +435,16 @@ impl OpenCADStudio {
}
CmdResult::CommitSolid { entity, solid } => {
let label = self.history_label_from_active_cmd(i, "SOLID");
self.push_undo_snapshot(i, label);
let pending = self.begin_undo(i, label, 1, true);
self.add_solid_model(entity, *solid);
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CommitAndEditText(entity) => {
let label = self.history_label_from_active_cmd(i, "ENTITY");
@@ -466,7 +469,10 @@ impl OpenCADStudio {
edit_index,
} => {
let label = self.history_label_from_active_cmd(i, "ENTITY");
self.push_undo_snapshot(i, label);
let delta_safe = entities
.iter()
.all(|entity| self.delta_add_safe(i, entity));
let pending = self.begin_undo(i, label, entities.len(), delta_safe);
let mut edit_handle = None;
let mut leader_handle = None;
for (idx, entity) in entities.into_iter().enumerate() {
@@ -494,6 +500,9 @@ impl OpenCADStudio {
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
self.ribbon.deactivate_tool();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
if let Some(h) = edit_handle {
return self.begin_text_edit(h);
}
@@ -533,7 +542,7 @@ impl OpenCADStudio {
}
CmdResult::CommitHatch(hatch) => {
let label = self.history_label_from_active_cmd(i, "HATCH");
self.push_undo_snapshot(i, label);
let pending = self.begin_undo(i, label, 1, true);
let new_handle = self.tabs[i].scene.add_hatch(hatch);
if !new_handle.is_null() {
self.tabs[i].scene.select_entity(new_handle, true);
@@ -544,6 +553,9 @@ impl OpenCADStudio {
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
self.refresh_properties();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::BatchCopy(handles, transforms) => {
let label = self.history_label_from_active_cmd(i, "ARRAY");
@@ -747,13 +759,17 @@ impl OpenCADStudio {
.and_then(|c| c.attreq_take_insert());
if let Some(entity) = entity {
let label = self.history_label_from_active_cmd(i, "INSERT");
self.push_undo_snapshot(i, label);
let delta_safe = self.delta_add_safe(i, &entity);
let pending = self.begin_undo(i, label, 1, delta_safe);
self.commit_entity(entity);
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
} else {
// Inject attdefs so the command enters attr-filling mode.
@@ -768,11 +784,19 @@ impl OpenCADStudio {
}
CmdResult::CommitLiveEntity(entity) => {
let label = self.history_label_from_active_cmd(i, "ENTITY");
self.push_undo_snapshot(i, label);
let delta_safe = self.delta_add_safe(i, &entity);
let pending = self.begin_undo(i, label, 1, delta_safe);
let handle = self.commit_entity_handle(entity);
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
if let Some(h) = handle {
// Keep the live document Arc unique while later vertices
// replace the geometry in place. The final Arc is captured
// by UpdateLiveEntity when the command completes.
self.defer_live_entity_history_after(i, h);
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_live_handle(h);
}
@@ -800,11 +824,13 @@ impl OpenCADStudio {
new.as_entity_mut().set_handle(old_handle);
new.as_entity_mut().set_layer(layer);
*old = new;
self.tabs[i].scene.mark_entity_dirty(handle);
self.tabs[i].scene.bump_geometry_no_blocks();
self.tabs[i]
.scene
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
self.tabs[i].dirty = true;
}
if finish {
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;
@@ -819,10 +845,10 @@ impl OpenCADStudio {
CmdResult::RemoveLiveEntity(handle) => {
// The command backed off below a valid entity (PLINE Undo at
// one remaining vertex): take the live entity out of the
// document and keep prompting. No undo bookkeeping — the
// create's snapshot already covers the whole in-progress
// object as one unit.
// document, drop its provisional history entry and keep
// prompting. A later second point creates one fresh entry.
self.tabs[i].scene.erase_entities(&[handle]);
self.discard_last_undo_entry(i);
self.tabs[i].dirty = true;
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
@@ -1790,7 +1816,7 @@ impl OpenCADStudio {
// ── Solid3D creation (BOX / SPHERE / CYLINDER) ────────────────
CmdResult::CommitSolid3D { mesh_fn } => {
use crate::modules::insert::solid3d_cmds::empty_solid3d;
self.push_undo_snapshot(i, "SOLID3D");
let pending = self.begin_undo(i, "SOLID3D", 1, true);
let entity = empty_solid3d();
let handle = self.tabs[i].scene.add_entity(entity);
if !handle.is_null() {
@@ -1806,6 +1832,9 @@ impl OpenCADStudio {
self.tabs[i].dirty = true;
self.command_line.push_output("Solid created.");
}
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
@@ -1861,7 +1890,7 @@ impl OpenCADStudio {
}
});
if let Some((mut mesh, solid)) = result {
self.push_undo_snapshot(i, "EXTRUDE");
let pending = self.begin_undo(i, "EXTRUDE", 1, true);
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
@@ -1874,6 +1903,9 @@ impl OpenCADStudio {
self.tabs[i].scene.solid_models.insert(new_handle, solid);
self.tabs[i].dirty = true;
self.command_line.push_output("EXTRUDE: solid created.");
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
} else {
self.command_line.push_error("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.).");
}
@@ -1943,7 +1975,7 @@ impl OpenCADStudio {
}
});
if let Some(mut mesh) = result {
self.push_undo_snapshot(i, "REVOLVE");
let pending = self.begin_undo(i, "REVOLVE", 1, true);
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
@@ -1954,6 +1986,9 @@ impl OpenCADStudio {
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("REVOLVE: solid created ({:.0}°).", angle_deg));
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
} else {
self.command_line
.push_error("REVOLVE: could not revolve profile.");
@@ -2111,7 +2146,7 @@ impl OpenCADStudio {
});
if let Some(mut mesh) = result {
self.push_undo_snapshot(i, "SWEEP");
let pending = self.begin_undo(i, "SWEEP", 1, true);
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
@@ -2121,6 +2156,9 @@ impl OpenCADStudio {
.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i].dirty = true;
self.command_line.push_output("SWEEP: solid created.");
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
} else {
self.command_line.push_error("SWEEP: could not sweep profile along path. Use a closed 2D profile and a Line or Polyline path.");
}
@@ -2200,7 +2238,7 @@ impl OpenCADStudio {
);
if let Some(mut mesh) = result {
self.push_undo_snapshot(i, "LOFT");
let pending = self.begin_undo(i, "LOFT", 1, true);
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
@@ -2213,6 +2251,9 @@ impl OpenCADStudio {
"LOFT: solid created from {} profiles.",
handles.len()
));
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
} else {
self.command_line.push_error("LOFT: could not loft profiles. Ensure sections have the same edge count and are compatible.");
}
+44 -5
View File
@@ -148,6 +148,46 @@ impl OpenCADStudio {
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
}
pub(super) fn defer_live_entity_history_after(&mut self, i: usize, handle: Handle) {
let Some(HistorySnapshot::Delta(delta)) =
self.tabs[i].history.undo_stack.last_mut()
else {
return;
};
if let Some((_, _, after)) = delta
.entities
.iter_mut()
.find(|(entry_handle, _, _)| *entry_handle == handle)
{
*after = None;
}
}
pub(super) fn finish_live_entity_history(&mut self, i: usize, handle: Handle) {
let Some(after) = self.tabs[i].scene.document.get_entity_arc(handle) else {
return;
};
let selected_after = self.tabs[i].scene.selected.iter().copied().collect();
let current_layout_after = self.tabs[i].scene.current_layout.clone();
let dirty_after = self.tabs[i].dirty;
let Some(HistorySnapshot::Delta(delta)) =
self.tabs[i].history.undo_stack.last_mut()
else {
return;
};
let Some((_, _, entry_after)) = delta
.entities
.iter_mut()
.find(|(entry_handle, _, _)| *entry_handle == handle)
else {
return;
};
*entry_after = Some(after);
delta.selected_after = selected_after;
delta.current_layout_after = current_layout_after;
delta.dirty_after = dirty_after;
}
pub(super) fn discard_last_undo_entry(&mut self, i: usize) {
if self.tabs[i].history.pending.take().is_some() {
self.tabs[i].scene.document.end_entity_change_recording();
@@ -296,16 +336,15 @@ impl OpenCADStudio {
}
/// Add is delta-safe only for a plain drawable on an already-existing layer:
/// an insert / block / image / dimension add also creates block records,
/// image definitions or layers (non-entity state).
/// 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.
pub(super) fn delta_add_safe(&self, i: usize, entity: &EntityType) -> bool {
if matches!(
entity,
EntityType::Insert(_)
| EntityType::Block(_)
EntityType::Block(_)
| EntityType::BlockEnd(_)
| EntityType::RasterImage(_)
| EntityType::Dimension(_)
// 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.
+4 -1
View File
@@ -67,7 +67,10 @@ pub struct HoverDwell {
}
/// How long the cursor must sit still before the idle rollover pick runs.
pub const HOVER_DWELL_MS: u128 = 120;
pub const HOVER_DWELL_MS: u128 = 500;
/// Dense resident sets also retain the previous rollover while moving; this
/// threshold gates that extra redraw-avoidance behavior.
pub const HOVER_DWELL_DENSE_WIRES: usize = 50_000;
/// Open multi-functional-grip popup state.
#[derive(Clone, Debug)]
+3 -2
View File
@@ -518,8 +518,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.tabs[i].scene.hidden.remove(&h);
self.tabs[i].scene.clear_preview_wire();
// Geometry restored to the backup — re-tessellate just it.
self.tabs[i].scene.mark_entity_dirty(h);
self.tabs[i].scene.bump_geometry_no_blocks();
self.tabs[i]
.scene
.bump_entities(&[(h, crate::scene::ChangeKind::Modified)]);
self.refresh_selected_grips();
}
self.tabs[self.active_tab].snap_result = None;
+61 -8
View File
@@ -496,6 +496,8 @@ impl OpenCADStudio {
return Task::none();
}
let i = self.active_tab;
let perf_move = std::env::var_os("OCS_PERF").is_some();
let move_started = Instant::now();
// UCS icon grip drag: map the cursor onto the UCS plane and
// slide the origin / rotate the axis. Short-circuits pan & snap.
@@ -694,6 +696,7 @@ impl OpenCADStudio {
// ── Grip drag ─────────────────────────────────────────────
if let Some(grip) = self.tabs[i].active_grip.clone() {
let grip_started = Instant::now();
let (vw, vh) = vp_size;
let bounds = iced::Rectangle {
x: 0.0,
@@ -734,9 +737,11 @@ impl OpenCADStudio {
// Back up the original geometry so Esc can cancel the drag.
self.grip_original = self.tabs[i].scene.document.get_entity(grip.handle).cloned();
self.tabs[i].scene.hidden.insert(grip.handle);
// Grip drag never changes a block definition — keep the
// block cache so the hide doesn't re-tessellate blocks.
self.tabs[i].scene.bump_geometry_no_blocks();
// Hiding changes exactly one resident run. Publishing a full
// delta here made the first grip move rebuild every wire.
self.tabs[i]
.scene
.bump_entities(&[(grip.handle, crate::scene::ChangeKind::Modified)]);
self.grip_preview_handle = Some(grip.handle);
// Snapshot the entity's glyph quads once so each move can
// slide the already-shaped text rather than re-shaping it
@@ -760,6 +765,8 @@ impl OpenCADStudio {
&& snap.iter().all(|w| w.points.is_empty())
&& square_grip;
}
let setup_ms = grip_started.elapsed().as_secs_f64() * 1000.0;
let snap_started = Instant::now();
// The edited entity is hidden, so it's already absent from
// `hit_test_wires` — snap against the set directly, no clone
@@ -833,6 +840,8 @@ impl OpenCADStudio {
}
}
let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0;
let apply_started = Instant::now();
let apply = if grip.is_translate {
GripApply::Translate(snapped - grip.last_world)
} else {
@@ -843,6 +852,8 @@ impl OpenCADStudio {
.apply_grip(grip.handle, grip.grip_id, apply);
self.tabs[i].dirty = true;
self.tabs[i].active_grip.as_mut().unwrap().last_world = snapped;
let apply_ms = apply_started.elapsed().as_secs_f64() * 1000.0;
let preview_started = Instant::now();
// Overlay the moved entity (hidden from the base). Pure text
// moved as a whole slides its drag-start glyphs — no re-shaping
// and no wire re-tess. Anything else (wire geometry, or a point
@@ -865,8 +876,25 @@ impl OpenCADStudio {
let preview = self.tabs[i].scene.wire_models_for(&[grip.handle]);
self.tabs[i].scene.set_preview_wires(preview);
}
let preview_ms = preview_started.elapsed().as_secs_f64() * 1000.0;
let geometry_ms = grip_started.elapsed().as_secs_f64() * 1000.0;
self.refresh_selected_grips();
self.refresh_properties();
let grips_ms = grip_started.elapsed().as_secs_f64() * 1000.0 - geometry_ms;
// Properties are refreshed when the grip is committed or cancelled.
// Rebuilding the inspector on every pointer event adds no drawing
// value and can monopolize the UI thread during a drag.
let total_ms = grip_started.elapsed().as_secs_f64() * 1000.0;
if perf_move && total_ms >= 50.0 {
eprintln!(
"[perf] grip-move {:>7.1}ms setup={:.1} snap={:.1} apply={:.1} preview={:.1} grips={:.1}",
total_ms,
setup_ms,
snap_ms,
apply_ms,
preview_ms,
grips_ms,
);
}
return Task::none();
}
@@ -888,11 +916,17 @@ impl OpenCADStudio {
// drag), defer the pick until the cursor stops. The full
// pick (wires + hatches + block hatches + shaded meshes) is
// O(N) per frame and stalls the cursor on large drawings,
// so each move clears the current highlight and resets the
// dwell timer — `HoverDwellTick` runs the hit-test only
// so each move resets the dwell timer — `HoverDwellTick` runs the hit-test only
// once the cursor has been still for `HOVER_DWELL_MS`.
if !dragging && self.tabs[i].active_cmd.is_none() {
self.tabs[i].scene.set_hover_highlight(None);
// On dense drawings, clearing a rollover immediately schedules a
// second full scene frame just as motion resumes. Keep the previous
// highlight until the next settled pick replaces it.
if self.tabs[i].scene.last_tess_wires.get()
< crate::app::HOVER_DWELL_DENSE_WIRES
{
self.tabs[i].scene.set_hover_highlight(None);
}
self.hover_dwell = Some(crate::app::HoverDwell {
last_move_at: Instant::now(),
point: p,
@@ -1417,6 +1451,13 @@ impl OpenCADStudio {
}
self.sync_dyn_fields();
let move_ms = move_started.elapsed().as_secs_f64() * 1000.0;
if perf_move && self.tabs[i].active_cmd.is_some() && move_ms >= 50.0 {
eprintln!(
"[perf] pointer-move {:>7.1}ms",
move_ms,
);
}
Task::none()
}
@@ -3410,13 +3451,16 @@ impl OpenCADStudio {
let Some(dwell) = self.hover_dwell.clone() else {
return Task::none();
};
let dwell_ms = crate::app::HOVER_DWELL_MS;
if Instant::now()
.duration_since(dwell.last_move_at)
.as_millis()
< crate::app::HOVER_DWELL_MS
< dwell_ms
{
return Task::none();
}
let perf = std::env::var_os("OCS_PERF").is_some();
let hover_started = Instant::now();
let i = dwell.tab;
// Re-check the gate — drag / command may have started
// between the move that armed the dwell and this tick.
@@ -3493,6 +3537,15 @@ impl OpenCADStudio {
});
self.tabs[i].scene.set_hover_highlight(hovered);
self.hover_dwell = None;
let hover_ms = hover_started.elapsed().as_secs_f64() * 1000.0;
if perf && hover_ms >= 5.0 {
eprintln!(
"[perf] hover-dwell {:>7.1}ms wires={} hit={}",
hover_ms,
self.tabs[i].scene.last_tess_wires.get(),
hovered.is_some(),
);
}
Task::none()
}