collective

Browse / github.com/storytold/cadcraft / patch

perf: Select All in a large drawing no longer stalls every frame

Select All on a large drawing takes about 19 s to settle and clearing takes about 10 s: the Properties palette does per-frame work that grows with the selection ## Summary In the test drawing, selecting everything (905,000 entities) settles after about 19 s, and clearing that selection blocks the control channel for about 10 s. A stack sample during the operation puts most main-thread time in the Properties palette, which looks up every selected entity on each frame while the palette is shown. The per-frame cost therefore grows with the size of the selection, not with the size of the change. ## Background - The desktop app shows a Properties palette for the current selection, and a quick-properties panel for the same selection. Both compute summaries

Status
open
Score
0.00
Adopters
0 other operators
Author
tarikjabiri (imported from pull request #2233, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+768 −222 in 23 files
Review
pending
Shared
2026-10-28
Id
ff6bbdc7b22e634298e80334

How it went

No other operator has reported using it yet.

Diff

raw · crates/doc/src/store.rs crates/engine/src/blockedit.rs crates/engine/src/cmd/view/named_views.rs crates/engine/src/doc_state.rs crates/engine/src/finite.rs crates/engine/src/lib.rs crates/engine/src/lisp.rs crates/engine/src/running.rs crates/engine/src/selection.rs crates/engine/src/session/execute.rs crates/engine/tests/it/query_entities.rs crates/engine/tests/new_open_end_command.rs crates/ui-egui/src/canvas/grips.rs crates/ui-egui/src/cmdline.rs crates/ui-egui/src/context_menu.rs crates/ui-egui/src/dyngrips.rs crates/ui-egui/src/hatchdlg.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/palettes.rs crates/ui-egui/src/quick.rs crates/ui-egui/src/summary.rs crates/ui-egui/src/vpscale.rs docs/hardware-parity.md

diff --git a/crates/doc/src/store.rs b/crates/doc/src/store.rs--- a/crates/doc/src/store.rs+++ b/crates/doc/src/store.rs@@ -3,6 +3,12 @@ //! Entities live in chunks (`Arc<Vec<Arc<Entity>>>`) in draw order, and a sharded handle index //! maps each handle to the stable id of its chunk. Cloning a store (an undo snapshot) copies //! only the chunk and shard pointers; an edit copies one chunk and one shard.+//!+//! A lookup by handle is two hash lookups (the handle's chunk id, the chunk's position) and a+//! scan of the chunk's handles, kept beside its entities so the scan reads one contiguous array+//! rather than chasing a pointer per entity. Looking up every object of a large drawing (a+//! selection of all of them) used to spend most of its time finding the chunk by scanning the+//! chunk list.  use std::collections::HashMap; use std::sync::Arc;@@ -16,11 +22,36 @@ const SHARDS: usize = 64; struct Chunk {     id: u32,     items: Vec<Arc<Entity>>,+    /// `items[i].handle`, for the lookup scan.+    handles: Vec<Handle>,+}++impl Chunk {+    fn find(&self, h: Handle) -> Option<usize> {+        self.handles.iter().position(|&x| x == h)+    }+    fn push(&mut self, e: Arc<Entity>) {+        self.handles.push(e.handle);+        self.items.push(e);+    }+    fn insert(&mut self, i: usize, e: Arc<Entity>) {+        self.handles.insert(i, e.handle);+        self.items.insert(i, e);+    }+    fn remove(&mut self, i: usize) -> Arc<Entity> {+        self.handles.remove(i);+        self.items.remove(i)+    }+    fn split_off(&mut self, at: usize) -> Chunk {+        Chunk { id: 0, items: self.items.split_off(at), handles: self.handles.split_off(at) }+    } }  #[derive(Clone, Debug)] pub struct EntityStore {     chunks: Vec<Arc<Chunk>>,+    /// Chunk id → its position in `chunks`; remade whenever a chunk comes, goes or moves.+    positions: Arc<HashMap<u32, usize>>,     shards: Vec<Arc<HashMap<Handle, u32>>>,     len: usize,     next_chunk: u32,@@ -28,7 +59,13 @@ pub struct EntityStore {  impl Default for EntityStore {     fn default() -> Self {-        EntityStore { chunks: Vec::new(), shards: (0..SHARDS).map(|_| Arc::new(HashMap::new())).collect(), len: 0, next_chunk: 0 }+        EntityStore {+            chunks: Vec::new(),+            positions: Arc::default(),+            shards: (0..SHARDS).map(|_| Arc::new(HashMap::new())).collect(),+            len: 0,+            next_chunk: 0,+        }     } } @@ -67,11 +104,15 @@ impl EntityStore {         self.shards.get(shard_of(h)).and_then(|s| s.get(&h).copied())     }     fn chunk_pos(&self, id: u32) -> Option<usize> {-        self.chunks.iter().position(|c| c.id == id)+        self.positions.get(&id).copied()+    }+    /// Remake `positions` after `chunks` changed.+    fn place_chunks(&mut self) {+        self.positions = Arc::new(self.chunks.iter().enumerate().map(|(i, c)| (c.id, i)).collect());     }     pub fn get(&self, h: Handle) -> Option<&Arc<Entity>> {-        let pos = self.chunk_pos(self.chunk_id(h)?)?;-        self.chunks.get(pos)?.items.iter().find(|e| e.handle == h)+        let chunk = self.chunks.get(self.chunk_pos(self.chunk_id(h)?)?)?;+        chunk.items.get(chunk.find(h)?)     }     fn set_index(&mut self, h: Handle, chunk: Option<u32>) {         if let Some(shard) = self.shards.get_mut(shard_of(h)) {@@ -89,7 +130,7 @@ impl EntityStore {     fn new_chunk(&mut self) -> Arc<Chunk> {         let id = self.next_chunk;         self.next_chunk = self.next_chunk.wrapping_add(1);-        Arc::new(Chunk { id, items: Vec::with_capacity(64) })+        Arc::new(Chunk { id, items: Vec::with_capacity(64), handles: Vec::with_capacity(64) })     }     /// Append on top of the draw order. Replaces an entity with the same handle in place.     pub fn push(&mut self, e: Entity) {@@ -97,15 +138,20 @@ impl EntityStore {             self.replace(e);             return;         }+        self.push_new(Arc::new(e));+    }+    /// Append `e`, which is not in the store, on top of the draw order.+    fn push_new(&mut self, e: Arc<Entity>) {         let needs_new = self.chunks.last().is_none_or(|c| c.items.len() >= CHUNK_MAX);         if needs_new {             let c = self.new_chunk();             self.chunks.push(c);+            self.place_chunks();         }         let h = e.handle;         let Some(last) = self.chunks.last_mut() else { return };         let chunk = Arc::make_mut(last);-        chunk.items.push(Arc::new(e));+        chunk.push(e);         let id = chunk.id;         self.set_index(h, Some(id));         self.len += 1;@@ -120,11 +166,12 @@ impl EntityStore {         if needs_new {             let c = self.new_chunk();             self.chunks.insert(0, c);+            self.place_chunks();         }         let h = e.handle;         let Some(first) = self.chunks.first_mut() else { return };         let chunk = Arc::make_mut(first);-        chunk.items.insert(0, Arc::new(e));+        chunk.insert(0, Arc::new(e));         let id = chunk.id;         self.set_index(h, Some(id));         self.len += 1;@@ -135,11 +182,12 @@ impl EntityStore {         let Some(pos) = self.chunk_pos(id) else { return false };         let Some(c) = self.chunks.get_mut(pos) else { return false };         let chunk = Arc::make_mut(c);-        if let Some(slot) = chunk.items.iter_mut().find(|x| x.handle == e.handle) {-            *slot = Arc::new(e);-            true-        } else {-            false+        match chunk.find(e.handle).and_then(|i| chunk.items.get_mut(i)) {+            Some(slot) => {+                *slot = Arc::new(e);+                true+            }+            None => false,         }     }     pub fn remove(&mut self, h: Handle) -> Option<Arc<Entity>> {@@ -147,11 +195,11 @@ impl EntityStore {         let pos = self.chunk_pos(id)?;         let c = self.chunks.get_mut(pos)?;         let chunk = Arc::make_mut(c);-        let i = chunk.items.iter().position(|x| x.handle == h)?;-        let e = chunk.items.remove(i);-        let empty = chunk.items.is_empty();-        if empty {+        let i = chunk.find(h)?;+        let e = chunk.remove(i);+        if chunk.items.is_empty() {             self.chunks.remove(pos);+            self.place_chunks();         }         self.set_index(h, None);         self.len = self.len.saturating_sub(1);@@ -163,9 +211,13 @@ impl EntityStore {         let Some(pos) = self.chunk_pos(id) else { return false };         let Some(c) = self.chunks.get_mut(pos) else { return false };         let chunk = Arc::make_mut(c);-        match chunk.items.iter_mut().find(|x| x.handle == h) {+        match chunk.find(h).and_then(|i| chunk.items.get_mut(i)) {             Some(slot) => {-                f(Arc::make_mut(slot));+                let e = Arc::make_mut(slot);+                f(e);+                // The handle is the entity's identity in the store (its chunk's index, the+                // shards): the closure cannot change it.+                e.handle = h;                 true             }             None => false,@@ -174,7 +226,7 @@ impl EntityStore {     pub fn bring_to_front(&mut self, h: Handle) -> bool {         match self.remove(h) {             Some(e) => {-                self.push(Arc::unwrap_or_clone(e));+                self.push_new(e);                 true             }             None => false,@@ -199,18 +251,18 @@ impl EntityStore {         let at = self.chunk_id(anchor).and_then(|id| Some((id, self.chunk_pos(id)?)));         let Some((id, pos)) = at else {             // Unreachable in practice (the anchor was present); keep the object on top.-            self.push(Arc::unwrap_or_clone(e));+            self.push_new(e);             return false;         };         let Some(c) = self.chunks.get_mut(pos) else {             // Unreachable in practice too; never lose the object.-            self.push(Arc::unwrap_or_clone(e));+            self.push_new(e);             return false;         };         let chunk = Arc::make_mut(c);-        let i = chunk.items.iter().position(|x| x.handle == anchor).unwrap_or(chunk.items.len());+        let i = chunk.find(anchor).unwrap_or(chunk.items.len());         let i = if above { (i + 1).min(chunk.items.len()) } else { i };-        chunk.items.insert(i, e);+        chunk.insert(i, e);         let split = chunk.items.len() > 2 * CHUNK_MAX;         self.set_index(h, Some(id));         self.len += 1;@@ -224,11 +276,13 @@ impl EntityStore {         let mut fresh = self.new_chunk();         let Some(c) = self.chunks.get_mut(pos) else { return };         let chunk = Arc::make_mut(c);-        let tail = chunk.items.split_off(chunk.items.len() / 2);+        let tail = chunk.split_off(chunk.items.len() / 2);         let new = Arc::make_mut(&mut fresh);-        new.items = tail;-        let (id, moved): (u32, Vec<Handle>) = (new.id, new.items.iter().map(|e| e.handle).collect());+        new.items = tail.items;+        new.handles = tail.handles;+        let (id, moved) = (new.id, new.handles.clone());         self.chunks.insert(pos + 1, fresh);+        self.place_chunks();         for h in moved {             self.set_index(h, Some(id));         }@@ -239,18 +293,7 @@ impl EntityStore {         items.sort_by_cached_key(|e| key(e));         *self = EntityStore::default();         for e in items {-            let needs_new = self.chunks.last().is_none_or(|c| c.items.len() >= CHUNK_MAX);-            if needs_new {-                let c = self.new_chunk();-                self.chunks.push(c);-            }-            let h = e.handle;-            let Some(last) = self.chunks.last_mut() else { return };-            let chunk = Arc::make_mut(last);-            chunk.items.push(e);-            let id = chunk.id;-            self.set_index(h, Some(id));-            self.len += 1;+            self.push_new(e);         }     }     /// True when `other` shares every chunk with `self` (same draw order, same entities). Holding@@ -308,6 +351,17 @@ mod tests {         assert_eq!(s.get(Handle(4)).map(|e| e.common.layer.as_str()), Some("X"));     } +    #[test]+    fn modify_keeps_the_handle() {+        let mut s = EntityStore::new();+        s.push(ent(1));+        s.push(ent(2));+        assert!(s.modify(Handle(1), |e| e.handle = Handle(9)));+        assert_eq!(s.get(Handle(1)).map(|e| e.handle), Some(Handle(1)));+        assert!(s.get(Handle(9)).is_none());+        check(&s);+    }+     #[test]     fn draw_order_moves() {         let mut s = EntityStore::new();@@ -364,4 +418,114 @@ mod tests {             assert_eq!(s.get(Handle(h)).map(|e| e.handle.0), Some(h));         }     }++    /// Every handle in the shard index names a chunk that is placed, which lists it where its+    /// entity is.+    fn check(s: &EntityStore) {+        assert_eq!(s.positions.len(), s.chunks.len());+        for (i, c) in s.chunks.iter().enumerate() {+            assert_eq!(s.positions.get(&c.id), Some(&i));+            assert_eq!(c.handles, c.items.iter().map(|e| e.handle).collect::<Vec<_>>());+            assert!(!c.items.is_empty());+        }+        let indexed: usize = s.shards.iter().map(|m| m.len()).sum();+        assert_eq!((indexed, s.iter().count()), (s.len(), s.len()));+        for e in s.iter() {+            assert!(Arc::ptr_eq(s.get(e.handle).unwrap(), e));+        }+    }++    /// Lookups stay right through every kind of edit, in a long random mix of them, with+    /// snapshots taken along the way left as they were. The edit and its variant are drawn+    /// apart, so each path runs (and the test says so).+    #[test]+    fn lookups_survive_every_edit() {+        let mut s = EntityStore::new();+        let mut seed = 0x2545_F491_4F6C_DD1Du64;+        let mut next = || {+            seed ^= seed << 13;+            seed ^= seed >> 7;+            seed ^= seed << 17;+            seed+        };+        let mut live: Vec<u64> = Vec::new();+        let mut snapshots: Vec<(EntityStore, Vec<u64>)> = Vec::new();+        let mut ran: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();+        for step in 0..6000u64 {+            let (r, variant) = (next(), next() % 2 == 0);+            let pick = |live: &[u64], r: u64| live.get((r >> 8) as usize % live.len().max(1)).copied();+            match r % 9 {+                0..=3 => {+                    let h = step + 1;+                    if variant {+                        s.push(ent(h));+                        ran.insert("push");+                    } else {+                        s.push_front(ent(h));+                        ran.insert("push_front");+                    }+                    live.push(h);+                }+                4 => {+                    if let Some(h) = pick(&live, r) {+                        assert!(s.remove(Handle(h)).is_some());+                        live.retain(|&x| x != h);+                        ran.insert("remove");+                    }+                }+                5 => {+                    if let Some(h) = pick(&live, r) {+                        assert!(s.modify(Handle(h), |e| e.common.layer = "M".into()));+                        assert_eq!(s.get(Handle(h)).map(|e| e.common.layer.as_str()), Some("M"));+                        ran.insert("modify");+                    }+                }+                6 => {+                    if let Some(h) = pick(&live, r) {+                        if variant {+                            assert!(s.bring_to_front(Handle(h)));+                            ran.insert("bring_to_front");+                        } else {+                            assert!(s.send_to_back(Handle(h)));+                            ran.insert("send_to_back");+                        }+                    }+                }+                7 => {+                    if let (Some(h), Some(a)) = (pick(&live, r), pick(&live, next())) {+                        assert_eq!(s.move_next_to(Handle(h), Handle(a), variant), h != a);+                        ran.insert(if variant { "move above" } else { "move under" });+                    }+                }+                _ => {+                    if let Some(h) = pick(&live, r) {+                        assert!(s.replace(ent(h)));+                        ran.insert("replace");+                    }+                }+            }+            if step % 500 == 0 {+                snapshots.push((s.clone(), live.clone()));+            }+            if step % 97 == 0 {+                check(&s);+            }+        }+        assert_eq!(ran.len(), 9, "every edit path ran: {ran:?}");+        assert_eq!(snapshots.len(), 12);+        check(&s);+        assert_eq!(s.len(), live.len());+        for h in &live {+            assert_eq!(s.get(Handle(*h)).map(|e| e.handle.0), Some(*h));+        }+        s.sort_by_key(|e| std::cmp::Reverse(e.handle.0));+        check(&s);+        for (snap, had) in &snapshots {+            check(snap);+            assert_eq!(snap.len(), had.len());+            for h in had {+                assert!(snap.get(Handle(*h)).is_some());+            }+        }+    } }diff --git a/crates/engine/src/blockedit.rs b/crates/engine/src/blockedit.rs--- a/crates/engine/src/blockedit.rs+++ b/crates/engine/src/blockedit.rs@@ -302,7 +302,7 @@ impl DocState {         self.undo_ctl = host.undo_ctl;         // The drawing comes back with nothing selected; Previous is what was selected before.         self.selection.clear();-        self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };+        self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection.into_vec() };         self.space = host.space;         self.mspace = host.mspace;         self.tiles = host.tiles;diff --git a/crates/engine/src/cmd/view/named_views.rs b/crates/engine/src/cmd/view/named_views.rs--- a/crates/engine/src/cmd/view/named_views.rs+++ b/crates/engine/src/cmd/view/named_views.rs@@ -166,7 +166,10 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {             }             let (w, h) = s.viewport_px;             let st = s.state_mut()?;-            st.mspace = None;+            // Leaving an active viewport deselects its model-space objects, as PSPACE does.+            if st.mspace.take().is_some() {+                st.selection.clear();+            }             let height = v.height.max(v.width / (w / h.max(1.0)).max(1e-9));             st.set_view(View { center: v.center, height });         }diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs--- a/crates/engine/src/doc_state.rs+++ b/crates/engine/src/doc_state.rs@@ -5,7 +5,7 @@ use super::*; pub struct Snapshot {     pub label: String,     pub doc: Arc<Drawing>,-    pub selection: Vec<Handle>,+    pub selection: Selection, }  /// One open drawing.@@ -16,7 +16,7 @@ pub struct DocState {     pub redo: Vec<Snapshot>,     /// UNDO marks, BEgin/End group and Control (see [`undoctl`]).     pub undo_ctl: undoctl::UndoCtl,-    pub selection: Vec<Handle>,+    pub selection: Selection,     pub previous_selection: Vec<Handle>,     pub path: Option<String>,     pub title: String,@@ -48,7 +48,7 @@ impl DocState {             undo: Vec::new(),             redo: Vec::new(),             undo_ctl: undoctl::UndoCtl::default(),-            selection: Vec::new(),+            selection: Selection::default(),             previous_selection: Vec::new(),             path,             title: title.into(),diff --git a/crates/engine/src/finite.rs b/crates/engine/src/finite.rs--- a/crates/engine/src/finite.rs+++ b/crates/engine/src/finite.rs@@ -13,13 +13,13 @@ use cadcraft_doc::{Drawing, Entity, EntityStore, Handle, entity_bounds}; use serde::Serialize; use serde::ser; -use crate::{EngineError, Session};+use crate::{EngineError, Selection, Session};  impl Session {     /// After command `cmd` changed the drawing from `before` (the document `uid`, or the active     /// one): when an object it changed has NaN or infinite numbers, put the drawing and selection     /// back and return the error to report.-    pub(crate) fn refuse_non_finite(&mut self, uid: Option<u64>, before: &Arc<Drawing>, selection: &[Handle], cmd: &str) -> crate::Result<()> {+    pub(crate) fn refuse_non_finite(&mut self, uid: Option<u64>, before: &Arc<Drawing>, selection: &Selection, cmd: &str) -> crate::Result<()> {         let st = match uid {             Some(uid) => self.docs.iter_mut().find(|d| d.uid == uid),             None => self.state_mut().ok(),@@ -27,7 +27,7 @@ impl Session {         let Some(st) = st.filter(|st| !Arc::ptr_eq(before, &st.doc)) else { return Ok(()) };         let Some(h) = first_non_finite(before, &st.doc) else { return Ok(()) };         st.doc = before.clone();-        st.selection = selection.to_vec();+        st.selection = selection.clone();         Err(EngineError::BadParams {             cmd: cmd.into(),             msg: format!("the result is too large: object {} would get infinite or undefined coordinates. Nothing was changed", h.hex()),diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -53,6 +53,7 @@ mod doc_state; mod error; mod last_used; mod running;+mod selection; mod session; mod settings; mod view;@@ -61,6 +62,7 @@ pub use doc_state::{DocState, Snapshot}; pub use error::{EngineError, Result}; pub use last_used::{LastUsed, LeaderOrder, LeaderType, MLeaderOptions}; pub use running::{PendingWindow, Running};+pub use selection::Selection; pub use settings::Settings; pub use view::{ModelTile, ModelTiles, View}; @@ -215,13 +217,28 @@ impl Session {     pub fn layout_space(&self) -> Space {         self.state().map(|s| s.space.clone()).unwrap_or_default()     }+    /// The selected objects, as a copy ([`Self::selected`] borrows them).     pub fn selection(&self) -> Vec<Handle> {-        self.state().map(|s| s.selection.clone()).unwrap_or_default()+        self.state().map(|s| s.selection.to_vec()).unwrap_or_default()     }+    /// The selected objects. Readers that only look (the palettes, hit tests, every frame)+    /// borrow them here: copying a selection of every object in a large drawing is not free.+    pub fn selected(&self) -> &[Handle] {+        self.state().map(|s| &s.selection[..]).unwrap_or(&[])+    }+    /// The selection's generation (see [`Selection`]): 0 with no drawing open, as for an empty+    /// selection.+    pub fn selection_generation(&self) -> u64 {+        self.state().map(|s| s.selection.generation()).unwrap_or(0)+    }+    /// Select `sel`: the objects of the edit space among them, each once, in the given order.+    /// Handles of another space, of deleted objects or given twice are dropped: a selection holds+    /// objects of the space commands act on, as AutoCAD's selection sets do.     pub fn set_selection(&mut self, sel: Vec<Handle>) {         if let Ok(st) = self.state_mut() {+            let store = st.doc.space(&st.edit_space());             let mut seen = std::collections::HashSet::new();-            st.selection = sel.into_iter().filter(|h| seen.insert(*h)).collect();+            st.selection.set(sel.into_iter().filter(|h| store.is_some_and(|s| s.contains(*h)) && seen.insert(*h)).collect());         }     } diff --git a/crates/engine/src/lisp.rs b/crates/engine/src/lisp.rs--- a/crates/engine/src/lisp.rs+++ b/crates/engine/src/lisp.rs@@ -158,7 +158,7 @@ struct Run {     /// Undo label: the command (`c:` function) or "LISP".     label: String,     /// The drawing and selection before the run (one undo step for the whole run).-    before: Option<(Arc<Drawing>, Vec<Handle>)>,+    before: Option<(Arc<Drawing>, crate::Selection)>,     /// Inputs given up front (`lisp {code, inputs}`): what `get…` calls answer.     inputs: std::collections::VecDeque<Value>,     /// Run to the end without waiting (programmatic calls): `get…` without an input gets nil.diff --git a/crates/engine/src/running.rs b/crates/engine/src/running.rs--- a/crates/engine/src/running.rs+++ b/crates/engine/src/running.rs@@ -5,7 +5,7 @@ pub struct Running {     pub id: String,     pub machine: Box<dyn Interactive>,     pub before: Arc<Drawing>,-    pub selection_before: Vec<Handle>,+    pub selection_before: crate::Selection, }  /// A pending window/crossing selection started by a click on empty space.diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/selection.rs@@ -0,0 +1,116 @@+//! The selected objects of a drawing, with a generation that stands for their contents.++use std::ops::Deref;+use std::sync::atomic::{AtomicU64, Ordering};++use cadcraft_doc::Handle;++/// The selected objects of a drawing.+///+/// Readers borrow the handles as a slice. Every change goes through [`Selection::set`] or+/// [`Selection::clear`], which give the selection a new generation: equal generations hold equal+/// handles (the empty selection is generation 0), so work kept per selection and looked at every+/// frame (the Properties palette's summary, the dynamic grips, the command line's typed-since+/// flag) is reused after comparing two numbers rather than every handle.+#[derive(Clone, Debug, Default)]+pub struct Selection {+    handles: Vec<Handle>,+    generation: u64,+}++/// Generations are handed out once across every drawing, so a selection restored from a copy (a+/// cancelled command, a refused edit) brings back a generation nothing else has held since.+static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);++impl Selection {+    /// Make `handles` the selection; the same handles again keep the generation.+    pub fn set(&mut self, handles: Vec<Handle>) {+        if handles != self.handles {+            self.generation = if handles.is_empty() { 0 } else { NEXT_GENERATION.fetch_add(1, Ordering::Relaxed) };+            self.handles = handles;+        }+    }++    /// Select nothing.+    pub fn clear(&mut self) {+        self.set(Vec::new());+    }++    /// A number that changes with the contents: equal generations hold equal handles, and 0 is+    /// the empty selection.+    pub fn generation(&self) -> u64 {+        self.generation+    }++    /// The handles, owned.+    pub fn into_vec(self) -> Vec<Handle> {+        self.handles+    }+}++impl From<Vec<Handle>> for Selection {+    fn from(handles: Vec<Handle>) -> Self {+        let mut s = Self::default();+        s.set(handles);+        s+    }+}++/// Selections are equal when they hold the same handles, whatever their generations.+impl PartialEq for Selection {+    fn eq(&self, other: &Self) -> bool {+        self.handles == other.handles+    }+}++impl Eq for Selection {}++impl Deref for Selection {+    type Target = [Handle];+    fn deref(&self) -> &[Handle] {+        &self.handles+    }+}++impl<'a> IntoIterator for &'a Selection {+    type Item = &'a Handle;+    type IntoIter = std::slice::Iter<'a, Handle>;+    fn into_iter(self) -> Self::IntoIter {+        self.handles.iter()+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn the_generation_follows_the_contents() {+        let mut s = Selection::default();+        assert!(s.is_empty() && s.generation() == 0);+        s.set(vec![Handle(1), Handle(2)]);+        let g = s.generation();+        assert_ne!(g, 0);+        assert_eq!(&s[..], [Handle(1), Handle(2)]);+        assert_eq!(s.iter().count(), 2);++        // The same handles set again keep the generation: nothing to rebuild.+        s.set(vec![Handle(1), Handle(2)]);+        assert_eq!(s.generation(), g);+        s.set(vec![Handle(2), Handle(1)]);+        assert_ne!(s.generation(), g);++        // A copy keeps its generation with its handles, so restoring it hands both back.+        let kept = s.clone();+        s.clear();+        assert_eq!((s.len(), s.generation()), (0, 0));+        s = kept.clone();+        assert_eq!((s.generation(), &s[..]), (kept.generation(), &kept[..]));+        assert_eq!(s, kept);+        assert_eq!(Selection::from(vec![Handle(2), Handle(1)]), kept, "equal handles, whatever the generation");++        assert_eq!(Selection::from(Vec::new()).generation(), 0);+        assert_ne!(Selection::from(vec![Handle(3)]).generation(), Selection::from(vec![Handle(3)]).generation());+        assert_eq!(Selection::from(vec![Handle(3)]).into_vec(), [Handle(3)]);+    }+}diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs--- a/crates/engine/src/session/execute.rs+++ b/crates/engine/src/session/execute.rs@@ -75,7 +75,7 @@ impl Session {         let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };         st.doc = snap.doc;         st.follow_layout(&cur.doc);-        st.selection = Vec::new();+        st.selection.clear();         st.redo.push(cur);         st.revision += 1;         Ok(Some(snap.label))@@ -87,7 +87,7 @@ impl Session {         let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };         st.doc = snap.doc;         st.follow_layout(&cur.doc);-        st.selection = Vec::new();+        st.selection.clear();         st.undo.push(cur);         st.revision += 1;         Ok(Some(snap.label))diff --git a/crates/engine/tests/it/query_entities.rs b/crates/engine/tests/it/query_entities.rs--- a/crates/engine/tests/it/query_entities.rs+++ b/crates/engine/tests/it/query_entities.rs@@ -80,6 +80,24 @@ fn selection_filter_combines_before_paging_without_changing_state() {     } } +#[test]+fn set_selection_keeps_only_objects_of_the_edit_space() {+    use cadcraft_doc::{Handle, Space};+    let mut s = Session::new();+    s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap();+    let model = s.doc().unwrap().model.iter().next().unwrap().handle;+    s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();+    s.execute("circle", &json!({"center": [10, 10], "radius": 2})).unwrap();+    let paper = s.doc().unwrap().space(&Space::Paper("Layout1".into())).unwrap().iter().last().unwrap().handle;+    // In the layout: the paper object once; the model object, the repeat and a stale handle are+    // dropped, so the palettes, `properties.set` and the `entities` query agree on the selection.+    s.set_selection(vec![model, paper, paper, Handle(u64::MAX)]);+    assert_eq!(s.selection(), [paper]);+    s.state_mut().unwrap().space = Space::Model;+    s.set_selection(vec![model, paper]);+    assert_eq!(s.selection(), [model]);+}+ #[test] fn selection_is_empty_by_default_and_limited_to_active_space() {     use cadcraft_doc::{Handle, Space};@@ -91,7 +109,7 @@ fn selection_is_empty_by_default_and_limited_to_active_space() {     s.execute("circle", &json!({"center": [10, 10], "radius": 2})).unwrap();     let paper = s.doc().unwrap().space(&Space::Paper("Layout1".into())).unwrap().iter().last().unwrap().handle;     // Even stale, duplicate and foreign-space selection handles cannot leak entities.-    s.state_mut().unwrap().selection = vec![model, paper, paper, Handle(u64::MAX)];+    s.state_mut().unwrap().selection.set(vec![model, paper, paper, Handle(u64::MAX)]);     let page = s.execute("entities", &json!({"selected": true})).unwrap();     assert_eq!(page["count"], 1);     assert_eq!(page["entities"][0]["handle"], paper.hex());diff --git a/crates/engine/tests/new_open_end_command.rs b/crates/engine/tests/new_open_end_command.rs--- a/crates/engine/tests/new_open_end_command.rs+++ b/crates/engine/tests/new_open_end_command.rs@@ -78,5 +78,5 @@ fn new_without_a_command_keeps_the_selection_of_the_previous_drawing() {     let selected = s.selection();     assert_eq!(selected.len(), 1);     s.execute("new", &json!({"metric": true})).unwrap();-    assert_eq!(s.docs[0].selection, selected);+    assert_eq!(&s.docs[0].selection[..], &selected[..]); }diff --git a/crates/ui-egui/src/canvas/grips.rs b/crates/ui-egui/src/canvas/grips.rs--- a/crates/ui-egui/src/canvas/grips.rs+++ b/crates/ui-egui/src/canvas/grips.rs@@ -79,7 +79,7 @@ pub(super) fn grip_at(app: &CadApp, xf: &Xf, hover: Option<Pos2>) -> Option<(Han     let hp = hover?;     let st = app.session.state().ok()?;     let gs = app.session.settings.gripsize as f32;-    let sel = app.session.selection();+    let sel = app.session.selected();     if !app.session.shows_grips(sel.len()) {         return None;     }diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs--- a/crates/ui-egui/src/cmdline.rs+++ b/crates/ui-egui/src/cmdline.rs@@ -12,8 +12,8 @@ use crate::theme::Tokens; #[derive(Default)] pub struct CmdLine {     pub buffer: String,-    /// The selection as of the last frame, to notice when it changes.-    seen_selection: Vec<cadcraft_doc::Handle>,+    /// The selection's generation as of the last frame, to notice when it changes.+    seen_selection: u64,     /// Something was typed at the command line since the selection was made.     typed_since_selection: bool,     pub history: Vec<String>,@@ -175,10 +175,10 @@ fn suggestions(prefix: &str) -> Vec<Suggestion> { }  impl CmdLine {-    /// Track the selection: a new selection starts with nothing typed since.-    pub fn watch_selection(&mut self, sel: &[cadcraft_doc::Handle]) {-        if self.seen_selection != sel {-            self.seen_selection = sel.to_vec();+    /// Track the selection by its generation: a new selection starts with nothing typed since.+    pub fn watch_selection(&mut self, generation: u64) {+        if self.seen_selection != generation {+            self.seen_selection = generation;             self.typed_since_selection = false;         }     }@@ -290,8 +290,7 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {         return;     }     let events = ctx.input(|i| i.events.clone());-    let selection = app.session.selection();-    app.cmd.watch_selection(&selection);+    app.cmd.watch_selection(app.session.selection_generation());     let mac = ctx.os() == egui::os::OperatingSystem::Mac;     let text_prompt = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number);     if text_prompt {@@ -370,7 +369,7 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {                         press,                         &app.cmd.buffer,                         app.session.prompting(),-                        !app.session.selection().is_empty(),+                        !app.session.selected().is_empty(),                         app.cmd.typed_since_selection,                     );                     if erase {@@ -840,7 +839,6 @@ mod tests { #[cfg(test)] mod delete_key_tests {     use super::*;-    use cadcraft_doc::Handle;      const PRESS: KeyPress = KeyPress { repeat: false, plain: true }; @@ -868,13 +866,13 @@ mod delete_key_tests {     #[test]     fn typing_is_forgotten_when_the_selection_changes() {         let mut c = CmdLine::default();-        c.watch_selection(&[Handle(1)]);+        c.watch_selection(1);         c.buffer.push('m');         c.note_typing();         c.buffer.clear();-        c.watch_selection(&[Handle(1)]);+        c.watch_selection(1);         assert!(c.typed_since_selection);-        c.watch_selection(&[Handle(1), Handle(2)]);+        c.watch_selection(2);         assert!(!c.typed_since_selection);     } }diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs--- a/crates/ui-egui/src/context_menu.rs+++ b/crates/ui-egui/src/context_menu.rs@@ -104,7 +104,7 @@ pub fn entries(app: &CadApp) -> Vec<Vec<Entry>> {     let mut clip: Vec<Entry> = sub("Clipboard", items(app, CLIPBOARD)).into_iter().collect();     clip.extend(sub("Isolate", items(app, ISOLATE)));     push(&mut groups, clip);-    let selected = !app.session.selection().is_empty();+    let selected = !app.session.selected().is_empty();     if selected {         let mut edit: Vec<Entry> = EDIT.iter().filter_map(|(id, label)| item(app, id, *label)).collect();         edit.extend(sub("Draw Order", items(app, DRAW_ORDER)));@@ -126,7 +126,7 @@ pub fn entries(app: &CadApp) -> Vec<Vec<Entry>> { /// Polyline Edit, Spline Edit… fn object_commands(app: &CadApp) -> Vec<(&'static str, Option<&'static str>)> {     let Ok(d) = app.session.doc() else { return Vec::new() };-    let sel = app.session.selection();+    let sel = app.session.selected();     let ents: Vec<_> = sel.iter().filter_map(|h| d.entity(*h)).collect();     let Some(first) = ents.first() else { return Vec::new() };     let ty = first.kind.type_name();diff --git a/crates/ui-egui/src/dyngrips.rs b/crates/ui-egui/src/dyngrips.rs--- a/crates/ui-egui/src/dyngrips.rs+++ b/crates/ui-egui/src/dyngrips.rs@@ -18,29 +18,31 @@ use cadcraft_engine::doc::dynblock::{DynGrip, GripShape}; /// Most selected references whose grips are shown. const MAX_REFERENCES: usize = 200; -/// The dynamic grips of the selection, worked out once per drawing revision and selection.+/// The dynamic grips of the selection, worked out once per drawing revision and selection+/// generation. #[derive(Clone, Default)] pub struct Cache {-    key: (u64, u64, Vec<Handle>),+    key: (u64, u64, u64),     grips: Arc<Vec<(Handle, DynGrip)>>, }  /// The selected references' dynamic grips (cached). pub(crate) fn of_selection(app: &mut CadApp) -> Arc<Vec<(Handle, DynGrip)>> {-    let sel = app.session.selection();     let Ok(st) = app.session.state() else { return Arc::default() };-    let key = (st.uid, st.revision, sel.clone());-    if app.canvas.dyn_grips.key != key {-        let grips: Vec<(Handle, DynGrip)> = sel+    let key = (st.uid, st.revision, st.selection.generation());+    let cache = &mut app.canvas.dyn_grips;+    if cache.key != key {+        let grips: Vec<(Handle, DynGrip)> = st+            .selection             .iter()             .filter(|h| matches!(st.doc.entity(**h).map(|e| &e.kind), Some(cadcraft_engine::doc::EntityKind::Insert(_))))             .filter(|h| st.doc.entity(**h).is_some_and(|e| !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked)))             .take(MAX_REFERENCES)             .flat_map(|h| cadcraft_engine::doc::dynblock::grips(&st.doc, *h).into_iter().map(move |g| (*h, g)))             .collect();-        app.canvas.dyn_grips = Cache { key, grips: Arc::new(grips) };+        *cache = Cache { key, grips: Arc::new(grips) };     }-    app.canvas.dyn_grips.grips.clone()+    cache.grips.clone() }  /// The screen outline of grip `g` (size `gs`, half the side of a square grip).diff --git a/crates/ui-egui/src/hatchdlg.rs b/crates/ui-egui/src/hatchdlg.rs--- a/crates/ui-egui/src/hatchdlg.rs+++ b/crates/ui-egui/src/hatchdlg.rs@@ -297,7 +297,7 @@ pub fn open_create(app: &mut CadApp, gradient: bool) { /// pick a hatch first. pub fn open_edit(app: &mut CadApp) {     let Ok(st) = app.session.state() else { return };-    let (mut edit, mut create) = (edit_state(&st.doc, st.uid, st.selection.clone()), create_state(&st.doc, st.uid, false));+    let (mut edit, mut create) = (edit_state(&st.doc, st.uid, st.selection.to_vec()), create_state(&st.doc, st.uid, false));     create.space = st.edit_space();     if let Some(e) = edit.as_mut() {         e.space = st.edit_space();diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -47,6 +47,7 @@ pub mod parametric; pub mod plotstyles; pub mod quick; pub mod shortcut_menus;+pub(crate) mod summary; pub mod theme; #[cfg(not(target_arch = "wasm32"))] pub mod updates_ui;@@ -285,6 +286,8 @@ pub struct CadApp {     pub hatch_dlg: Option<hatchdlg::HatchDlgState>,     /// The Edit Block Definition dialog ([`bedit`]).     pub bedit: bedit::State,+    /// What the palettes show about the selection as a whole ([`summary`]).+    pub(crate) summary: summary::Cache,     /// The startup update check and its prompt (desktop; [`updates_ui`]).     #[cfg(not(target_arch = "wasm32"))]     pub updates_rt: updates_ui::Runtime,@@ -337,6 +340,7 @@ impl CadApp {             alias_dlg: None,             hatch_dlg: None,             bedit: bedit::State::default(),+            summary: summary::Cache::default(),             #[cfg(not(target_arch = "wasm32"))]             updates_rt: updates_ui::Runtime::default(),         }diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs--- a/crates/ui-egui/src/palettes.rs+++ b/crates/ui-egui/src/palettes.rs@@ -1,12 +1,13 @@ //! Tool Sets (left) and the Layers / Properties palettes (right). -use cadcraft_color::{Color, Rgb};+use cadcraft_color::Color; use cadcraft_doc::EntityKind; use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::{Value, json};  use crate::CadApp; use crate::icons::{self, Icon};+use crate::summary::VARIES; use crate::theme::Tokens;  #[cfg(test)]@@ -644,22 +645,10 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) {     ui.painter().text(half.center(), egui::Align2::CENTER_CENTER, crate::tl!("All"), crate::theme::small(), t.text);     ui.painter().text(my.center(), egui::Align2::CENTER_CENTER, crate::tl!("My"), crate::theme::small(), t.text_faint);     ui.interact(my, ui.scope_id().with("prop_my"), Sense::hover()).on_hover_text(crate::tl!("My properties: not available yet"));-    let sel = app.session.selection();+    let sum = crate::summary::of_selection(app);     let pickadd = app.session.settings.pickadd;     let Ok(d) = app.session.doc() else { return };-    // Selected handles by type, in first-seen order.-    let mut types: Vec<(&'static str, Vec<String>)> = Vec::new();-    for h in &sel {-        if let Some(e) = d.entity(*h) {-            let n = e.kind.type_name();-            match types.iter_mut().find(|(t, _)| *t == n) {-                Some((_, hs)) => hs.push(h.hex()),-                None => types.push((n, vec![h.hex()])),-            }-        }-    }-    let count: usize = types.iter().map(|(_, hs)| hs.len()).sum();-    let header = match (count, types.as_slice()) {+    let header = match (sum.count, sum.types.as_slice()) {         (0, _) => crate::tl!("No selection").to_string(),         (1, [(n, _)]) => crate::i18n::t(n).to_string(),         (n, [(ty, _)]) => format!("{} ({n})", crate::i18n::t(ty)),@@ -671,13 +660,21 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) {         ui.add_space(8.0);         ui.spacing_mut().item_spacing = vec2(3.0, 0.0);         let w = (ui.available_width() - 80.0).max(60.0);-        let resp = ui.allocate_ui(vec2(w, 20.0), |ui| value_box(ui, &header, true, count > 0)).inner;-        if count > 0 {-            // Narrow the selection to one object type.+        let resp = ui.allocate_ui(vec2(w, 20.0), |ui| value_box(ui, &header, true, sum.count > 0)).inner;+        if sum.count > 0 {+            // Narrow the selection to one object type (its objects are listed only when picked).             egui::Popup::from_toggle_button_response(&resp).width(w - 10.0).close_behavior(egui::PopupCloseBehavior::CloseOnClick).show(|ui| {-                for (ty, hs) in &types {-                    if ui.button(format!("{} ({})", crate::i18n::t(ty), hs.len())).clicked() {-                        run = Some(("select", json!({ "handles": hs })));+                for (ty, n) in &sum.types {+                    if ui.button(format!("{} ({n})", crate::i18n::t(ty))).clicked() {+                        let of_type: Vec<String> = app+                            .session+                            .selected()+                            .iter()+                            .filter_map(|h| d.entity(*h))+                            .filter(|e| e.kind.type_name() == *ty)+                            .map(|e| e.handle.hex())+                            .collect();+                        run = Some(("select", json!({ "handles": of_type })));                     }                 }             });@@ -709,7 +706,7 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {     let t = Tokens::get();     properties_header(app, ui);-    let sel = app.session.selection();+    let sum = crate::summary::of_selection(app);     let Ok(d) = app.session.doc() else { return };     ui.add_space(4.0);     let h = &d.header;@@ -717,7 +714,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {     let (lu, lp) = (h.i64("LUNITS", 2), h.i64("LUPREC", 4));     let len = |v: f64| cadcraft_engine::units::format_distance(v, lu, lp);     let mut bad: Option<String> = None;-    if sel.is_empty() {+    if sum.count == 0 {         // Current properties for new objects; each row runs the command that sets it.         let layers: Vec<String> = d.layers.iter().map(|l| l.name.clone()).collect();         let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();@@ -851,36 +848,19 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {         report_bad(app, bad);         return;     }-    // Selected objects: common + geometry for a single object.-    let first = sel.first().and_then(|h| d.entity(*h)).map(|e| (**e).clone());+    // Selected objects: the properties they share (from the summary, so the selection is not+    // scanned every frame), and the geometry of a single one. The setters leave `handles` out:+    // `properties.set` acts on the selection.+    let first = app.session.selected().iter().find_map(|h| d.entity(*h)).map(|e| (**e).clone());     let Some(e) = first else { return };-    let same = |f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String {-        let vals: Vec<String> = sel.iter().filter_map(|h| d.entity(*h)).map(|e| f(e)).collect();-        match vals.first() {-            Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(),-            _ => "*VARIES*".into(),-        }-    };-    let selected_color = Some(e.common.color).filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| e.common.color == *c));-    let color = selected_color.map_or_else(|| "*VARIES*".into(), Color::name);-    let effective_color = |e: &cadcraft_doc::Entity| {-        let layer = d.layer(&e.common.layer).map_or(Color::WHITE, |l| l.color);-        e.common.color.resolve(layer, Color::WHITE)-    };-    let resolved_color = sel-        .first()-        .and_then(|h| d.entity(*h))-        .map(|e| effective_color(e))-        .filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| effective_color(e) == *c))-        .unwrap_or(Rgb(255, 255, 255));-    let layer = same(&|e| e.common.layer.clone());-    let linetype = same(&|e| e.common.linetype.clone());-    let lts = same(&|e| format!("{:.4}", e.common.ltscale));-    let lw = same(&|e| e.common.lineweight.name());-    let tr = same(&|e| e.common.transparency.name());+    let selected_color = sum.color;+    let color = selected_color.map_or_else(|| VARIES.into(), Color::name);+    let resolved_color = sum.resolved;+    let (layer, linetype, lts, lw, tr) = (&sum.layer, &sum.linetype, &sum.ltscale, &sum.lineweight, &sum.transparency);     let layers: Vec<String> = d.layers.iter().map(|l| l.name.clone()).collect();     let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();-    let ids: Vec<String> = sel.iter().map(|h| h.hex()).collect();+    // Keys the fields' widget ids to the selection: a value typed for one is not carried to another.+    let key = sum.fingerprint;     let mut set: Option<Value> = None;     let mut custom_set: Option<Value> = None;     let group = |ui: &mut egui::Ui, title: &str| {@@ -893,14 +873,14 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {         let width = (ui.available_width() - 10.0).max(0.0);         if let Some(c) = crate::layers::color_button_resolved(             ui,-            egui::Id::unique(("properties_color", &ids)),+            egui::Id::unique(("properties_color", key)),             selected_color.unwrap_or(Color::ByLayer),             resolved_color,             &color,             true,             width,         ) {-            set = Some(json!({ "handles": ids, "color": c.name() }));+            set = Some(json!({ "color": c.name() }));         }     });     prop_row(ui, "Layer", |ui| {@@ -908,7 +888,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {             egui::ScrollArea::vertical().id_salt("properties_layer_menu").max_height(300.0).show(ui, |ui| {                 for l in &layers {                     if ui.button(l).clicked() {-                        set = Some(json!({ "handles": ids, "layer": l }));+                        set = Some(json!({ "layer": l }));                         ui.close();                     }                 }@@ -916,50 +896,50 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {         });     });     prop_row(ui, "Linetype", |ui| {-        ui.menu_button(if ["ByLayer", "ByBlock", "*VARIES*"].contains(&linetype.as_str()) { crate::i18n::t(&linetype) } else { &linetype }, |ui| {+        ui.menu_button(if ["ByLayer", "ByBlock", VARIES].contains(&linetype.as_str()) { crate::i18n::t(linetype) } else { linetype }, |ui| {             for l in std::iter::once("ByLayer".to_string())                 .chain(std::iter::once("ByBlock".to_string()))                 .chain(linetypes.iter().filter(|l| !["ByLayer", "ByBlock"].contains(&l.as_str())).cloned())             {                 if ui.button(if ["ByLayer", "ByBlock"].contains(&l.as_str()) { crate::i18n::t(&l) } else { &l }).clicked() {-                    set = Some(json!({ "handles": ids, "linetype": l }));+                    set = Some(json!({ "linetype": l }));                     ui.close();                 }             }         });     });     prop_row(ui, "Linetype scale", |ui| {-        if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", &ids)), lts.clone()), &mut bad) {-            set = Some(json!({ "handles": ids, "ltscale": f }));+        if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", key)), lts.clone()), &mut bad) {+            set = Some(json!({ "ltscale": f }));         }     });     prop_row(ui, "Lineweight", |ui| {-        ui.menu_button(crate::i18n::t(&lw), |ui| {+        ui.menu_button(crate::i18n::t(lw), |ui| {             for n in ["ByLayer", "ByBlock", "Default"] {                 if ui.button(crate::i18n::t(n)).clicked() {-                    set = Some(json!({ "handles": ids, "lineweight": n }));+                    set = Some(json!({ "lineweight": n }));                     ui.close();                 }             }             for v in cadcraft_doc::Lineweight::STANDARD {                 if ui.button(format!("{:.2} mm", f64::from(v) / 100.0)).clicked() {-                    set = Some(json!({ "handles": ids, "lineweight": f64::from(v) / 100.0 }));+                    set = Some(json!({ "lineweight": f64::from(v) / 100.0 }));                     ui.close();                 }             }         });     });     prop_row(ui, "Transparency", |ui| {-        ui.menu_button(crate::i18n::t(&tr), |ui| {+        ui.menu_button(crate::i18n::t(tr), |ui| {             for v in ["ByLayer", "ByBlock"] {                 if ui.button(crate::i18n::t(v)).clicked() {-                    set = Some(json!({ "handles": ids, "transparency": v }));+                    set = Some(json!({ "transparency": v }));                     ui.close();                 }             }             ui.separator();             // A fixed percentage, committed when the drag or edit ends.-            let tid = egui::Id::unique(("prop_tr", &ids));+            let tid = egui::Id::unique(("prop_tr", key));             let cur = tr.parse::<u8>().ok();             let mut pct = ui.data_mut(|d| d.get_temp::<u8>(tid)).or(cur).unwrap_or(0);             ui.horizontal(|ui| {@@ -970,61 +950,61 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {                 }                 if r.drag_stopped() || r.lost_focus() {                     if cur != Some(pct) {-                        set = Some(json!({ "handles": ids, "transparency": pct }));+                        set = Some(json!({ "transparency": pct }));                     }                     ui.data_mut(|d| d.remove::<u8>(tid));                 }             });         });     });-    if sel.len() == 1 {+    if sum.count == 1 {         group(ui, "Geometry");         let real = |v: f64| format!("{v:.4}");         // Rotations show as the drawing measures directions (ANGBASE, ANGDIR).         let angles = app.session.angle_settings();-        let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option<Value>, bad: &mut Option<String>, ids: &Vec<String>| {+        let num = |ui: &mut egui::Ui, label: &str, field: &str, shown: String, set: &mut Option<Value>, bad: &mut Option<String>| {             prop_row(ui, label, |ui| {-                if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), bad) {-                    *set = Some(json!({ "handles": ids, key: f }));+                if let Some(f) = number(edit_field(ui, ui.scope_id().with((field, key)), shown), bad) {+                    *set = Some(json!({ field: f }));                 }             });         };         match &e.kind {             EntityKind::Line(l) => {                 prop_row(ui, "Start X", |ui| {-                    if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", &ids)), len(l.a.x)), &mut bad) {-                        set = Some(json!({ "handles": ids, "start": [f, l.a.y] }));+                    if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", key)), len(l.a.x)), &mut bad) {+                        set = Some(json!({ "start": [f, l.a.y] }));                     }                 });                 prop_row(ui, "Start Y", |ui| {-                    if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", &ids)), len(l.a.y)), &mut bad) {-                        set = Some(json!({ "handles": ids, "start": [l.a.x, f] }));+                    if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", key)), len(l.a.y)), &mut bad) {+                        set = Some(json!({ "start": [l.a.x, f] }));                     }                 });                 prop_row(ui, "End X", |ui| {-                    if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", &ids)), len(l.b.x)), &mut bad) {-                        set = Some(json!({ "handles": ids, "end": [f, l.b.y] }));+                    if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", key)), len(l.b.x)), &mut bad) {+                        set = Some(json!({ "end": [f, l.b.y] }));                     }                 });                 prop_row(ui, "End Y", |ui| {-                    if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", &ids)), len(l.b.y)), &mut bad) {-                        set = Some(json!({ "handles": ids, "end": [l.b.x, f] }));+                    if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", key)), len(l.b.y)), &mut bad) {+                        set = Some(json!({ "end": [l.b.x, f] }));                     }                 });                 let dlt = l.b.xy() - l.a.xy();                 prop_row(ui, "Delta X", |ui| read_only(ui, &len(dlt.x)));                 prop_row(ui, "Delta Y", |ui| read_only(ui, &len(dlt.y)));                 // Length and angle keep the start point and move the end point.-                num(ui, "Length", "length", len(dlt.len()), &mut set, &mut bad, &ids);-                num(ui, "Angle", "angle", real(dlt.angle().to_degrees()), &mut set, &mut bad, &ids);+                num(ui, "Length", "length", len(dlt.len()), &mut set, &mut bad);+                num(ui, "Angle", "angle", real(dlt.angle().to_degrees()), &mut set, &mut bad);             }             EntityKind::Circle(c) => {-                num(ui, "Center X", "cx", len(c.center.x), &mut set, &mut bad, &ids);-                num(ui, "Center Y", "cy", len(c.center.y), &mut set, &mut bad, &ids);-                num(ui, "Radius", "radius", len(c.radius), &mut set, &mut bad, &ids);-                num(ui, "Diameter", "diameter", len(c.radius * 2.0), &mut set, &mut bad, &ids);-                num(ui, "Circumference", "circumference", len(c.radius * std::f64::consts::TAU), &mut set, &mut bad, &ids);-                num(ui, "Area", "area", real(c.radius * c.radius * std::f64::consts::PI), &mut set, &mut bad, &ids);+                num(ui, "Center X", "cx", len(c.center.x), &mut set, &mut bad);+                num(ui, "Center Y", "cy", len(c.center.y), &mut set, &mut bad);+                num(ui, "Radius", "radius", len(c.radius), &mut set, &mut bad);+                num(ui, "Diameter", "diameter", len(c.radius * 2.0), &mut set, &mut bad);+                num(ui, "Circumference", "circumference", len(c.radius * std::f64::consts::TAU), &mut set, &mut bad);+                num(ui, "Area", "area", real(c.radius * c.radius * std::f64::consts::PI), &mut set, &mut bad);                 if let Some(v) = &mut set                     && let Some(o) = v.as_object_mut()                 {@@ -1037,31 +1017,31 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {                 }             }             EntityKind::Arc(a) => {-                num(ui, "Radius", "radius", len(a.radius), &mut set, &mut bad, &ids);-                num(ui, "Start angle", "startAngle", real(a.start.to_degrees()), &mut set, &mut bad, &ids);-                num(ui, "End angle", "endAngle", real(a.end.to_degrees()), &mut set, &mut bad, &ids);+                num(ui, "Radius", "radius", len(a.radius), &mut set, &mut bad);+                num(ui, "Start angle", "startAngle", real(a.start.to_degrees()), &mut set, &mut bad);+                num(ui, "End angle", "endAngle", real(a.end.to_degrees()), &mut set, &mut bad);             }             EntityKind::Text(tx) => {                 prop_row(ui, "Contents", |ui| {-                    if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", &ids)), tx.value.clone()) {-                        set = Some(json!({ "handles": ids, "text": s }));+                    if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", key)), tx.value.clone()) {+                        set = Some(json!({ "text": s }));                     }                 });-                num(ui, "Height", "height", len(tx.height), &mut set, &mut bad, &ids);-                num(ui, "Rotation", "rotation", real(angles.shown(tx.rotation).to_degrees()), &mut set, &mut bad, &ids);-                num(ui, "Width factor", "widthFactor", real(tx.width_factor), &mut set, &mut bad, &ids);+                num(ui, "Height", "height", len(tx.height), &mut set, &mut bad);+                num(ui, "Rotation", "rotation", real(angles.shown(tx.rotation).to_degrees()), &mut set, &mut bad);+                num(ui, "Width factor", "widthFactor", real(tx.width_factor), &mut set, &mut bad);             }             EntityKind::MText(tx) => {                 prop_row(ui, "Contents", |ui| {-                    if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", &ids)), tx.contents.clone()) {-                        set = Some(json!({ "handles": ids, "text": s }));+                    if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", key)), tx.contents.clone()) {+                        set = Some(json!({ "text": s }));                     }                 });-                num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, &ids);-                num(ui, "Defined width", "width", len(tx.width), &mut set, &mut bad, &ids);+                num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad);+                num(ui, "Defined width", "width", len(tx.width), &mut set, &mut bad);             }             EntityKind::LwPolyline(pl) => {-                num(ui, "Global width", "width", len(pl.const_width), &mut set, &mut bad, &ids);+                num(ui, "Global width", "width", len(pl.const_width), &mut set, &mut bad);                 let g = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed };                 prop_row(ui, "Length", |ui| read_only(ui, &len(g.len())));                 if pl.closed {@@ -1071,7 +1051,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {                     let mut c = pl.closed;                     let label = if c { "Yes" } else { "No" };                     if ui.checkbox(&mut c, crate::i18n::t(label)).changed() {-                        set = Some(json!({ "handles": ids, "closed": c }));+                        set = Some(json!({ "closed": c }));                     }                 });             }@@ -1079,35 +1059,35 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {                 prop_row(ui, "Pattern", |ui| {                     ui.menu_button(hh.pattern.clone(), |ui| {                         if let Some(name) = patterns::menu(ui, &hh.pattern) {-                            set = Some(json!({ "handles": ids, "pattern": name }));+                            set = Some(json!({ "pattern": name }));                             ui.close();                         }                     });                 });-                num(ui, "Scale", "scale", real(hh.scale), &mut set, &mut bad, &ids);-                num(ui, "Angle", "angle", real(hh.angle.to_degrees()), &mut set, &mut bad, &ids);+                num(ui, "Scale", "scale", real(hh.scale), &mut set, &mut bad);+                num(ui, "Angle", "angle", real(hh.angle.to_degrees()), &mut set, &mut bad);             }             EntityKind::Dimension(dm) => {                 prop_row(ui, "Text override", |ui| {-                    if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", &ids)), dm.text.clone()) {-                        set = Some(json!({ "handles": ids, "textOverride": s }));+                    if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", key)), dm.text.clone()) {+                        set = Some(json!({ "textOverride": s }));                     }                 });                 prop_row(ui, "Dim style", |ui| {                     if let Some(n) = pick_menu(ui, &dm.style, d.dim_styles.iter().map(|st| st.name.clone())) {-                        set = Some(json!({ "handles": ids, "dimStyle": n }));+                        set = Some(json!({ "dimStyle": n }));                     }                 });                 // The style's values with this dimension's own overrides; editing one overrides                 // it for the selected dimensions, as AutoCAD's Properties palette does.                 let st = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);-                num(ui, "Text height", "textHeight", len(st.text_height), &mut set, &mut bad, &ids);-                num(ui, "Arrow size", "arrowSize", len(st.arrow_size), &mut set, &mut bad, &ids);-                num(ui, "Dim scale overall", "dimScale", real(st.scale), &mut set, &mut bad, &ids);+                num(ui, "Text height", "textHeight", len(st.text_height), &mut set, &mut bad);+                num(ui, "Arrow size", "arrowSize", len(st.arrow_size), &mut set, &mut bad);+                num(ui, "Dim scale overall", "dimScale", real(st.scale), &mut set, &mut bad);                 prop_row(ui, "Precision", |ui| {                     let cur = st.decimals.to_string();                     if let Some(n) = pick_menu(ui, &cur, (0..=8).map(|n: u8| n.to_string())).and_then(|n| n.parse::<u8>().ok()) {-                        set = Some(json!({ "handles": ids, "precision": n }));+                        set = Some(json!({ "precision": n }));                     }                 });             }@@ -1120,8 +1100,8 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {                 prop_row(ui, "Position X", |ui| read_only(ui, &len(i.insert.x)));                 prop_row(ui, "Position Y", |ui| read_only(ui, &len(i.insert.y)));                 prop_row(ui, "Position Z", |ui| read_only(ui, &len(i.insert.z)));-                num(ui, "Rotation", "rotation", real(angles.shown(i.rotation).to_degrees()), &mut set, &mut bad, &ids);-                num(ui, "Scale", "scale", real(i.scale.x), &mut set, &mut bad, &ids);+                num(ui, "Rotation", "rotation", real(angles.shown(i.rotation).to_degrees()), &mut set, &mut bad);+                num(ui, "Scale", "scale", real(i.scale.x), &mut set, &mut bad);                 if let Some((_, props)) = custom.filter(|(_, p)| !p.is_empty()) {                     group(ui, "Custom");                     for p in &props {diff --git a/crates/ui-egui/src/quick.rs b/crates/ui-egui/src/quick.rs--- a/crates/ui-egui/src/quick.rs+++ b/crates/ui-egui/src/quick.rs@@ -1,7 +1,7 @@ //! Quick Select (QSELECT dialog → the engine `qselect` command) and the Quick Properties //! palette shown near the cursor while objects are selected and QPMODE is on. -use cadcraft_doc::{EntityKind, Handle};+use cadcraft_doc::EntityKind; use egui::{Pos2, RichText, vec2}; use serde_json::{Value, json}; @@ -226,7 +226,8 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {  #[derive(Clone, Debug, Default)] struct QpState {-    sel: Vec<Handle>,+    /// The selection the palette was placed for ([`crate::summary::Summary::fingerprint`]).+    key: Option<u64>,     at: Option<Pos2>, } @@ -251,58 +252,50 @@ fn ro_row(ui: &mut egui::Ui, label: &str, v: String) { /// The Quick Properties palette, near where the selection was made. pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {     let sid = egui::Id::unique("qp_state");-    let sel = app.session.selection();+    let sum = crate::summary::of_selection(app);     let active = app.session.settings.qpmode-        && !sel.is_empty()+        && sum.count > 0         && app.session.running.is_none()         && !app.ui.start_tab         && app.ui.dialog.as_deref() != Some("qselect");     let mut st: QpState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default();     if !active {-        if !st.sel.is_empty() {+        if st.key.is_some() {             ctx.data_mut(|d| d.insert_temp(sid, QpState::default()));         }         return;     }-    if st.sel != sel || st.at.is_none() {+    if st.key != Some(sum.fingerprint) || st.at.is_none() {         let hp = ctx.input(|i| i.pointer.hover_pos());         let r = app.canvas.rect.unwrap_or(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));         let p = hp.filter(|p| r.contains(*p)).unwrap_or(r.center()) + vec2(28.0, 28.0);         // Keep it on the canvas.         let p = Pos2::new(p.x.min(r.right() - 270.0).max(r.left()), p.y.min(r.bottom() - 200.0).max(r.top()));-        st.sel = sel.clone();+        st.key = Some(sum.fingerprint);         st.at = Some(p);     }     let Some(at) = st.at else { return };     let Ok(d) = app.session.doc() else { return };-    let ents: Vec<_> = sel.iter().filter_map(|h| d.entity(*h)).collect();-    let Some(first) = ents.first() else { return };-    let ty = first.kind.type_name();-    let same_type = ents.iter().all(|e| e.kind.type_name() == ty);-    let title = if ents.len() == 1 {-        ty.to_string()-    } else if same_type {-        format!("{} ({})", crate::i18n::t(ty), ents.len())-    } else {-        format!("{} ({})", crate::tl!("All"), ents.len())+    // The shared properties come from the summary (the selection is not scanned every frame);+    // the setters leave `handles` out, `properties.set` acting on the selection.+    let title = match (sum.count, sum.types.as_slice()) {+        (1, [(ty, _)]) => ty.to_string(),+        (n, [(ty, _)]) => format!("{} ({n})", crate::i18n::t(ty)),+        (n, _) => format!("{} ({n})", crate::tl!("All")),     };-    let same = |f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String {-        let v0 = ents.first().map(|e| f(e)).unwrap_or_default();-        if ents.iter().all(|e| f(e) == v0) { v0 } else { "*VARIES*".into() }-    };-    let color = ents.first().map(|e| e.common.color).filter(|c| ents.iter().all(|e| e.common.color == *c));-    let layer = same(&|e| e.common.layer.clone());-    let linetype = same(&|e| e.common.linetype.clone());+    let color = sum.color;+    let (layer, linetype) = (sum.layer.clone(), sum.linetype.clone());     let layers: Vec<String> = d.layers.iter().map(|l| l.name.clone()).collect();     let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();-    let kind = if ents.len() == 1 { Some(first.kind.clone()) } else { None };-    let ids: Vec<String> = sel.iter().map(|h| h.hex()).collect();+    // The geometry of a single selected object.+    let kind = (sum.count == 1).then(|| app.session.selected().iter().find_map(|h| d.entity(*h)).map(|e| e.kind.clone())).flatten();+    let key = sum.fingerprint;     let mut set: Option<(String, Value)> = None;     // Rotations show as the drawing measures directions (ANGBASE, ANGDIR).     let angles = app.session.angle_settings();     let mut close = false;     egui::Window::new(title)-        .id(egui::Id::unique(("quick_properties", &st.sel)))+        .id(egui::Id::unique(("quick_properties", key)))         .default_pos(at)         .collapsible(false)         .resizable(false)@@ -312,7 +305,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {             egui::Grid::new("qp_grid").num_columns(2).spacing(vec2(10.0, 4.0)).min_col_width(80.0).show(ui, |ui| {                 ui.label(RichText::new(crate::tl!("Color")).color(Tokens::get().text_dim));                 let cur = color.unwrap_or(cadcraft_color::Color::ByLayer);-                if let Some(c) = crate::layers::color_button(ui, egui::Id::unique(("qp_color", &ids)), cur, true, 150.0) {+                if let Some(c) = crate::layers::color_button(ui, egui::Id::unique(("qp_color", key)), cur, true, 150.0) {                     set = Some(("color".into(), json!(c.name())));                 }                 ui.end_row();@@ -337,7 +330,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {                     }                 });                 ui.end_row();-                let id = |k: &str| egui::Id::unique(("qp", k, &ids));+                let id = |k: &str| egui::Id::unique(("qp", k, key));                 match &kind {                     Some(EntityKind::Line(l)) => {                         let dl = l.b.xy() - l.a.xy();@@ -419,7 +412,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {         {             v = json!(angles.unshown(f.to_radians()).to_degrees());         }-        let _ = app.run("properties.set", json!({ "handles": ids, k: v }));+        let _ = app.run("properties.set", json!({ k: v }));     }     if close {         let _ = app.run("qpmode", json!({ "on": false }));diff --git a/crates/ui-egui/src/summary.rs b/crates/ui-egui/src/summary.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/summary.rs@@ -0,0 +1,242 @@+//! What the palettes say about the selection as a whole: how many objects of which types, and+//! the properties they share. It is worked out once per selection and drawing revision and kept+//! ([`Cache`]). The Properties and Quick Properties palettes are painted every frame, and scanning+//! the whole selection each time, several times over (once per shared property, with a string+//! per object), made the app crawl once every object of a large drawing was selected.++use std::collections::HashMap;+use std::hash::{Hash, Hasher};+use std::sync::Arc;++use cadcraft_color::{Color, Rgb};+use cadcraft_doc::{Drawing, Handle};+use cadcraft_engine::DocState;++use crate::CadApp;++/// Shown for a property whose value differs between the selected objects, as AutoCAD's+/// Properties palette does.+pub(crate) const VARIES: &str = "*VARIES*";++/// The selection as the palettes present it.+#[derive(Debug, Default, PartialEq)]+pub(crate) struct Summary {+    /// The selected objects the drawing has (a stale handle is not counted).+    pub count: usize,+    /// Their types in first-seen order, with how many of each.+    pub types: Vec<(&'static str, usize)>,+    /// The colour they are all given, when it is the same one.+    pub color: Option<Color>,+    /// The colour they all draw in (their own, resolved through the layer), when it is the same+    /// one; white otherwise.+    pub resolved: Rgb,+    /// Layer, linetype, linetype scale, lineweight and transparency: the shared value, or+    /// [`VARIES`].+    pub layer: String,+    pub linetype: String,+    pub ltscale: String,+    pub lineweight: String,+    pub transparency: String,+    /// Tells this selection from another in widget ids, so a field being edited for one+    /// selection does not carry over to the next.+    pub fingerprint: u64,+}++/// A value every object seen so far agrees on, until one differs.+enum Shared<T> {+    Unset,+    Same(T),+    Varies,+}++impl<T: PartialEq> Shared<T> {+    fn see(&mut self, v: T) {+        match self {+            Shared::Unset => *self = Shared::Same(v),+            Shared::Same(s) if *s == v => {}+            Shared::Same(_) => *self = Shared::Varies,+            Shared::Varies => {}+        }+    }+    fn get(self) -> Option<T> {+        match self {+            Shared::Same(v) => Some(v),+            Shared::Unset | Shared::Varies => None,+        }+    }+    fn text(self, show: impl FnOnce(T) -> String) -> String {+        self.get().map_or_else(|| VARIES.to_string(), show)+    }+}++impl Summary {+    /// The summary of `selection` in `space` of `d`, which is drawing `drawing` (the uid, part of+    /// the fingerprint). Objects of another space are not counted: the selection holds objects of+    /// the space commands act on, and a handle put in it directly counts for nothing.+    pub(crate) fn of(d: &Drawing, space: &cadcraft_engine::doc::Space, selection: &[Handle], drawing: u64) -> Summary {+        let mut types: Vec<(&'static str, usize)> = Vec::new();+        let (mut color, mut resolved, mut layer, mut linetype) = (Shared::Unset, Shared::Unset, Shared::Unset, Shared::Unset);+        let (mut ltscale, mut lineweight, mut transparency) = (Shared::Unset, Shared::Unset, Shared::Unset);+        // A layer's colour is looked up once per layer, not once per object.+        let mut layer_colors: HashMap<&str, Color> = HashMap::new();+        let mut count = 0;+        let store = d.space(space);+        for e in selection.iter().filter_map(|h| store.and_then(|s| s.get(*h))) {+            count += 1;+            let name = e.kind.type_name();+            match types.iter_mut().find(|(t, _)| *t == name) {+                Some((_, n)) => *n += 1,+                None => types.push((name, 1)),+            }+            let c = &e.common;+            let layer_color = *layer_colors.entry(c.layer.as_str()).or_insert_with(|| d.layer(&c.layer).map_or(Color::WHITE, |l| l.color));+            color.see(c.color);+            resolved.see(c.color.resolve(layer_color, Color::WHITE));+            layer.see(c.layer.as_str());+            linetype.see(c.linetype.as_str());+            ltscale.see(c.ltscale.to_bits());+            lineweight.see(c.lineweight);+            transparency.see(c.transparency);+        }+        Summary {+            count,+            types,+            color: color.get(),+            resolved: resolved.get().unwrap_or(Rgb(255, 255, 255)),+            layer: layer.text(str::to_string),+            linetype: linetype.text(str::to_string),+            ltscale: ltscale.text(|bits| format!("{:.4}", f64::from_bits(bits))),+            lineweight: lineweight.text(|lw| lw.name()),+            transparency: transparency.text(|tr| tr.name()),+            fingerprint: fingerprint(drawing, selection),+        }+    }+}++fn fingerprint(drawing: u64, selection: &[Handle]) -> u64 {+    let mut h = std::hash::DefaultHasher::new();+    drawing.hash(&mut h);+    selection.hash(&mut h);+    h.finish()+}++/// The summary of the current selection, kept while the drawing, its revision and the selection+/// stay the same. Whether they did is three numbers: the selection's generation stands for its+/// handles, so a frame with everything selected costs no more here than one with nothing.+#[derive(Default)]+pub(crate) struct Cache {+    /// The (drawing uid, revision, selection generation) the summary was made for.+    key: Option<(u64, u64, u64)>,+    summary: Arc<Summary>,+}++impl Cache {+    /// The summary of `st`'s selection: the kept one while nothing changed, else a fresh one.+    pub(crate) fn get(&mut self, st: &DocState) -> Arc<Summary> {+        let key = (st.uid, st.revision, st.selection.generation());+        if self.key != Some(key) {+            self.key = Some(key);+            self.summary = Arc::new(Summary::of(&st.doc, &st.edit_space(), &st.selection, st.uid));+        }+        self.summary.clone()+    }+}++/// The current selection's summary (see [`Cache`]); an empty one with no drawing open.+pub(crate) fn of_selection(app: &mut CadApp) -> Arc<Summary> {+    match app.session.state() {+        Ok(st) => app.summary.get(st),+        Err(_) => Arc::default(),+    }+}++#[cfg(test)]+#[allow(clippy::unwrap_used)]+mod tests {+    use super::*;+    use crate::Services;+    use cadcraft_engine::Session;+    use serde_json::json;++    fn app_with_lines() -> (CadApp, Vec<String>) {+        let mut app = CadApp::new(Session::default(), Services::default());+        app.run("layer.new", json!({ "name": "Blue", "color": 5 })).unwrap();+        let a = app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string();+        let b = app.run("line", json!({ "points": [[0, 1], [10, 1]] })).unwrap()["handles"][0].as_str().unwrap().to_string();+        let c = app.run("circle", json!({ "center": [5, 5], "radius": 2 })).unwrap()["handle"].as_str().unwrap().to_string();+        app.run("properties.set", json!({ "handles": [&b], "layer": "Blue", "color": 1 })).unwrap();+        (app, vec![a, b, c])+    }++    #[test]+    fn shared_and_differing_properties() {+        let (mut app, hs) = app_with_lines();+        app.run("select", json!({ "handles": [&hs[0], &hs[2]] })).unwrap();+        let s = of_selection(&mut app);+        assert_eq!((s.count, s.types.clone()), (2, vec![("Line", 1), ("Circle", 1)]));+        assert_eq!((s.color, s.layer.as_str(), s.linetype.as_str()), (Some(Color::ByLayer), "0", "ByLayer"));+        assert_eq!((s.ltscale.as_str(), s.lineweight.as_str(), s.transparency.as_str()), ("1.0000", "ByLayer", "ByLayer"));+        assert_eq!(s.resolved, Rgb(255, 255, 255));++        app.run("select", json!({ "handles": [&hs[0], &hs[1]] })).unwrap();+        let s = of_selection(&mut app);+        assert_eq!((s.count, s.types.clone()), (2, vec![("Line", 2)]));+        assert_eq!((s.color, s.layer.as_str()), (None, VARIES));+        assert_eq!(s.resolved, Rgb(255, 255, 255), "a red line and a white one draw in no shared colour");++        app.run("select", json!({ "handles": [&hs[1]] })).unwrap();+        let s = of_selection(&mut app);+        assert_eq!((s.count, s.color, s.layer.as_str()), (1, Some(Color::Index(1)), "Blue"));+        assert_eq!(s.resolved, Rgb(255, 0, 0));+    }++    #[test]+    fn summary_is_kept_until_the_selection_or_drawing_changes() {+        let (mut app, hs) = app_with_lines();+        app.run("select", json!({ "handles": [&hs[0], &hs[1]] })).unwrap();+        let first = of_selection(&mut app);+        let again = of_selection(&mut app);+        assert!(Arc::ptr_eq(&first, &again), "nothing changed: the same summary is handed out");++        // The same selection in another order is another selection (its fingerprint tells+        // widget ids apart), with the same properties.+        app.run("select", json!({ "handles": [&hs[1], &hs[0]] })).unwrap();+        let reordered = of_selection(&mut app);+        assert!(!Arc::ptr_eq(&first, &reordered));+        assert_ne!(first.fingerprint, reordered.fingerprint);+        assert_eq!((reordered.count, &reordered.layer), (2, &first.layer));++        // An edit to a selected object: a fresh summary with the new value.+        app.run("properties.set", json!({ "handles": [&hs[0]], "layer": "Blue" })).unwrap();+        let edited = of_selection(&mut app);+        assert!(!Arc::ptr_eq(&reordered, &edited));+        assert_eq!(edited.layer, "Blue");++        app.run("ai_deselect", json!({})).unwrap();+        let none = of_selection(&mut app);+        assert_eq!((none.count, none.types.len(), none.color), (0, 0, None));+    }++    #[test]+    fn objects_of_another_space_are_not_counted() {+        let (mut app, hs) = app_with_lines();+        app.run("layout.set", json!({ "name": "Layout1" })).unwrap();+        let paper = app.run("circle", json!({ "center": [5, 5], "radius": 2 })).unwrap()["handle"].as_str().unwrap().to_string();+        app.run("select", json!({ "handles": [&paper] })).unwrap();+        assert_eq!(of_selection(&mut app).count, 1);+        // A model-space handle put in the selection directly (selecting drops it) counts for nothing.+        let st = app.session.state_mut().unwrap();+        st.selection.set(vec![Handle::parse_hex(&hs[0]).unwrap(), Handle::parse_hex(&paper).unwrap()]);+        assert_eq!(of_selection(&mut app).count, 1);+    }++    #[test]+    fn stale_handles_are_not_counted() {+        let (mut app, hs) = app_with_lines();+        app.run("select", json!({ "handles": [&hs[0], &hs[2]] })).unwrap();+        let d = Arc::make_mut(&mut app.session.state_mut().unwrap().doc);+        d.remove_entity(Handle::parse_hex(&hs[2]).unwrap());+        let s = of_selection(&mut app);+        assert_eq!((s.count, s.types.clone()), (1, vec![("Line", 1)]));+    }+}diff --git a/crates/ui-egui/src/vpscale.rs b/crates/ui-egui/src/vpscale.rs--- a/crates/ui-egui/src/vpscale.rs+++ b/crates/ui-egui/src/vpscale.rs@@ -103,8 +103,7 @@ pub fn target(app: &CadApp) -> Option<(Handle, Viewport)> {     if let Some(a) = st.active_viewport() {         return Some(a);     }-    let sel = app.session.selection();-    let [h] = sel.as_slice() else { return None };+    let [h] = app.session.selected() else { return None };     match &st.doc.space(&st.space)?.get(*h)?.kind {         EntityKind::Viewport(v) if v.id != 1 => Some((*h, v.clone())),         _ => None,diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md--- a/docs/hardware-parity.md+++ b/docs/hardware-parity.md@@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (zoom and pan regenerate in the background) · **Target:** Autodesk AutoCAD 2027 (Mac and Windows)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Select All in a large drawing: 1.4 s per frame down to 2.4 ms) · **Target:** Autodesk AutoCAD 2027 (Mac and Windows)  The hardware AutoCAD uses and how CADCraft compares, per platform. Work items: [gaps.md](gaps.md#hardware).@@ -44,9 +44,19 @@ worker thread while the previous list is drawn, so the longest stall is 26–64 Edits still rebuild on the UI thread (about 1.8 s on that drawing): per-entity caching is the next step. +Selecting everything in a large drawing no longer stalls the app (2026-10-26). On a 98 MB parcel+plan (524k entities, 1.45M display primitives) Select All left every frame at 1.4 s: the+Properties palette scanned the whole selection on each frame, once per shared property, and each+object lookup scanned the chunk list of the entity store. The palettes now keep one summary of the+selection, remade only when the selection or the drawing changes (the selection carries a+generation every change advances, so telling that compares three numbers, not the handles), and a+lookup is two hash lookups and one contiguous scan: 2.4 ms per frame with the 468k objects+selected (M-series Mac).+ ## Revision history  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Select All on a 524k-entity drawing: 1.4 s per frame down to 2.4 ms (selection summary kept per selection, constant-time entity lookup) | | 2026-10-13 | minor | Zoom and pan regenerate in the background: 2–4.5 s stalls down to 26–64 ms on a hatched DWG | | 2026-10-10 | major | First hardware parity doc: GPU backends per platform, input devices, displays, plotters |