From 2d2fdacdfd2aea4ee43345c9155fd782c799f530 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:04:13 +0300 Subject: [PATCH 1/5] perf: parse each wire name once when applying draw order The resident wire sort keys on the entity handle parsed out of each wire's name `String`. `sort_by_key` evaluates that key on every comparison, so a 198 k-wire resident set parsed every name roughly eighteen times over. Measured at 295 ms on every rebuild of the resident set -- including rebuilds that re-tessellated nothing, where the memo hit on all 186 455 entities and the sort was most of what was left. `sort_by_cached_key` evaluates the key once per element. It is stable in the same way `sort_by_key` is, so draw order is unchanged. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index da511e24f..f012cd2e6 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -9066,7 +9066,12 @@ impl Scene { if let Some((_, ref idx)) = *cache { if let Some(sort_map) = idx.get(&block_handle) { sorted = true; - wires.sort_by_key(|w| { + // `sort_by_cached_key`, not `sort_by_key`: the key parses + // the wire's name out of a `String`, and `sort_by_key` calls + // it O(n log n) times. On the resident set that is ~198 k + // wires parsed eighteen times over — 295 ms on every rebuild, + // including rebuilds where nothing was re-tessellated. + wires.sort_by_cached_key(|w| { let key = Self::handle_from_wire_name(&w.name) .map(|h| h.value()) .unwrap_or(u64::MAX); From d5d9ceec457eb1c8f66b3859934fc97a7e3798ed Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:06:21 +0300 Subject: [PATCH 2/5] perf: hand the loader thread's tessellation memo to the scene `prepare_open_geometry` builds the first Model wire set on a throwaway `Scene`, filling its per-entity tessellation memo with an entry for every one of the drawing's 186 455 entities. `install_prepared_open_geometry` then transferred the wire set and the interaction index but not the memo, so the temporary scene took it to the grave. The first geometry edit therefore rebuilt from cold: wires-build 2723.3ms memo_hit=0 memo_miss=186455 against 760 ms once the memo is warm -- 1.3 s of work the loader thread had already done, repeated on the user's first action. `PreparedOpenGeometry` now carries the memo, moved rather than cloned since the scene is dropped immediately afterwards. The guard hash travels with it: the memo is only usable under the state it was built for, and a mismatch on the receiving side clears it, which is what happened unconditionally before. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f012cd2e6..f1b8cf7df 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -432,6 +432,17 @@ struct ResidentWireLayout { pub struct PreparedOpenGeometry { pub wires: Arc>, pub interaction_index: Option>, + /// The per-entity tessellation memo the loader thread filled while building + /// `wires`, with the guard hash it was valid under. + /// + /// `prepare_open_geometry` works on a throwaway `Scene`, so this used to be + /// dropped with it and the first geometry edit re-tessellated all 186 455 + /// entities from cold — 1.3 s of work the loader had already done. The + /// guard travels with it because the memo is only usable under the state it + /// was built for; a mismatch on the receiving side clears it, which is + /// exactly the old behaviour. + pub tess_memo: HashMap>>, + pub tess_guard: u64, } impl std::fmt::Debug for PreparedOpenGeometry { @@ -439,6 +450,7 @@ impl std::fmt::Debug for PreparedOpenGeometry { f.debug_struct("PreparedOpenGeometry") .field("wires", &self.wires.len()) .field("interaction_index", &self.interaction_index.is_some()) + .field("tess_memo", &self.tess_memo.len()) .finish() } } @@ -1016,12 +1028,17 @@ pub fn prepare_open_geometry( wires.len(), ); } + // Taken, not cloned: the scene is discarded on the next line. + let tess_memo = std::mem::take(&mut *scene.resident_tess_memo.borrow_mut()); + let tess_guard = scene.resident_tess_guard.get(); let doc = std::mem::replace(&mut scene.document, CadDocument::new()); ( doc, PreparedOpenGeometry { wires, interaction_index, + tess_memo, + tess_guard, }, ) } @@ -2206,6 +2223,12 @@ impl Scene { layout: None, }, ); + // Adopt the loader thread's tessellation memo so the first edit patches + // the entities it touched instead of rebuilding every one of them. + if !prepared.tess_memo.is_empty() { + *self.resident_tess_memo.borrow_mut() = prepared.tess_memo; + self.resident_tess_guard.set(prepared.tess_guard); + } if let Some(index) = prepared.interaction_index { self.cache_interaction_index(self.geometry_epoch, prepared.wires, index); } From 38f576c296017827bb16e2c21166204e5a499c49 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:08:43 +0300 Subject: [PATCH 3/5] perf: warm the interaction handle index during open The first hover over the canvas after opening a large drawing froze the UI for a second: hover-detail query=0.2 handles=986.1 ... candidates=253 hover-dwell 986.6ms `interaction_handle_index` and the insert-hatch map it reads are built lazily, and first use is that hover. Neither can move to the loader thread: they read `hatches`, `meshes` and `block_meshes`, which are installed on the real `Scene` only after the loader has finished. Build them at the end of the open instead, where the user is already waiting. This does not make the work cheaper -- open grows by roughly the second the hover used to cost -- but a second inside an operation that is already seconds long reads very differently from a second-long freeze on a mouse move. Co-Authored-By: Claude Opus 5 --- src/app/update/file.rs | 2 ++ src/scene/mod.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 3defb1f27..53db29cc0 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1438,6 +1438,8 @@ pub(super) fn on_open_file(&mut self) -> Task { if let Some(prepared) = prepared_geometry { self.tabs[i].scene.install_prepared_open_geometry(prepared); } + // Pay for the interaction index here, not on the first hover. + self.tabs[i].scene.warm_interaction_caches(); self.tabs[i] .scene .replace_selection(rustc_hash::FxHashSet::default()); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f1b8cf7df..228cfc276 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -7874,6 +7874,22 @@ impl Scene { } } + /// Build the interaction handle index and the insert-hatch map now, while + /// the drawing is still opening. + /// + /// Both are built lazily on first use, and first use is the user's first + /// hover over the canvas — where the cold build cost 992 ms mid-gesture + /// (`hover-detail ... handles=986.1`). They cannot move to the loader + /// thread: they read `hatches` / `meshes` / `block_meshes`, which are + /// installed on the real `Scene` after the loader is done. + /// + /// This does not make the work cheaper, it moves it to where the user is + /// already waiting. A second on an open that is already seconds long reads + /// very differently from a second-long freeze on a mouse move. + pub(crate) fn warm_interaction_caches(&self) { + let _ = self.interaction_handle_index(); + } + fn interaction_handle_index( &self, ) -> Arc { From 909b7f337904f6287c092943392c3bd38870db6d Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:12:44 +0300 Subject: [PATCH 4/5] perf: hand the loader thread's block definitions to the scene The first edit on a large drawing spent 1147 ms rebuilding the block definition cache: wires-build 2723.3ms ... blk_cache=1147.2 ... memo_miss=186455 Not because the edit invalidated it -- `add_entity_internal` already routes a non-block entity through `bump_entities`, so drawing a line leaves `block_epoch` alone. The cache was empty because `prepare_open_geometry` built it on a throwaway `Scene`, exactly as it did the tessellation memo, and it went the same way. `PreparedOpenGeometry` now carries the definitions across, moved rather than cloned, and `install_prepared_open_geometry` stamps them with the receiving scene's `block_epoch`. That stamp is sound: the document is not touched between the loader finishing and this call, so the definitions describe it exactly. A key that does not match what the scene later asks for simply misses, which is the old behaviour. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 228cfc276..67a3ed77c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -443,6 +443,14 @@ pub struct PreparedOpenGeometry { /// exactly the old behaviour. pub tess_memo: HashMap>>, pub tess_guard: u64, + /// Block definitions the loader thread built while tessellating, keyed as + /// `block_defn_cache` keys them but without an epoch — the receiving scene + /// stamps its own. + /// + /// Dropped with the throwaway scene before, which is why the first edit + /// spent 1147 ms rebuilding definitions the loader had just built from the + /// same, unmodified document. + pub block_defns: Vec<(u64, Arc)>, } impl std::fmt::Debug for PreparedOpenGeometry { @@ -451,6 +459,7 @@ impl std::fmt::Debug for PreparedOpenGeometry { .field("wires", &self.wires.len()) .field("interaction_index", &self.interaction_index.is_some()) .field("tess_memo", &self.tess_memo.len()) + .field("block_defns", &self.block_defns.len()) .finish() } } @@ -1031,6 +1040,11 @@ pub fn prepare_open_geometry( // Taken, not cloned: the scene is discarded on the next line. let tess_memo = std::mem::take(&mut *scene.resident_tess_memo.borrow_mut()); let tess_guard = scene.resident_tess_guard.get(); + let block_defns: Vec<(u64, Arc)> = + std::mem::take(&mut *scene.block_defn_cache.borrow_mut()) + .into_iter() + .map(|(key, (_epoch, cache))| (key, cache)) + .collect(); let doc = std::mem::replace(&mut scene.document, CadDocument::new()); ( doc, @@ -1039,6 +1053,7 @@ pub fn prepare_open_geometry( interaction_index, tess_memo, tess_guard, + block_defns, }, ) } @@ -2229,6 +2244,16 @@ impl Scene { *self.resident_tess_memo.borrow_mut() = prepared.tess_memo; self.resident_tess_guard.set(prepared.tess_guard); } + // Same for the block definitions, stamped with this scene's block + // epoch: the document has not been touched between the loader + // finishing and this call, so they describe it exactly. + if !prepared.block_defns.is_empty() { + let epoch = self.block_epoch; + let mut cache = self.block_defn_cache.borrow_mut(); + for (key, defns) in prepared.block_defns { + cache.insert(key, (epoch, defns)); + } + } if let Some(index) = prepared.interaction_index { self.cache_interaction_index(self.geometry_epoch, prepared.wires, index); } From b9c252474853d47c91bd6c03cd6bf8752378061f Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:55:19 +0300 Subject: [PATCH 5/5] perf: warm the entity quadtree alongside the handle index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warming only the interaction handle index left the first hover at 385 ms, down from 992 but not gone: hover-detail query=0.1 handles=384.6 ... candidates=186 The handles stage builds two indexes, not one. `interaction_candidate_handles` also reaches for `entity_index()` — the entity quadtree, keyed on geometry_epoch and just as cold at that point. Warm both. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 67a3ed77c..fff1285b1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -7912,6 +7912,11 @@ impl Scene { /// already waiting. A second on an open that is already seconds long reads /// very differently from a second-long freeze on a mouse move. pub(crate) fn warm_interaction_caches(&self) { + // Both indexes on the hover path, not just one. The handle index alone + // left `hover-detail ... handles=384.6` on the first hover: the same + // stage also builds the entity quadtree, which is keyed on + // `geometry_epoch` and equally cold at that point. + let _ = self.entity_index(); let _ = self.interaction_handle_index(); }