perf(clipboard): keep large copy/paste responsive

Copying and pasting a whole drawing froze the app. Three synchronous,
main-thread hot spots scaled with the full document rather than the
selection:

- Copy centroid did a linear document scan per selected handle
  (wire_models_for / mirror_preview_parts), making copy O(H*N). Use the
  document's O(1) handle index instead.
- The paste ghost translated every point of every clipboard wire on each
  mouse move — O(points) per frame, which stalls placement of a huge
  selection. Above a point budget, rubber-band a single bounding-box
  outline instead (O(1) per frame).
- finalize_paste re-tessellated every solid in the document on each
  paste. add_entity already tessellates pasted solids, so switch to the
  incremental populate_missing_meshes_from_document.
This commit is contained in:
sLuCHa committed 2026-07-08 16:09:35 +02:00
1 parent a4f7ee376f
commit 30b907cdd6
3 files changed
+76 -6

No files matched your search

+6 -1
View File
@@ -2080,7 +2080,12 @@ impl OpenCADStudio {
})
.collect();
self.merge_clipboard_ext_objects(i, &by_index);
self.tabs[i].scene.populate_meshes_from_document();
// Incremental: `add_entity` already tessellated every pasted top-level
// solid, and existing document solids are still cached — so only newly
// introduced block-definition solids need building. The full rebuild
// would clear and re-tessellate the entire document (every solid in the
// drawing) on each paste, which is what made a large paste stall.
self.tabs[i].scene.populate_missing_meshes_from_document();
by_index
}
+68 -3
View File
@@ -22,15 +22,75 @@ use crate::command::{CadCommand, CmdResult};
use crate::scene::model::wire_model::WireModel;
pub struct PasteCommand {
/// Wire models of the clipboard entities (used for preview).
/// Wire models of the clipboard entities (used for the ghost preview).
/// Emptied when `bbox_wire` is used, so a huge clipboard isn't kept twice.
wires: Vec<WireModel>,
/// Lightweight bounding-box outline that replaces `wires` for very large
/// clipboards. `on_preview_wires` runs every mouse move and translates every
/// point of the ghost; for a whole-drawing paste that is O(hundreds of
/// thousands) per frame and freezes placement. Above a point budget we ghost
/// just this 5-point rectangle instead — O(1) per frame — the same way
/// AutoCAD rubber-bands a box for a huge selection. `None` = ghost the full
/// wires.
bbox_wire: Option<WireModel>,
/// Centroid of the clipboard entities (offset origin for translation).
centroid: Vec3,
}
impl PasteCommand {
/// Per-frame preview cost is proportional to the total point count across
/// the ghost wires. Above this, switch to the bounding-box outline so
/// placement stays smooth. ~50k points is well under a frame budget yet
/// still shows the full ghost for typical multi-object pastes.
const MAX_PREVIEW_POINTS: usize = 50_000;
pub fn new(wires: Vec<WireModel>, centroid: Vec3) -> Self {
Self { wires, centroid }
let total_points: usize = wires.iter().map(|w| w.points.len()).sum();
if total_points > Self::MAX_PREVIEW_POINTS {
if let Some(bbox_wire) = Self::bbox_outline(&wires) {
// Drop the full wires — the box is all the ghost needs now.
return Self { wires: Vec::new(), bbox_wire: Some(bbox_wire), centroid };
}
}
Self { wires, bbox_wire: None, centroid }
}
/// Build a closed rectangle outline around the XY extent of `wires`, in the
/// same world coordinates so the standard `translated(delta)` shift applies.
/// Uses the double-single `points + points_low` sum so the box stays precise
/// at UTM-scale coordinates. Returns `None` when there are no points.
fn bbox_outline(wires: &[WireModel]) -> Option<WireModel> {
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
let mut any = false;
for w in wires {
for (i, p) in w.points.iter().enumerate() {
let lo = w.points_low.get(i).copied().unwrap_or([0.0; 3]);
for k in 0..3 {
let v = p[k] as f64 + lo[k] as f64;
min[k] = min[k].min(v);
max[k] = max[k].max(v);
}
any = true;
}
}
if !any {
return None;
}
let z = min[2];
let pts = vec![
[min[0], min[1], z],
[max[0], min[1], z],
[max[0], max[1], z],
[min[0], max[1], z],
[min[0], min[1], z],
];
Some(WireModel::solid_f64(
"paste_bbox".into(),
pts,
WireModel::CYAN,
false,
))
}
}
@@ -55,8 +115,13 @@ impl CadCommand for PasteCommand {
vec![]
}
fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> { let pt = pt.as_vec3();
fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> {
let pt = pt.as_vec3();
let delta = pt - self.centroid;
// Large clipboard: ghost just the bounding box — O(1) per frame.
if let Some(bbox) = &self.bbox_wire {
return vec![bbox.translated(delta)];
}
self.wires.iter().map(|w| w.translated(delta)).collect()
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ impl Scene {
handles
.iter()
.flat_map(|h| {
match self.document.entities().find(|e| e.common().handle == *h) {
match self.document.get_entity(*h) {
// Hatches carry no outline in the normal wire set, but an
// edit preview (move / copy / array / grip-drag) needs to
// show the shape following the cursor. Build a live boundary
@@ -54,7 +54,7 @@ impl Scene {
let mut plain: Vec<WireModel> = Vec::new();
let mut texts: Vec<(WireModel, glam::DVec3)> = Vec::new();
for h in handles {
let Some(e) = self.document.entities().find(|e| e.common().handle == *h) else {
let Some(e) = self.document.get_entity(*h) else {
continue;
};
if matches!(e, EntityType::Text(_)) {