diff --git a/.github/workflows/control.yml b/.github/workflows/control.yml index 5e3fe1517..a656c10ba 100644 --- a/.github/workflows/control.yml +++ b/.github/workflows/control.yml @@ -19,6 +19,7 @@ jobs: run: | cargo test --lib app::control::tests cargo test --lib io::update_check::tests + cargo test --lib scene::pipeline::wire_arena::tests - name: Validate MCP smoke script if: runner.os != 'Windows' run: python3 -m py_compile docs/automation/mcp_smoke.py diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 71847384e..b5fe0efd6 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -432,24 +432,10 @@ 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. + /// Loader tessellations and their guard; a state mismatch clears the memo. 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. + /// Loader block definitions; the receiving scene supplies its own epoch. pub block_defns: Vec<(u64, Arc)>, } @@ -1859,14 +1845,8 @@ pub struct Scene { /// Boundary source → associative hatch handles. Source edits reuse this /// index instead of scanning the document during every drag step. associative_hatch_source_cache: RefCell>>>, - /// Whether the drawing contains any associative centerline or center mark. - /// - /// Refreshing those means scanning every entity and parsing its extended - /// data — 1.17 M entities and ~30 ms each on the reproducer, run twice on - /// every edit, to find none at all. `None` means not yet determined; the - /// first scan records the answer, and later edits only re-examine the - /// handles they actually changed. Staying `true` after the last one is - /// deleted is merely slower, never wrong. + /// Conservative association hint: unknown until scanned, then updated from + /// changed entities. Retaining `true` after deletion only costs an extra scan. has_associative_centers: std::cell::Cell>, /// Tessellated block definitions in block-local coords, keyed by render /// background and block epoch. Model and Paper adapt black/white colours @@ -2589,12 +2569,7 @@ impl Scene { changes } - /// Whether the associative centerline / center-mark scans can be skipped. - /// - /// Answers from the cached hint when it is known, and otherwise from the - /// changed entities alone: if any of them carries an association the hint - /// flips to `true` and the scans run from then on. Only an undetermined - /// hint costs a full pass, and that happens once. + /// Check changed entities and scan the document only when the hint is unknown. fn associative_centers_possible(&self, changes: &[(Handle, ChangeKind)]) -> bool { if changes.iter().any(|(handle, _)| { self.document.get_entity(*handle).is_some_and(|entity| { @@ -7943,23 +7918,8 @@ 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. + /// Prepare hover indexes after derived geometry is installed during open. 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(); } @@ -9179,11 +9139,7 @@ impl Scene { if let Some((_, ref idx)) = *cache { if let Some(sort_map) = idx.get(&block_handle) { sorted = true; - // `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. + // Parse each handle once while preserving stable draw order. wires.sort_by_cached_key(|w| { let key = Self::handle_from_wire_name(&w.name) .map(|h| h.value()) diff --git a/src/scene/pipeline/gpu_budget.rs b/src/scene/pipeline/gpu_budget.rs index 052f1fdb9..1fe715cf5 100644 --- a/src/scene/pipeline/gpu_budget.rs +++ b/src/scene/pipeline/gpu_budget.rs @@ -8,13 +8,8 @@ fn device_ceiling(device: &wgpu::Device) -> usize { (usize::try_from(device.limits().max_buffer_size).unwrap_or(usize::MAX) / 10) * 9 } -/// The wire arena keeps one persistent instance buffer for the whole resident -/// set and patches it in place. Capping that at the per-chunk budget pushes any -/// drawing past ~524 k instances onto the batched fallback, which re-uploads -/// everything on every edit — 200 ms a click on a drawing with 821 k. A single -/// long-lived buffer can afford more than a transient chunk, and 128 MiB is -/// still four times smaller than the 505 MiB allocation that crashed a 2 GB -/// card in the first place. +/// Persistent arenas need a larger cap than transient chunks to keep large +/// drawings on the incremental upload path. const ARENA_CAP_BYTES: usize = 128 * 1024 * 1024; /// `OCS_GPU_CHUNK_MIB`, when set. Overrides both caps so a test can force diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index ee512c002..df3e32a4d 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2419,15 +2419,7 @@ impl Pipeline { Some(&mut self.block_geometry), ); - // Index handle → wire slots once, here, so the per-hover selection - // overlay can gather just the highlighted wires instead of scanning + - // string-parsing the whole set every time the hovered entity changes. - // - // Parsing 198 k names is the bulk of this and each one is independent, - // so it runs across cores; only the map insertion stays serial, and - // that is cheap once the parse is done. Emission order is preserved -- - // `par_iter().enumerate()` collects in index order -- which the slot - // lists rely on. + // Parse handles in parallel, preserving wire order for hover slot lists. let t_index = crate::perf::enabled().then(iced::time::Instant::now); let parsed: Vec<(u64, u32)> = { use crate::par::prelude::*;