diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3789f6366..864496a60 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 = { @@ -365,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 { @@ -411,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, @@ -420,6 +452,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 +823,7 @@ impl OpenCADStudio { ) }; + mark("viewport_mouse"); let viewport_mouse = mouse_area(container( iced::widget::Space::new().width(Fill).height(Fill), )) @@ -935,6 +969,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 +1088,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 +1419,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 +2139,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 => { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 28cab5091..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 @@ -10014,20 +10029,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 +10068,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 +10087,7 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates Some(&blk_cache), None, None, - self.current_layout != "Model", + paper, ) } @@ -10240,27 +10272,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 { 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)