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.rs
new 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.rs
new 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 |
