Merge PR #322: perf — responsive large copy/paste + point picking
External contributor sLuCHa (sLuCHaa). Gates O(k²) snap intersection passes, O(1) handle lookups in preview, bbox paste anchor/ghost, count-only properties panel for huge selections, incremental paste mesh.
This commit is contained in:
commit
17f46968d8
7 changed files with 143 additions and 35 deletions
|
|
@ -2085,7 +2085,12 @@ impl OpenCADStudio {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
self.merge_clipboard_ext_objects(i, &by_index);
|
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
|
by_index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,9 @@ impl OpenCADStudio {
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned())
|
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned())
|
||||||
.collect();
|
.collect();
|
||||||
self.clipboard_centroid = super::super::helpers::entities_centroid(
|
self.clipboard_centroid = super::super::helpers::entities_centroid_by_bbox(
|
||||||
&self.tabs[i].scene.wire_models_for(&handles),
|
&self.tabs[i].scene.document,
|
||||||
|
&handles,
|
||||||
);
|
);
|
||||||
self.clipboard = entities;
|
self.clipboard = entities;
|
||||||
self.clipboard_deps = super::super::ClipboardDeps::capture(
|
self.clipboard_deps = super::super::ClipboardDeps::capture(
|
||||||
|
|
@ -158,8 +159,9 @@ impl OpenCADStudio {
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned())
|
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned())
|
||||||
.collect();
|
.collect();
|
||||||
self.clipboard_centroid = super::super::helpers::entities_centroid(
|
self.clipboard_centroid = super::super::helpers::entities_centroid_by_bbox(
|
||||||
&self.tabs[i].scene.wire_models_for(&handles),
|
&self.tabs[i].scene.document,
|
||||||
|
&handles,
|
||||||
);
|
);
|
||||||
let count = entities.len();
|
let count = entities.len();
|
||||||
self.clipboard = entities;
|
self.clipboard = entities;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use crate::scene::WireModel;
|
|
||||||
use crate::ui::overlay::GridPlane;
|
use crate::ui::overlay::GridPlane;
|
||||||
use acadrust::tables::Ucs;
|
use acadrust::tables::Ucs;
|
||||||
|
|
||||||
|
|
@ -253,28 +252,27 @@ pub(super) fn polar_constrain_near(
|
||||||
|
|
||||||
// ── Clipboard / selection helpers ──────────────────────────────────────────
|
// ── Clipboard / selection helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
/// Compute the centroid of a set of wire models (average of all points).
|
/// Cheap copy/paste anchor: the mean of each entity's bounding-box centre.
|
||||||
pub(super) fn entities_centroid(wires: &[WireModel]) -> glam::DVec3 {
|
/// Replaces averaging every tessellated wire vertex, which cost O(total
|
||||||
// Reconstruct each vertex's absolute f64 from the double-single high/low
|
/// geometry) and stalled a whole-drawing copy. The anchor only sets which point
|
||||||
// pair and accumulate in f64: summing the f32 `points` alone at UTM scale
|
/// of the selection sits under the cursor, so a per-entity bbox centre is an
|
||||||
// (~5.7e6) both quantizes each term ~0.5 m and loses low bits across the
|
/// equally valid, far cheaper origin.
|
||||||
// running total, drifting the paste base / block base metres off.
|
pub(super) fn entities_centroid_by_bbox(
|
||||||
|
doc: &acadrust::CadDocument,
|
||||||
|
handles: &[acadrust::Handle],
|
||||||
|
) -> glam::DVec3 {
|
||||||
let mut sum = glam::DVec3::ZERO;
|
let mut sum = glam::DVec3::ZERO;
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
for w in wires {
|
for &h in handles {
|
||||||
for (i, p) in w.points.iter().enumerate() {
|
let Some(e) = doc.get_entity(h) else { continue };
|
||||||
// Wire models carry NaN points as separators between disjoint
|
let bb = e.as_entity().bounding_box();
|
||||||
// segments; summing them poisons the whole centroid into NaN,
|
let c = glam::DVec3::new(
|
||||||
// which then makes every paste base point NaN. (#129)
|
(bb.min.x + bb.max.x) * 0.5,
|
||||||
if !p[0].is_finite() || !p[1].is_finite() || !p[2].is_finite() {
|
(bb.min.y + bb.max.y) * 0.5,
|
||||||
continue;
|
(bb.min.z + bb.max.z) * 0.5,
|
||||||
}
|
);
|
||||||
let l = w.points_low.get(i).copied().unwrap_or([0.0; 3]);
|
if c.x.is_finite() && c.y.is_finite() && c.z.is_finite() {
|
||||||
sum += glam::DVec3::new(
|
sum += c;
|
||||||
p[0] as f64 + l[0] as f64,
|
|
||||||
p[1] as f64 + l[1] as f64,
|
|
||||||
p[2] as f64 + l[2] as f64,
|
|
||||||
);
|
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ use crate::scene::view::dispatch;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
use acadrust::{EntityType, Handle};
|
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 {
|
impl OpenCADStudio {
|
||||||
/// Rebuild the PropertiesPanel from the current entity selection.
|
/// Rebuild the PropertiesPanel from the current entity selection.
|
||||||
/// Preserves UI state (open pickers, edit buffer) across refreshes.
|
/// Preserves UI state (open pickers, edit buffer) across refreshes.
|
||||||
|
|
@ -432,6 +437,23 @@ impl OpenCADStudio {
|
||||||
..Default::default()
|
..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 groups = build_selection_groups(&selected);
|
||||||
let active_group = selected_group
|
let active_group = selected_group
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,78 @@ use crate::command::{CadCommand, CmdResult};
|
||||||
use crate::scene::model::wire_model::WireModel;
|
use crate::scene::model::wire_model::WireModel;
|
||||||
|
|
||||||
pub struct PasteCommand {
|
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>,
|
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 of the clipboard entities (offset origin for translation).
|
||||||
centroid: Vec3,
|
centroid: Vec3,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PasteCommand {
|
impl PasteCommand {
|
||||||
|
/// `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<WireModel>, centroid: Vec3) -> Self {
|
pub fn new(wires: Vec<WireModel>, centroid: Vec3) -> Self {
|
||||||
Self { wires, centroid }
|
let total_points: usize = wires.iter().map(|w| w.points.len()).sum();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 +118,13 @@ impl CadCommand for PasteCommand {
|
||||||
vec![]
|
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;
|
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()
|
self.wires.iter().map(|w| w.translated(delta)).collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ impl Scene {
|
||||||
handles
|
handles
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|h| {
|
.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
|
// Hatches carry no outline in the normal wire set, but an
|
||||||
// edit preview (move / copy / array / grip-drag) needs to
|
// edit preview (move / copy / array / grip-drag) needs to
|
||||||
// show the shape following the cursor. Build a live boundary
|
// 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 plain: Vec<WireModel> = Vec::new();
|
||||||
let mut texts: Vec<(WireModel, glam::DVec3)> = Vec::new();
|
let mut texts: Vec<(WireModel, glam::DVec3)> = Vec::new();
|
||||||
for h in handles {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
if matches!(e, EntityType::Text(_)) {
|
if matches!(e, EntityType::Text(_)) {
|
||||||
|
|
|
||||||
21
src/snap.rs
21
src/snap.rs
|
|
@ -878,6 +878,19 @@ impl Snapper {
|
||||||
&& cursor_world.y - r <= wire.aabb[3] as f64
|
&& 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 mut try_pt = |world: glam::DVec3, snap_type: SnapType| {
|
||||||
let screen = world_to_screen(world, view_rot, eye, bounds);
|
let screen = world_to_screen(world, view_rot, eye, bounds);
|
||||||
if !in_bounds(screen) {
|
if !in_bounds(screen) {
|
||||||
|
|
@ -1018,8 +1031,8 @@ impl Snapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Intersection — segment-segment intersections ──────────
|
// ── Intersection — segment-segment intersections (pairwise, gated) ──
|
||||||
if self.is_on(SnapType::Intersection) {
|
if self.is_on(SnapType::Intersection) && allow_pairwise {
|
||||||
for i in 0..wires.len() {
|
for i in 0..wires.len() {
|
||||||
if !wire_in_range(&wires[i]) {
|
if !wire_in_range(&wires[i]) {
|
||||||
continue;
|
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.
|
// 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<Option<Vec<Point>>> = wires
|
let screen_pts: Vec<Option<Vec<Point>>> = wires
|
||||||
.iter()
|
.iter()
|
||||||
.map(|w| {
|
.map(|w| {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue