From 30b907cdd6c7079907d99a7bae612faa35b1d71d Mon Sep 17 00:00:00 2001 From: sLuCHa <142535358+sLuCHaa@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:08:19 +0200 Subject: [PATCH 1/5] perf(clipboard): keep large copy/paste responsive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/command_driver.rs | 7 ++- src/modules/draw/clipboard/paste.rs | 71 +++++++++++++++++++++++++++-- src/scene/preview.rs | 4 +- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 45fec88b..388c2048 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -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 } diff --git a/src/modules/draw/clipboard/paste.rs b/src/modules/draw/clipboard/paste.rs index a2d5ce33..0bc8a6b5 100644 --- a/src/modules/draw/clipboard/paste.rs +++ b/src/modules/draw/clipboard/paste.rs @@ -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, + /// 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, /// 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, 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 { + 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 { let pt = pt.as_vec3(); + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + 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() } } diff --git a/src/scene/preview.rs b/src/scene/preview.rs index 32982f74..4c2200de 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -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 = 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(_)) { From d0286fbacf5269f3a2519e23c9257c633b173d11 Mon Sep 17 00:00:00 2001 From: sLuCHa <142535358+sLuCHaa@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:57:10 +0200 Subject: [PATCH 2/5] perf(snap): keep point picking responsive on large drawings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object snap ran several full scans of the drawing on every cursor move, and the Intersection / Apparent-Intersection passes compare pairs of in-range segments — O(k^2) in the number of wires whose bounding box overlaps the aperture. Zoomed out over a large drawing (or with long lines whose AABB spans the view) that pairwise scan grows to the whole document and freezes point picking, so MOVE/COPY/paste placement hung. Count the in-range wires once and gate only the pairwise passes on it: above the cap they are skipped (you cannot resolve a single intersection when the aperture spans thousands of segments anyway), while every single-wire snap (endpoint, midpoint, centre, perpendicular, nearest) keeps working — so point picking still snaps to corners at any zoom. --- src/snap.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/snap.rs b/src/snap.rs index f1983522..0fe6d148 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -878,6 +878,19 @@ impl Snapper { && cursor_world.y - r <= wire.aabb[3] as f64 }; + // The per-wire snaps below skip out-of-aperture wires via `wire_in_range`, + // so they stay O(n) and snap at any zoom. Intersection / ApparentIntersection + // instead compare pairs of in-range segments — O(k²) — which hangs when the + // aperture spans the whole drawing. Count the in-range wires once and gate + // only those pairwise passes; the single-wire snaps always run. + const MAX_PAIRWISE_CANDIDATES: usize = 1_500; + let allow_pairwise = wires + .iter() + .filter(|w| wire_in_range(w)) + .take(MAX_PAIRWISE_CANDIDATES + 1) + .count() + <= MAX_PAIRWISE_CANDIDATES; + let mut try_pt = |world: glam::DVec3, snap_type: SnapType| { let screen = world_to_screen(world, view_rot, eye, bounds); if !in_bounds(screen) { @@ -1018,8 +1031,8 @@ impl Snapper { } } - // ── Intersection — segment-segment intersections ────────── - if self.is_on(SnapType::Intersection) { + // ── Intersection — segment-segment intersections (pairwise, gated) ── + if self.is_on(SnapType::Intersection) && allow_pairwise { for i in 0..wires.len() { if !wire_in_range(&wires[i]) { continue; @@ -1169,9 +1182,9 @@ impl Snapper { } } - // ── Apparent Intersection — screen-space intersections ───────────── + // ── Apparent Intersection — screen-space intersections (pairwise, gated) ── // L: pre-project each in-range wire's points to screen once, not once per segment pair. - if self.is_on(SnapType::ApparentIntersection) { + if self.is_on(SnapType::ApparentIntersection) && allow_pairwise { let screen_pts: Vec>> = wires .iter() .map(|w| { From 4d7588e1e0f482e9982fd073de74fb69ac2a5f33 Mon Sep 17 00:00:00 2001 From: sLuCHa <142535358+sLuCHaa@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:57:11 +0200 Subject: [PATCH 3/5] perf(properties): count-only panel for very large selections Rebuilding the Properties panel aggregated shared/varying values across the whole selection: O(n) per property row, plus an O(n^2) group filter (group.handles.contains is a linear scan per selected entity). Selecting or pasting tens of thousands of objects stalled the rebuild for seconds. Above a cap, show a lightweight ' objects selected' panel instead of the per-entity aggregation; bulk layer/colour/lineweight edits still go through the ribbon. --- src/app/properties.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/app/properties.rs b/src/app/properties.rs index 3171f733..eb8bd93b 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -5,6 +5,11 @@ use crate::scene::view::dispatch; use crate::ui; use acadrust::{EntityType, Handle}; +/// Above this many selected objects the Properties panel skips per-entity +/// property aggregation (which is O(n) per row, plus an O(n²) group filter) and +/// shows a count-only summary instead. Bulk edits still go through the ribbon. +const MAX_PROP_AGGREGATE: usize = 2_000; + impl OpenCADStudio { /// Rebuild the PropertiesPanel from the current entity selection. /// Preserves UI state (open pickers, edit buffer) across refreshes. @@ -416,6 +421,23 @@ impl OpenCADStudio { ..Default::default() } } + // Property aggregation is O(n) per row plus an O(n²) group filter + // (`group.handles.contains` scans per entity), stalling the rebuild + // for seconds at tens of thousands of objects. Above the cap show a + // count-only panel; bulk edits still go through the ribbon. + n if n > MAX_PROP_AGGREGATE => ui::PropertiesPanel { + title: format!("{} objects selected", n), + layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), + linetype_combo: iced::widget::combo_box::State::new(linetype_items.clone()), + lineweight_combo: iced::widget::combo_box::State::new( + ui::properties::lw_options(), + ), + hatch_pattern_combo: iced::widget::combo_box::State::new( + crate::scene::model::hatch_patterns::names(), + ), + linetype_items, + ..Default::default() + }, _ => { let groups = build_selection_groups(&selected); let active_group = selected_group From 56835791af09642a5f33b9c49c057159b521e3a5 Mon Sep 17 00:00:00 2001 From: sLuCHa <142535358+sLuCHaa@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:57:11 +0200 Subject: [PATCH 4/5] perf(clipboard): derive the paste anchor from bounding boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COPYCLIP/CUTCLIP computed the clipboard anchor by tessellating every selected entity and averaging all wire vertices — O(total geometry), ~250 ms when copying a whole drawing. The anchor only fixes which point of the selection sits under the cursor at paste, so the mean of each entity's bounding-box centre is an equally valid, far cheaper origin. --- src/app/commands/blocks.rs | 10 ++++++---- src/app/helpers.rs | 40 ++++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index e9c639c6..94a1c69d 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -70,8 +70,9 @@ impl OpenCADStudio { .iter() .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned()) .collect(); - self.clipboard_centroid = super::super::helpers::entities_centroid( - &self.tabs[i].scene.wire_models_for(&handles), + self.clipboard_centroid = super::super::helpers::entities_centroid_by_bbox( + &self.tabs[i].scene.document, + &handles, ); self.clipboard = entities; self.clipboard_deps = super::super::ClipboardDeps::capture( @@ -158,8 +159,9 @@ impl OpenCADStudio { .iter() .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned()) .collect(); - self.clipboard_centroid = super::super::helpers::entities_centroid( - &self.tabs[i].scene.wire_models_for(&handles), + self.clipboard_centroid = super::super::helpers::entities_centroid_by_bbox( + &self.tabs[i].scene.document, + &handles, ); let count = entities.len(); self.clipboard = entities; diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 657102b7..3fc841ed 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -1,4 +1,3 @@ -use crate::scene::WireModel; use crate::ui::overlay::GridPlane; use acadrust::tables::Ucs; @@ -253,28 +252,27 @@ pub(super) fn polar_constrain_near( // ── Clipboard / selection helpers ────────────────────────────────────────── -/// Compute the centroid of a set of wire models (average of all points). -pub(super) fn entities_centroid(wires: &[WireModel]) -> glam::DVec3 { - // Reconstruct each vertex's absolute f64 from the double-single high/low - // pair and accumulate in f64: summing the f32 `points` alone at UTM scale - // (~5.7e6) both quantizes each term ~0.5 m and loses low bits across the - // running total, drifting the paste base / block base metres off. +/// Cheap copy/paste anchor: the mean of each entity's bounding-box centre. +/// Replaces averaging every tessellated wire vertex, which cost O(total +/// geometry) and stalled a whole-drawing copy. The anchor only sets which point +/// of the selection sits under the cursor, so a per-entity bbox centre is an +/// equally valid, far cheaper origin. +pub(super) fn entities_centroid_by_bbox( + doc: &acadrust::CadDocument, + handles: &[acadrust::Handle], +) -> glam::DVec3 { let mut sum = glam::DVec3::ZERO; let mut count = 0usize; - for w in wires { - for (i, p) in w.points.iter().enumerate() { - // Wire models carry NaN points as separators between disjoint - // segments; summing them poisons the whole centroid into NaN, - // which then makes every paste base point NaN. (#129) - if !p[0].is_finite() || !p[1].is_finite() || !p[2].is_finite() { - continue; - } - let l = w.points_low.get(i).copied().unwrap_or([0.0; 3]); - sum += glam::DVec3::new( - p[0] as f64 + l[0] as f64, - p[1] as f64 + l[1] as f64, - p[2] as f64 + l[2] as f64, - ); + for &h in handles { + let Some(e) = doc.get_entity(h) else { continue }; + let bb = e.as_entity().bounding_box(); + let c = glam::DVec3::new( + (bb.min.x + bb.max.x) * 0.5, + (bb.min.y + bb.max.y) * 0.5, + (bb.min.z + bb.max.z) * 0.5, + ); + if c.x.is_finite() && c.y.is_finite() && c.z.is_finite() { + sum += c; count += 1; } } From cd28b6f7c1ce375f64b67273bea90e85573315a3 Mon Sep 17 00:00:00 2001 From: sLuCHa <142535358+sLuCHaa@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:57:11 +0200 Subject: [PATCH 5/5] perf(clipboard): cap the ghost preview by wire count, box beyond it on_preview_wires clones and re-uploads the whole ghost on every cursor move, so its cost scales with the wire count, not just the point count. A whole-drawing paste flooded the event loop and locked up placement. Gate on the wire count as well as the point count, and fall back to a single bounding-box outline above the budget. The budget still shows the full-geometry ghost for typical pastes (up to ~20k wires); only a whole-drawing paste rubber-bands a box. --- src/modules/draw/clipboard/paste.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/modules/draw/clipboard/paste.rs b/src/modules/draw/clipboard/paste.rs index 0bc8a6b5..01dc3c36 100644 --- a/src/modules/draw/clipboard/paste.rs +++ b/src/modules/draw/clipboard/paste.rs @@ -38,15 +38,18 @@ pub struct PasteCommand { } 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; + /// `on_preview_wires` clones and re-uploads the whole ghost every cursor + /// move, so its cost scales with the wire count and the total point count. + /// Past either budget the per-move work floods the event loop, so switch to + /// a bounding-box outline; below them the full ghost still shows. + const MAX_PREVIEW_WIRES: usize = 20_000; + const MAX_PREVIEW_POINTS: usize = 300_000; pub fn new(wires: Vec, centroid: Vec3) -> Self { let total_points: usize = wires.iter().map(|w| w.points.len()).sum(); - if total_points > Self::MAX_PREVIEW_POINTS { + let too_heavy = + wires.len() > Self::MAX_PREVIEW_WIRES || total_points > Self::MAX_PREVIEW_POINTS; + if too_heavy { 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 };