From e0f5126a2b00be13f152393ea2743fb6f17fdc01 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:49:20 +0300 Subject: [PATCH 1/4] perf: break the view frame down by widget-tree section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first Model→Layout switch costs ~9.2 s, and the largest single piece of it is `view` at 5810 ms — iced widget-tree construction, not the scene. The scene work that follows it in `prepare` (two `wires-build` calls plus `frame-prepare`) accounts for 3377 ms, and the log's ordering shows the two are sequential, not nested: `view` prints before any of them. `view` being one number could not say which part of a 1950-line `view_main` that is. It now reports eight sections — viewport_3d, model_input, grid, selection_overlay, viewport_mouse, viewport_stack, active_vp_rect, chrome — plus the preamble as `head`. The marks go through a closure over a `RefCell` rather than one binding per section: two of the sections sit inside a nested block, where per-section bindings are out of scope by the time the report runs. Co-Authored-By: Claude Opus 5 --- src/app/view/mod.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3789f6366..78962a4b1 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -197,6 +197,20 @@ impl OpenCADStudio { /// `view` so the single-window web build can render it directly, bypassing /// the multi-window id dispatch above (the web build has no extra windows). pub fn view_main(&self) -> Element<'_, Message> { + // Section marks for `view-detail`. A closure over a RefCell rather + // than one binding per section: two of these sections sit inside a + // nested block and would otherwise be out of scope at the report. + let view_t0 = crate::perf::enabled().then(iced::time::Instant::now); + let view_marks: std::cell::RefCell> = + std::cell::RefCell::new(Vec::with_capacity(8)); + let mark = |name: &'static str| { + if let Some(t0) = view_t0 { + view_marks + .borrow_mut() + .push((name, t0.elapsed().as_secs_f64() * 1000.0)); + } + }; + let i = self.active_tab; let tab = &self.tabs[i]; let thumbnail_capture_clean = self.thumbnail_capture_clean; @@ -257,6 +271,7 @@ impl OpenCADStudio { // the same shader as model space: a full-canvas top-locked "sheet" // viewport draws the layout's own geometry (white sheet + entities + // borders) and the floating content viewports blit on top. + mark("viewport_3d"); let viewport_3d: Element<'_, Message> = if tab.is_start { start_page_view( &self.patrons, @@ -334,6 +349,7 @@ impl OpenCADStudio { // the shader pane_grid (same `model_panes` → identical layout). Layered // above the crosshair overlay so it actually receives mouse events, and // it owns the divider resize. Only built for the Model layout. + mark("model_input"); let model_input_layer: Option> = if is_paper || tab.is_start { None } else { @@ -351,6 +367,7 @@ impl OpenCADStudio { ) }; + mark("grid"); let grid_overlay = { let (vw, vh) = tab.scene.selection.borrow().vp_size; let model_basis = { @@ -420,6 +437,7 @@ impl OpenCADStudio { crate::ui::overlay::grid_overlay(grid, grid_style) }; + mark("selection_overlay"); let selection_overlay = { // Hold a single `Ref<'_, SelectionState>` for the whole overlay // block. The `vp_size` and `last_move_pos` reads below become @@ -790,6 +808,7 @@ impl OpenCADStudio { ) }; + mark("viewport_mouse"); let viewport_mouse = mouse_area(container( iced::widget::Space::new().width(Fill).height(Fill), )) @@ -935,6 +954,7 @@ impl OpenCADStudio { None }; + mark("viewport_stack"); let mut viewport_stack = if tab.is_start { // Start tab: only the welcome widget over a flat background. // Skip every drawing-only overlay (selection markers, snap info, @@ -1053,6 +1073,7 @@ impl OpenCADStudio { // both layered ABOVE the viewport mouse_area so they receive // clicks (the shader viewport sits below it). Positioned with // leading Spaces sized to the viewport's screen rectangle. + mark("active_vp_rect"); let active_vp_rect: Option<(acadrust::Handle, iced::Rectangle)> = if is_paper && !tab.is_start { tab.scene.active_viewport.and_then(|h| { @@ -1383,6 +1404,7 @@ impl OpenCADStudio { } // Reserve the overlaid command line when placing cursor-anchored panels. + mark("chrome"); let command_line_inset = if self.command_line.history_open { self.command_line.history_height.clamp( crate::ui::command_line::HISTORY_HEIGHT_MIN, @@ -2102,6 +2124,34 @@ impl OpenCADStudio { }; // Shared CAD colour picker. Indexed ACI colours use OpenCADStudio's own // dialog; True Color keeps the existing iced_aw gradient picker. + // Which part of the widget tree the frame went into. Reported as a + // breakdown rather than one number, because a first switch to a paper + // layout spends seconds here and `view` alone cannot say where. + if let Some(t0) = view_t0 { + mark("end"); + let total = t0.elapsed().as_secs_f64() * 1000.0; + if total >= 1.0 { + let marks = view_marks.borrow(); + // A mark opens a section; the following mark closes it, so a + // section's cost is the gap to its successor. The first mark + // is preceded by the preamble, reported as `head`. + let mut detail = String::new(); + if let Some((_, first)) = marks.first() { + let _ = std::fmt::Write::write_fmt( + &mut detail, + format_args!(" head={first:.1}"), + ); + } + for pair in marks.windows(2) { + let _ = std::fmt::Write::write_fmt( + &mut detail, + format_args!(" {}={:.1}", pair[0].0, pair[1].1 - pair[0].1), + ); + } + crate::perf_record!("[perf] view-detail total={total:.1}ms{detail}"); + } + } + if let Some((_, current)) = self.color_pick_target.as_ref() { match self.color_picker_tab { super::ColorPickerTab::Index => { From 91b2344db1a7ca59ae8b23072e05806d151a1b63 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:17:32 +0300 Subject: [PATCH 2/4] perf: break the grid overlay down by part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `view-detail` located the first paper frame's cost precisely: 7826.9 of 7832.8 ms in `grid`. Once, on the first frame after a layout switch — every other frame in a 2769-frame session reports it at 0.0. That is the largest single cost in a layout switch, larger than the model re-tessellation and the arena rebuild combined, and it is not geometry work at all. `grid_overlay` itself only wraps a canvas; `grid_segments` runs later, in `draw`. So the time is in `grid_views` or in the per-view parameter build, and reading the two does not settle which: both reach helpers that can fall back to a full document walk, and neither obviously fires for this drawing. `grid-detail views= params= bg= n=` splits it, and reports only when the block costs at least a millisecond so the quiet frames stay quiet. Co-Authored-By: Claude Opus 5 --- src/app/view/mod.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 78962a4b1..864496a60 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -382,9 +382,15 @@ impl OpenCADStudio { ); (o, (ux.as_vec3(), uy.as_vec3(), uz.as_vec3())) }; - let grid: Vec = tab - .scene - .grid_views(vw, vh) + // The first paper frame spends seconds in this block and `grid` + // alone cannot say which part. Reported only when it costs + // something, so ordinary frames stay quiet. + let t_grid = crate::perf::enabled().then(iced::time::Instant::now); + let views = tab.scene.grid_views(vw, vh); + let views_ms = crate::perf::elapsed_ms(t_grid); + let view_count = views.len(); + let t_params = crate::perf::enabled().then(iced::time::Instant::now); + let grid: Vec = views .into_iter() .map(|(bounds, cam, handle)| { let (origin, mut axes): (glam::DVec3, _) = if is_paper { @@ -428,7 +434,16 @@ impl OpenCADStudio { } }) .collect(); + let params_ms = crate::perf::elapsed_ms(t_params); + let t_bg = crate::perf::enabled().then(iced::time::Instant::now); let bg = crosshair_background(tab, is_paper); + let bg_ms = crate::perf::elapsed_ms(t_bg); + if views_ms + params_ms + bg_ms >= 1.0 { + crate::perf_record!( + "[perf] grid-detail views={views_ms:.1}ms params={params_ms:.1}ms \ +bg={bg_ms:.1}ms n={view_count}" + ); + } let bg_lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]; let grid_style = crate::ui::overlay::GridStyle { opacity: self.model_space.grid_opacity, From 0b4551b6ec5ca84c12984fe9041d0ed59d909012 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:35:34 +0300 Subject: [PATCH 3/4] perf: stop tessellating the whole model to measure it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `view-detail` put 7826.9 of the first paper frame's 7832.8 ms in the grid overlay. The path is `grid_views` → `active_viewports` → `camera_for_viewport` → `model_space_extents`, whose fast path reads the already-built wire AABBs but is gated on `current_layout == "Model"` — correctly, because `wire_cache` holds the *current layout's* wires, which in a Layout are the paper sheet's. So a paper frame takes the fallback, which tessellates every model-space entity to read its key vertices. That fallback called `tessellate_one` per entity, and `tessellate_one` resolves the background, annotation scale and block cache from `&self` every time — including a `RefCell` borrow and a cache lookup for a block cache that is identical on all 186 455 calls. The walk was also serial, while the wire builder's own tessellation pass has been parallel for a while. Those inputs are now resolved once, by a `tessellation_context` that `tessellate_one` also uses so the two cannot drift, and the walk runs through rayon. The reduction is min/max/or, so parallel order does not change the result; the arguments to `tessellate_entity` are unchanged. This makes the fallback cheaper, not absent. Tessellating a million entities to obtain a bounding box is the wrong shape, and the structural fix — a model-space extent that does not depend on which layout is being viewed — needs the next measurement to size. Recorded in DEBUG_NOTES. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 118 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 94 insertions(+), 24 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 28cab5091..9a3c58ac1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -10014,20 +10014,31 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates } /// Full tessellation pipeline for one entity. - fn tessellate_one(&self, e: &EntityType) -> Vec { - let bg = if self.current_layout == "Model" { + /// The document-wide inputs `tessellate_entity` takes, resolved from the + /// current layout and viewport. + /// + /// Every entity in a walk gets the same four, and resolving them per entity + /// is not free — `block_cache_arc_for` takes a `RefCell` borrow and a cache + /// lookup. Callers that tessellate more than one entity resolve them once + /// and pass them down; sharing the resolution keeps those callers from + /// drifting away from `tessellate_one`. + fn tessellation_context( + &self, + ) -> ([f32; 4], f32, Option, Arc, bool) { + let is_model = self.current_layout == "Model"; + let bg = if is_model { self.bg_color } else { self.paper_bg_color }; - let anno = if self.current_layout == "Model" { + let anno = if is_model { self.annotation_scale } else if let Some(viewport) = self.active_viewport { self.viewport_annotation_multiplier(viewport) } else { 1.0 }; - let annotation_scale_handle = if self.current_layout == "Model" { + let annotation_scale_handle = if is_model { crate::scene::annotative::scale_handle_by_name( &self.document, &self.document.header.current_annotation_scale, @@ -10042,6 +10053,12 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates self.annotation_all_visible(), self.active_viewport, ); + (bg, anno, annotation_scale_handle, blk_cache, !is_model) + } + + fn tessellate_one(&self, e: &EntityType) -> Vec { + let (bg, anno, annotation_scale_handle, blk_cache, paper) = + self.tessellation_context(); // tessellate_one is used for one-off lookups (hit test, properties). // Skip culling here so the caller always gets the full geometry. tessellate_entity( @@ -10055,7 +10072,7 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates Some(&blk_cache), None, None, - self.current_layout != "Model", + paper, ) } @@ -10240,27 +10257,80 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates // offset-rel coords there silently double-subtracts world_offset // inside `camera_for_viewport` and points the viewport at the // wrong location on UTM-scale drawings. - for entity in self.document.entities() { - let c = entity.common(); - if c.owner_handle != model_block - || c.invisible - || self.entity_temporarily_hidden(c.handle) - { - continue; - } - for wire in self.tessellate_one(entity) { - for &[x, y, z] in &wire.key_vertices { - let v = glam::Vec3::new(x as f32, y as f32, z as f32); - // Check finiteness *after* the f32 cast: a ray/xline endpoint - // is a huge-but-finite f64 that overflows to inf in f32, which - // the f64 `is_finite` test would have let through. - if v.is_finite() { - min = min.min(v); - max = max.max(v); - any = true; + // + // `tessellate_one` resolves the background, annotation scale and block + // cache from `&self` on every call, and this walk calls it once per + // model entity — 186 k times on a large drawing, each one taking a + // `RefCell` borrow for a block cache that is the same every time. Those + // inputs do not vary across the loop, so they are resolved once and the + // shared tessellator is called directly, which also lets the walk run + // in parallel the way the wire builder's own tessellation pass does. + // Same arguments, same output: only who computes them changed. + let (bg_for_tess, anno_for_tess, anno_handle_for_tess, blk_for_tess, paper_for_tess) = + self.tessellation_context(); + let subjects: Vec<&EntityType> = self + .document + .entities() + .filter(|entity| { + let c = entity.common(); + c.owner_handle == model_block + && !c.invisible + && !self.entity_temporarily_hidden(c.handle) + }) + .collect(); + let doc = &self.document; + let sel = &self.selected; + let avp = self.active_viewport; + let blk_ref: &cache::block_cache::BlockCache = &blk_for_tess; + use rayon::iter::{IntoParallelIterator, ParallelIterator}; + let reduced = subjects + .into_par_iter() + .map(|entity| { + let mut lo = glam::Vec3::splat(f32::INFINITY); + let mut hi = glam::Vec3::splat(f32::NEG_INFINITY); + let mut hit = false; + for wire in tessellate_entity( + doc, + sel, + avp, + bg_for_tess, + anno_for_tess, + anno_handle_for_tess, + entity, + Some(blk_ref), + None, + None, + paper_for_tess, + ) { + for &[x, y, z] in &wire.key_vertices { + let v = glam::Vec3::new(x as f32, y as f32, z as f32); + // Check finiteness *after* the f32 cast: a ray/xline + // endpoint is a huge-but-finite f64 that overflows to + // inf in f32, which the f64 `is_finite` test would have + // let through. + if v.is_finite() { + lo = lo.min(v); + hi = hi.max(v); + hit = true; + } } } - } + (lo, hi, hit) + }) + .reduce( + || { + ( + glam::Vec3::splat(f32::INFINITY), + glam::Vec3::splat(f32::NEG_INFINITY), + false, + ) + }, + |a, b| (a.0.min(b.0), a.1.max(b.1), a.2 || b.2), + ); + if reduced.2 { + min = min.min(reduced.0); + max = max.max(reduced.1); + any = true; } // Same mesh inclusion for the tessellate fallback path. for (&handle, set) in &self.meshes { From d9a1d55f2eb6e68acdb3f2dfd326aab0688866a6 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:14:02 +0300 Subject: [PATCH 4/4] perf: don't resolve viewport cameras for a grid nobody draws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `grid-detail` came back `views=1095.2ms params=0.0ms bg=0.0ms n=0`. Zero grid views — every millisecond of it thrown away. `grid_views` asked `active_viewports` for every viewport, which resolves a camera for each, and `camera_for_viewport` reaches `model_space_extents`, which outside the Model layout tessellates the whole drawing. Only then did it filter on `grid_on`, which removed all of them. The parallel walk from the previous commit had already cut this from 7826.9 ms; it was 7.8 seconds of work for an empty vector. `grid_views` now asks first. `any_viewport_grid_on` reads the same fields `active_viewports` copies into `ViewportInstance::grid_on`, and skips the visibility filters that function also applies: answering "maybe" costs only the work that would have happened anyway, while answering "no" wrongly would drop a grid the user asked for. Also reports the tessellation memo's guard and the inputs that can move it. Two consecutive identical resident builds each missed all 186 455 entries — `memo_hit=0` twice in a row — which a stable guard cannot explain, so something is clearing the memo between them and reading the code has not said what. `guard=`, `guard_stale=`, `avp=`, `anno=`, `anno_h=`, `all_vis=` and `sdf_gen=` will. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 19 +++++++++++++++++-- src/scene/paper.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9a3c58ac1..6e8298c8f 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -9080,6 +9080,11 @@ impl Scene { let mut materialize_ms = 0.0f64; let mut memo_hits = 0usize; let memo_misses; + // Two consecutive identical resident builds both missed every entry, + // which a stable guard cannot explain — the memo is being cleared + // between them. Report the guard and the inputs that can move it. + let mut guard_dbg = 0u64; + let mut guard_was_stale = false; let t_build = perf.then(iced::time::Instant::now); let mut wires: Vec = if memo_active { // Guard hash of everything tessellate_entity output depends on @@ -9114,7 +9119,9 @@ impl Scene { } else { (&self.tess_memo, &self.tess_memo_guard) }; - if guard_cell.get() != guard { + guard_dbg = guard; + guard_was_stale = guard_cell.get() != guard; + if guard_was_stale { memo_cell.borrow_mut().clear(); guard_cell.set(guard); } @@ -9228,7 +9235,8 @@ impl Scene { crate::perf_record!( "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} \ build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ -entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={}", +entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={} \ +guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, @@ -9247,6 +9255,13 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates if memo_active { "on" } else { "off" }, visible_path, visible_candidates, + guard_dbg, + guard_was_stale, + avp.map(|h| h.value()).unwrap_or(0), + anno, + annotation_scale_handle.map(|h| h.value()).unwrap_or(0), + all_visible, + crate::scene::text::sdf_atlas::generation(), ); } wires diff --git a/src/scene/paper.rs b/src/scene/paper.rs index d7a1d9285..450074122 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -69,7 +69,36 @@ impl Scene { (layout_block, sheet, content) } + /// Could any viewport in this layout draw a grid? + /// + /// Read from the same fields `active_viewports` copies into + /// `ViewportInstance::grid_on`, and deliberately without the visibility + /// filters that function also applies: answering "maybe" only costs the + /// work that would have happened anyway, while answering "no" wrongly + /// would drop a grid the user asked for. + fn any_viewport_grid_on(&self) -> bool { + if self.current_layout == "Model" { + return self.model_tiles.borrow().iter().any(|tile| tile.grid_on); + } + let (_, sheet, content) = self.paper_viewport_handles(); + let grid_on = |handle: Handle| { + matches!( + self.document.get_entity(handle), + Some(EntityType::Viewport(vp)) if vp.status.grid_on + ) + }; + grid_on(sheet) || content.iter().copied().any(grid_on) + } + pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera, Handle)> { + // `active_viewports` resolves a camera for every viewport, and + // `camera_for_viewport` reaches `model_space_extents`, which outside the + // Model layout tessellates the whole drawing. Filtering on `grid_on` + // afterwards threw all of that away: on the reproducer the first paper + // frame spent 7.8 s here to produce `n=0` views. Ask first. + if !self.any_viewport_grid_on() { + return Vec::new(); + } self.active_viewports(vw, vh, acadrust::entities::ViewportRenderMode::Wireframe2D) .into_iter() .filter(|inst| inst.grid_on)