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
@@ -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());+ }+ }+ } }@@ -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;@@ -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 }); }@@ -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(),@@ -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()),@@ -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()); } } @@ -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.@@ -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.new file mode 100644@@ -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)]);+ }+}@@ -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))@@ -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());@@ -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[..]); }@@ -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; }@@ -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); } }@@ -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();@@ -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).@@ -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();@@ -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(), }@@ -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 {@@ -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 }));new file mode 100644@@ -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)]));+ }+}@@ -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,@@ -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 |