diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 11b14c6a0..2a7aa4118 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -165,6 +165,7 @@ clipboard = .paste-to-original-coordinates = Özgün Koordinatlara Yapıştır viewport = + .preparing-layout = Yerleşim hazırlanıyor… .split-vertical = Görünüm Penceresini Dikey Böl .split-horizontal = Görünüm Penceresini Yatay Böl .move = Görünüm Penceresini Taşı diff --git a/src/app/automation.rs b/src/app/automation.rs index a3c33d0a0..e0b11eb66 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -653,6 +653,36 @@ impl OpenCADStudio { mod tests { use crate::app::OpenCADStudio; + #[test] + fn layout_notice_skips_grid_camera_and_scene_builds() { + let mut app = OpenCADStudio::new_for_test(); + assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true); + assert_eq!(app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"], true); + let i = app.active_tab; + let scene = &mut app.tabs[i].scene; + scene.document.add_layout("Review").unwrap(); + scene.set_current_layout("Review".to_string()); + let mut viewport = acadrust::entities::Viewport::new(); + viewport.id = 2; + viewport.width = 100.0; + viewport.height = 50.0; + viewport.status.is_on = true; + scene.add_entity(acadrust::EntityType::Viewport(viewport)); + for entity in scene.document.entities_mut() { + if let acadrust::EntityType::Viewport(viewport) = entity { + viewport.status.grid_on = true; + } + } + let before = scene.last_tess_wires.get(); + app.layout_settling = true; + drop(app.view_main()); + assert_eq!(app.tabs[i].scene.last_tess_wires.get(), before); + let _ = app.update(crate::app::Message::LayoutSettled); + assert!(!app.layout_settling); + drop(app.view_main()); + assert!(app.tabs[i].scene.last_tess_wires.get() > before); + } + #[test] fn automation_ops_round_trip() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 261a32d27..a4547f0dd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1055,14 +1055,7 @@ pub(super) struct OpenCADStudio { /// `Some` while a CAD file is loading — drives the modal overlay. /// Cleared when the load finishes, errors, or the user cancels. pub(super) opening: Option, - /// Set the moment a layout switch changes `current_layout`, cleared on the - /// next presented frame. - /// - /// The switch itself costs about 10 ms; the frame after it rebuilds the - /// whole wire set and can block the main thread for seconds. While this is - /// set the viewport is replaced by a notice, so that frame is cheap — which - /// gives the compositor an answer, and the user something other than a - /// stale window, immediately before the expensive one. + /// Show a lightweight notice until the next layout redraw. pub(super) layout_settling: bool, open_job_serial: u64, /// Last repair or failed-open report shown in the recovery modal. @@ -2292,8 +2285,7 @@ pub enum Message { /// Timer pulse while the cursor is dwelling on a grip; drives the /// dwell-to-popup transition without requiring further mouse motion. GripDwellTick, - /// One frame has been presented since a layout switch; the next may do the - /// expensive rebuild. + /// The notice redraw allows the next frame to build the scene. LayoutSettled, /// Timer pulse while a rollover hit-test is queued; fires when the /// cursor has been still long enough to safely run the pick. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 47ae2acb7..5e04de369 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3046,8 +3046,7 @@ impl OpenCADStudio { } Message::LayoutSettled => { - // The notice frame has been presented, so the compositor has - // had its answer. Let the next frame build the real thing. + // Restore the scene after the notice redraw. self.layout_settling = false; Task::none() } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 141dc8ef3..7541e8742 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -5535,9 +5535,7 @@ impl OpenCADStudio { .layers .sync_with_viewports(&doc_layers, vp_info); let layers_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; - // The switch is cheap; the frame that follows it is not. Draw a notice - // for one frame first, so the compositor gets an answer before the main - // thread disappears into rebuilding the wire set. + // Give the layout rebuild a visible notice first. self.layout_settling = true; if perf { crate::perf_record!( diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 334de7834..2895b2aae 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -36,11 +36,7 @@ pub(in crate::app) const VIEWPORT_CAPTURE_BOUNDS_ID: &str = "viewport-capture-bo const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX; -/// Base surface directly under the crosshair, selection marquee, and grid overlay. -/// In paper space, drafting surface context is unconditionally the white sheet (`tab.scene.paper_bg_color`), -/// matching AutoCAD's model (where crosshairs and overlays adapt per context — model vs. sheet/layout — -/// rather than per-pixel cursor sampling). This avoids flicker across sheet edges and provides a clean -/// dark crosshair that maintains ~5.85:1 contrast against the default medium-gray desk margin (#8A8A8A). +/// Background used by drafting overlays in model or paper space. fn crosshair_background(tab: &DocumentTab, is_paper: bool) -> [f32; 4] { if !is_paper { return tab.scene.bg_color; @@ -273,10 +269,7 @@ impl OpenCADStudio { // borders) and the floating content viewports blit on top. mark("viewport_3d"); let viewport_3d: Element<'_, Message> = if self.layout_settling && !tab.is_start { - // One cheap frame between the switch and the rebuild. No shader - // widget means no scene primitive, so `prepare` has nothing to do - // and the event loop turns — which is what stops the compositor - // deciding the application has stopped responding. + // Omit the shader and drafting overlays for the notice frame. let bg = crosshair_background(tab, is_paper); let lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]; let ink = if lum > 0.5 { @@ -376,7 +369,7 @@ impl OpenCADStudio { // 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 { + let model_input_layer: Option> = if is_paper || tab.is_start || self.layout_settling { None } else { let scene = &tab.scene; @@ -394,7 +387,9 @@ impl OpenCADStudio { }; mark("grid"); - let grid_overlay = { + let grid_overlay = if self.layout_settling || tab.is_start { + Space::new().into() + } else { let (vw, vh) = tab.scene.selection.borrow().vp_size; let model_basis = { let (o, ux, uy, uz) = tab.ucs_xform().axes(); @@ -479,7 +474,9 @@ bg={bg_ms:.1}ms n={view_count}" }; mark("selection_overlay"); - let selection_overlay = { + let selection_overlay = if self.layout_settling || tab.is_start { + Space::new().into() + } else { // Hold a single `Ref<'_, SelectionState>` for the whole overlay // block. The `vp_size` and `last_move_pos` reads below become // field accesses on `sel_ref` (no redundant `borrow()`s), and @@ -996,7 +993,7 @@ bg={bg_ms:.1}ms n={view_count}" }; mark("viewport_stack"); - let mut viewport_stack = if tab.is_start { + let mut viewport_stack = if tab.is_start || self.layout_settling { // Start tab: only the welcome widget over a flat background. // Skip every drawing-only overlay (selection markers, snap info, // mouse-area capturing draw clicks, viewcube, nav toolbar, …). @@ -1057,7 +1054,7 @@ bg={bg_ms:.1}ms n={view_count}" .height(Fill) }; - if !thumbnail_capture_clean { + if !thumbnail_capture_clean && !self.layout_settling { // Per-pane input pane_grid goes ABOVE the crosshair overlay so it // receives mouse events (the overlay's `Hidden` cursor would otherwise // starve any layer beneath it). The controls bar is pushed on top of it. @@ -2279,10 +2276,7 @@ impl OpenCADStudio { // the mouse perfectly still — `ViewportMove` alone would never // fire again. Auto-stops once the hover clears or the popup is // already open. - // One frame after a layout switch, then stop. `window::frames` fires - // once a frame has actually been presented, which is the guarantee this - // needs: the notice is on screen and the compositor has been answered - // before the expensive frame starts. + // Resume scene construction after the notice redraw. let layout_settle = if self.layout_settling { window::frames().map(|_| Message::LayoutSettled) } else { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index adde471ab..d817ff041 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1495,8 +1495,8 @@ pub enum ViewportRefreshScope { All, } -/// `(geometry_epoch, entities per block, entities no block claims)`. -type BlockMembers = (u64, HashMap>, Vec); +/// Entity membership in document order, keyed by geometry epoch. +type BlockMembers = (u64, HashMap>); pub struct Scene { pub camera: Rc>, @@ -1836,24 +1836,9 @@ pub struct Scene { /// Reverse map: entity_handle → block_record_handle, built from entity_handles lists. /// Keyed by geometry_epoch. Eliminates the O(B) fallback scan in belongs_to_visible_block. entity_block_map_cache: RefCell)>>, - /// Candidate entities per block, keyed by `geometry_epoch`. - /// - /// Selecting a block's contents used to walk every entity in the document - /// and ask `belongs_to_visible_block` about each: 313 ms of a 334 ms - /// resident build for a paper sheet holding 43 entities, on a drawing with - /// 1.17 M. The membership rules only depend on owner handles and the block - /// records, so one pass answers them for every block at once. - /// - /// The second field holds entities no block claims, which - /// `belongs_to_visible_block` treats as belonging to everything — but only - /// when no block record enumerates its contents at all. + /// Candidate handles per block, in document order and keyed by geometry epoch. block_members_cache: RefCell>, - /// Handles of entities the quadtree cannot index — Insert, Viewport, Block, - /// BlockEnd — in document order, keyed by `geometry_epoch`. - /// - /// The quadtree path has to append these regardless of the view, and did so - /// by walking every entity in the document: ~300 ms of every resident - /// rebuild on a 1.17 M-entity drawing, to find a few thousand. + /// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index. unindexable_cache: RefCell)>>, /// Distinct entity type names present in a layout, keyed by /// (geometry_epoch, layout block). The status bar asks for this on every @@ -1958,17 +1943,9 @@ pub struct Scene { /// Hash of the tessellation parameters `tess_memo` was built under. When /// the current call's parameters differ, the memo is stale and cleared. tess_memo_guard: std::cell::Cell, - /// Per-entity memo for the **resident** model wire set (`model_tile_wires_arc`, - /// the one the main GPU render holds). Kept separate from `tess_memo` because - /// the resident set is camera-INDEPENDENT (no view cull, no zoom LOD), so its - /// guard depends only on anno-scale — it survives pan/zoom and layout switches, and a - /// single-entity edit re-tessellates just the changed entity instead of the - /// whole model. Sharing `tess_memo` would let the camera-dependent culled path - /// thrash it on every zoom. (#perf) + /// Camera-independent model tessellation, separate from the culled memo. resident_tess_memo: RefCell>>>, - /// Guard hash for `resident_tess_memo` (anno-scale only). The background - /// is not part of it: it changes colours, not geometry, and - /// `resolve_colors_for_bg` reapplies it to the assembled set. + /// Annotation, background, viewport and glyph-atlas signature. resident_tess_guard: std::cell::Cell, /// Per-mutation delta journal: which handles changed on each `geometry_epoch` /// bump. Bounded ring ([`GEOMETRY_JOURNAL_CAP`]); a derived cache replays the @@ -8916,7 +8893,7 @@ impl Scene { // the compiler enforces that. A default here is what made the first // version of this diagnostic report a constant. let visible_path: &str; - let mut visible_candidates = 0usize; + let mut visible_candidates: usize; // Phase 2.1 — quadtree-driven candidate selection. When a view // AABB exists (Model layout with a settled camera), only iterate @@ -8978,6 +8955,7 @@ impl Scene { // — the legacy-DXF case. Both need the full walk, and both must // keep document order, which is draw order for the wire pass. visible_path = "full-scan"; + visible_candidates = self.document.entity_count(); self.document .entities() .filter(|e| visibility_ok(e)) @@ -8993,7 +8971,7 @@ impl Scene { // in exactly the order the full scan produced. visible_path = "block-index"; let members = self.block_members(); - let (_, by_block, _) = &*members; + let (_, by_block) = &*members; let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]); visible_candidates = claimed.len(); let mut out: Vec<&EntityType> = Vec::with_capacity(claimed.len()); @@ -9089,11 +9067,7 @@ impl Scene { 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's GEOMETRY depends on - // besides the entity itself. A mismatch (zoom/tol, view, anno, - // offset, entered viewport) means the memo is stale. For the - // resident path wpp/view_aabb are None, so this collapses to anno. - // The background is excluded on purpose — see the note below. + // Invalidate when any baked display input changes. let guard = { let mut g: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |x: u64| g = g.rotate_left(13) ^ x; @@ -9106,13 +9080,11 @@ impl Scene { mix(anno.to_bits() as u64); mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); mix(all_visible as u64); - // The background is deliberately NOT mixed in. It changes - // colours, never geometry — `local_wires_for` discards it with - // `let _ = bg_color;` and `Batches::finalize` applies it last — - // so a memo entry built under one background serves any other - // once `resolve_colors_for_bg` has run over the assembled set - // below. Keying on it made a layout switch re-tessellate the - // whole model to arrive at identical points. + // Direct entities and inherited/faded block colours still bake + // the background before Batches::finalize records its inputs. + for channel in bg { + mix(channel.to_bits() as u64); + } mix(avp.map(|h| h.value()).unwrap_or(0)); // SDF glyph quads bake the atlas UV of each tile, so a growth or // a re-bake (which rescale / rewind every UV) makes memoized text @@ -9283,21 +9255,7 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", wires } - /// Decide whether an entity should be drawn as direct content of `block_handle`. - /// Entities each block may contain, and the ones no block claims. - /// - /// Mirrors `belongs_to_visible_block` exactly, resolved once per - /// `geometry_epoch` for every block instead of per entity per query: - /// - /// * a non-null owner *is* the block, so the entity is that block's; - /// * a null owner falls back to the reverse map built from the block - /// records' `entity_handles`; - /// * an entity neither names nor is named by any block is "unowned", and - /// counts towards every block only when no block record enumerates its - /// contents at all — the legacy-DXF case that predicate allows. - /// Entities the quadtree cannot index, in document order, resolved once per - /// `geometry_epoch`. Membership depends only on the entity's type, so an - /// edit that does not add or remove one leaves the list unchanged. + /// Unindexable entity handles in document order, cached per geometry epoch. fn unindexable_handles(&self) -> std::cell::Ref<'_, (u64, Vec)> { { let cache = self.unindexable_cache.borrow(); @@ -9318,10 +9276,11 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", std::cell::Ref::map(self.unindexable_cache.borrow(), |c| c.as_ref().unwrap()) } + /// Resolve owner handles and block-record membership once per geometry epoch. fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> { { let cache = self.block_members_cache.borrow(); - if cache.as_ref().is_some_and(|(epoch, _, _)| *epoch == self.geometry_epoch) { + if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { drop(cache); return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { c.as_ref().unwrap() @@ -9329,7 +9288,6 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", } } let mut members: HashMap> = HashMap::default(); - let mut unowned: Vec = Vec::new(); { let map = self.entity_block_map(); for entity in self.document.entities() { @@ -9339,16 +9297,15 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", members.entry(owner).or_default().push(common.handle); } else if let Some(&block) = map.get(&common.handle) { members.entry(block).or_default().push(common.handle); - } else { - unowned.push(common.handle); } } } *self.block_members_cache.borrow_mut() = - Some((self.geometry_epoch, members, unowned)); + Some((self.geometry_epoch, members)); std::cell::Ref::map(self.block_members_cache.borrow(), |c| c.as_ref().unwrap()) } + /// Whether the entity is direct content of this block. fn belongs_to_visible_block( &self, entity_handle: Handle, @@ -10044,15 +10001,7 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", std::cell::Ref::map(self.entity_index_cache.borrow(), |c| &c.as_ref().unwrap().1) } - /// Full tessellation pipeline for one entity. - /// 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`. + /// Resolve the active layout and annotation context for tessellation. fn tessellation_context( &self, ) -> ([f32; 4], f32, Option, Arc, bool) { @@ -10227,167 +10176,43 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", if model_block.is_null() { return None; } - let mut min = glam::Vec3::splat(f32::INFINITY); - let mut max = glam::Vec3::splat(f32::NEG_INFINITY); - let mut any = false; - - // Prefer the already-computed wire AABB cache when available — avoids re-tessellating. - if self.current_layout == "Model" { - let cache = self.wire_cache.borrow(); - if let Some(((epoch, _cam_gen), _gen, ref arc)) = *cache { - if epoch == self.geometry_epoch { - for wire in arc.iter() { - let [ax, ay, bx, by] = wire.aabb; - let lo = glam::Vec3::new(ax, ay, 0.0); - let hi = glam::Vec3::new(bx, by, 0.0); - // Reject the whole AABB unless every component is finite: - // rays/xlines carry an unbounded AABB, and checking only - // x let a vertical ray's infinite y poison the extents. - if lo.is_finite() && hi.is_finite() { - min = min.min(lo); - max = max.max(hi); - any = true; - } - } - // 3D solids render as meshes, not wires, so fold their - // complete 3D boxes in too — otherwise view fitting loses - // both solid-only drawings and their depth. - for (&handle, set) in &self.meshes { - if !self.mesh_entity_visible(handle) - || !self.document.get_entity(handle).is_some_and(|entity| { - self.belongs_to_visible_block( - handle, - entity.common().owner_handle, - model_block, - ) - }) - { - continue; - } - let [ax, ay, bx, by] = set.world_aabb; - let [az, bz] = set.z_aabb; - let lo = glam::Vec3::new(ax, ay, az); - let hi = glam::Vec3::new(bx, by, bz); - if lo.is_finite() && hi.is_finite() { - min = min.min(lo); - max = max.max(hi); - any = true; - } - } - return if any { Some((min, max)) } else { None }; - } - } - } - - // Fallback: tessellate (first call or paper-space context). - // wire.key_vertices live in offset-rel coords (world_offset - // already subtracted at tessellation time). Add it back so the - // result matches Path 1 above and the caller's expectation — - // callers (auto_fit_viewport) write the centroid directly to - // `Viewport.view_target`, which is a WCS field; storing - // 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. - // - // `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 { + // Reuse the full resident source in every layout. The kernel bounds + // its world-space key vertices; camera fitting never tessellates a + // second copy or reads the current paper sheet's wire cache. + let scale = crate::scene::annotative::scale_handle_by_name( + &self.document, + &self.document.header.current_annotation_scale, + ); + let wires = self.resident_wires_for( + model_block, + Some(self.annotation_scale), + scale, + None, + None, + ); + let wire_points = wires.iter().flat_map(|wire| wire.key_vertices.iter().copied()); + let mesh_points = self.meshes.iter().filter_map(|(&handle, set)| { + let entity = self.document.get_entity(handle)?; if !self.mesh_entity_visible(handle) - || !self.document.get_entity(handle).is_some_and(|entity| { - self.belongs_to_visible_block( - handle, - entity.common().owner_handle, - model_block, - ) - }) + || !self.belongs_to_visible_block(handle, entity.common().owner_handle, model_block) { - continue; + return None; } let [ax, ay, bx, by] = set.world_aabb; let [az, bz] = set.z_aabb; - let lo = glam::Vec3::new(ax, ay, az); - let hi = glam::Vec3::new(bx, by, bz); - if lo.is_finite() && hi.is_finite() { - min = min.min(lo); - max = max.max(hi); - any = true; - } - } - if any { - return Some((min, max)); + Some([ + [ax as f64, ay as f64, az as f64], + [bx as f64, by as f64, bz as f64], + ]) + }).flatten(); + let points = wire_points.chain(mesh_points).filter(|point| { + point.iter().all(|coordinate| (*coordinate as f32).is_finite()) + }); + if let Some(bounds) = cadkernel::brep::bounds::Aabb::around(points) { + return Some(( + glam::Vec3::from_array(bounds.min.map(|v| v as f32)), + glam::Vec3::from_array(bounds.max.map(|v| v as f32)), + )); } // Last resort: saved EXTMIN/EXTMAX before the wire cache is built. const SANE_EXTENT: f64 = 1.0e16; @@ -10649,7 +10474,7 @@ mod journal_tests { // What the membership index reports for `block`, in order. fn indexed(s: &Scene, block: Handle) -> Vec { let members = s.block_members(); - let (_, by_block, _) = &*members; + let (_, by_block) = &*members; by_block.get(&block).cloned().unwrap_or_default() } @@ -11311,3 +11136,83 @@ mod selection_arc_tests { assert!(true); } } + +#[cfg(test)] +mod layout_cache_tests { + use super::*; + #[test] + fn memoized_plain_line_matches_fresh_background() { + let mut s = Scene::new(); + s.bg_color = [0.1, 0.1, 0.1, 1.0]; + let handle = s.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ))); + let build = |s: &Scene| { + s.wires_for_block_culled( + s.model_space_block_handle(), + None, + None, + None, + None, + None, + true, + None, + ) + .into_iter() + .find(|wire| wire.name == handle.value().to_string()) + .unwrap() + }; + let original = build(&s); + assert!(s.resident_tess_memo.borrow().contains_key(&handle)); + s.paper_bg_color = [1.0; 4]; + s.document.add_layout("Review").unwrap(); + s.set_current_layout("Review".to_string()); + let cached = build(&s); + s.resident_tess_memo.borrow_mut().clear(); + let fresh = build(&s); + assert_ne!( + original.color, fresh.color, + "fixture must depend on background" + ); + assert_eq!( + cached.color, fresh.color, + "memo hit should match fresh tessellation on a white background" + ); + } + + #[test] + fn model_extents_reuse_resident_geometry_in_every_layout() { + let mut s = Scene::new(); + s.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(1_000_000.0, 2_000_000.0, 30.0), + acadrust::types::Vector3::new(1_000_010.0, 2_000_020.0, 40.0), + ))); + s.document.add_layout("Review").unwrap(); + for layout in ["Model", "Review"] { + s.set_current_layout(layout.to_string()); + *s.model_extents_cache.borrow_mut() = None; + let scale = crate::scene::annotative::scale_handle_by_name( + &s.document, + &s.document.header.current_annotation_scale, + ); + let source = s.resident_wires_for( + s.model_space_block_handle(), + Some(s.annotation_scale), + scale, + None, + None, + ); + let generation = s.last_model_wire_gen.get(); + let bounds = s.model_space_extents().unwrap(); + assert_eq!(bounds.0, glam::Vec3::new(1_000_000.0, 2_000_000.0, 30.0)); + assert_eq!(bounds.1, glam::Vec3::new(1_000_010.0, 2_000_020.0, 40.0)); + assert_eq!( + s.last_model_wire_gen.get(), + generation, + "fitting must reuse the resident source" + ); + assert!(!source.is_empty()); + } + } +} diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 72fe30e25..90064d106 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -319,3 +319,92 @@ fn toggling_shadows_reallocates_without_upsetting_the_device() { "GPU validation failed" ); } + +#[test] +#[ignore = "requires a GPU adapter"] +fn urgent_release_preserves_visible_panes_and_frees_cold_slots() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).unwrap(); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .unwrap(); + let mut pipeline = ::new( + &device, + &queue, + wgpu::TextureFormat::Bgra8UnormSrgb, + ); + let first = pipeline.resolve_slots(&device, &queue, &[10])[0]; + pipeline.inners[first].slot_id = 10; + pipeline.inners[first].ensure_depth_texture(&device, Size::new(512, 512)); + let second = pipeline.resolve_slots(&device, &queue, &[20])[0]; + pipeline.inners[second].slot_id = 20; + pipeline.inners[second].ensure_depth_texture(&device, Size::new(512, 512)); + pipeline.release_idle_slots(&device, &[second], true); + assert_eq!( + pipeline.inners[first].alloc_size, + Size::new(512, 512), + "a sibling pane prepared immediately before this pane is still visible" + ); + + pipeline + .frame_rendered + .store(true, std::sync::atomic::Ordering::Relaxed); + let next = pipeline.resolve_slots(&device, &queue, &[20]); + assert_eq!( + pipeline.release_idle_slots(&device, &next, true), + 0, + "a previous-frame pane may still be prepared later in this frame" + ); + pipeline + .frame_rendered + .store(true, std::sync::atomic::Ordering::Relaxed); + let next = pipeline.resolve_slots(&device, &queue, &[20]); + assert_eq!(pipeline.release_idle_slots(&device, &next, true), 1); + assert_eq!(pipeline.inners[first].alloc_size, Size::new(0, 0)); + assert_eq!(pipeline.inners[second].alloc_size, Size::new(512, 512)); +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn late_device_errors_invalidate_uploaded_content_and_targets() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).unwrap(); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .unwrap(); + let mut pipeline = ::new( + &device, + &queue, + wgpu::TextureFormat::Bgra8UnormSrgb, + ); + let inner = &mut pipeline.inners[0]; + inner.cached_wire_id = 7; + inner.wire_arena_id = 7; + inner.ensure_depth_texture(&device, Size::new(512, 512)); + let before = gpu_errors_seen(); + let _rejected = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("rejected test allocation"), + size: device.limits().max_buffer_size + 4, + usage: wgpu::BufferUsages::VERTEX, + mapped_at_creation: false, + }); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + assert!(gpu_errors_seen() > before); + inner.sync_gpu_error_epoch(); + assert_eq!(inner.cached_wire_id, u64::MAX); + assert_eq!(inner.wire_arena_id, u64::MAX); + assert_eq!(inner.alloc_size, Size::new(0, 0)); + assert_eq!(inner.background_source_id, usize::MAX); + inner.cached_wire_id = 8; + inner.sync_gpu_error_epoch(); + assert_eq!( + inner.cached_wire_id, 8, + "the same error must not invalidate twice" + ); +} diff --git a/src/scene/pipeline/hatch_gpu/mod.rs b/src/scene/pipeline/hatch_gpu/mod.rs index 8731bfd9c..ea5db5789 100644 --- a/src/scene/pipeline/hatch_gpu/mod.rs +++ b/src/scene/pipeline/hatch_gpu/mod.rs @@ -159,6 +159,19 @@ impl HatchGpu { } } + pub(super) fn clear(&mut self) { + match &mut self.backend { + HatchBackend::Storage { resident, preview } => { + resident.clear(); + preview.clear(); + } + HatchBackend::Texture { resident, preview } => { + resident.clear(); + preview.clear(); + } + } + } + pub fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, hatches: &[HatchModel]) { match &mut self.backend { HatchBackend::Storage { resident, .. } => { diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index da2248a23..908fece79 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -74,6 +74,7 @@ struct MeshHighlightDraw { } pub struct Pipeline { + gpu_error_epoch: usize, background_pipeline: wgpu::RenderPipeline, shadow_pipeline: wgpu::RenderPipeline, shadow_plain_pipeline: wgpu::RenderPipeline, @@ -2226,6 +2227,7 @@ impl Pipeline { wire_const_bgl, block_wire_const_bgl, block_wire_mode: wire_mode, + gpu_error_epoch: gpu_errors_seen(), wipeout_pipeline, hatch_gpu, image_pipeline, @@ -3557,9 +3559,12 @@ impl Pipeline { fn sync_shadow_target(&mut self, device: &wgpu::Device) { match (self.shadow_enabled, self.shadow_full.is_some()) { (true, false) => { + let errors_before = gpu_errors_seen(); let texture = create_shadow_texture(device, SHADOW_MAP_SIZE); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - self.shadow_full = Some((texture, view)); + if gpu_errors_seen() == errors_before { + self.shadow_full = Some((texture, view)); + } } (false, true) => self.shadow_full = None, _ => return, @@ -3575,7 +3580,11 @@ impl Pipeline { ) { self.shadow_enabled = uniforms.shadow_params[0] > 0.5; self.sync_shadow_target(device); - queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms)); + let mut uniforms = *uniforms; + if self.shadow_full.is_none() { + uniforms.shadow_params[0] = 0.0; + } + queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); } pub fn upload_background_images( @@ -3710,9 +3719,7 @@ impl Pipeline { // render rectangle does the clipping). let stencil_ref: u32 = if self.clip_boundary.is_some() { 0xFF } else { 0 }; - // `shadow_enabled` says the style wants shadows; the target existing - // says the device could give us one. Without the second test a failed - // 16 MiB allocation would render the pass into the 1x1 placeholder. + // Render shadows only when a full target was allocated. if let Some(shadow_target) = self .shadow_full .as_ref() @@ -4671,6 +4678,23 @@ impl Pipeline { self.render_sig = u64::MAX; } + /// Catch errors delivered after the previous upload/submit completed. + pub(crate) fn sync_gpu_error_epoch(&mut self) { + let errors = gpu_errors_seen(); + if errors == self.gpu_error_epoch { + return; + } + self.gpu_error_epoch = errors; + self.forget_cached_keys(); + self.wire_arena = None; + self.wire_arena_mesh = None; + self.wire_arena_id = u64::MAX; + self.alloc_size = Size::new(0, 0); + self.shadow_full = None; + self.background_source_id = usize::MAX; + self.environment_source_id = usize::MAX; + } + /// Give this slot's device memory back and forget what it held. /// /// A slot nobody is drawing still owns its wire arena, its render targets, @@ -4698,6 +4722,7 @@ impl Pipeline { self.gpu_selected_wires.clear(); self.gpu_selected_block_wires.clear(); self.gpu_preview_wires.clear(); + self.hatch_gpu.clear(); self.gpu_wipeouts.clear(); self.wipeout_skip_flags.clear(); self.gpu_images.clear(); @@ -4928,19 +4953,8 @@ impl Pipeline { } impl MultiPipeline { - /// Hand back the device memory of slots nobody is drawing. - /// - /// `reserved` are the slots this frame is about to use; they are never - /// touched. Everything else is released once it has been idle for - /// `idle_frames` prepare calls — gradually, so an ordinary session pays - /// nothing and a layout the user keeps switching back to stays warm. - /// - /// `urgent` drops that patience: the device has just refused an allocation, - /// and holding a viewport nobody can see is what turns a degraded frame - /// into a session that never recovers. Releasing early costs one slow frame - /// if the slot is wanted again; not releasing costs the drawing. - /// - /// Returns how many slots were released. + /// Release cold slots after 64 frames, or two frames under memory pressure. + /// Current and previous frame slots remain reserved for sibling widgets. pub(crate) fn release_idle_slots( &mut self, device: &wgpu::Device, @@ -4948,7 +4962,9 @@ impl MultiPipeline { urgent: bool, ) -> usize { const IDLE_FRAMES: u64 = 64; - let idle_frames = if urgent { 0 } else { IDLE_FRAMES }; + // Other shader widgets may prepare later in this frame. Keep every + // slot used in this or the previous frame, even under memory pressure. + let idle_frames = if urgent { 2 } else { IDLE_FRAMES }; let mut released = 0; for index in 0..self.inners.len() { if reserved.contains(&index) { @@ -4970,6 +4986,15 @@ impl MultiPipeline { self.inners[index].release_heavy_resources(device); released += 1; } + if released > 0 { + self.wire_buffer_cache.retain(|_, entry| { + std::sync::Arc::strong_count(&entry.0) > 1 + || std::sync::Arc::strong_count(&entry.1) > 1 + }); + self.block_geometry.retain(|_, chunks| { + chunks.iter().any(|chunk| std::sync::Arc::strong_count(&chunk.bind_group) > 1) + }); + } released } @@ -5104,25 +5129,11 @@ pub struct MultiPipeline { pub(crate) slot_by_instance: rustc_hash::FxHashMap, slot_last_used: Vec, slot_clock: u64, - /// The resident wire batches, keyed by `wire_content_id` and shared across - /// every slot (and every pane — one `MultiPipeline` backs all of them) that - /// renders the same content. `prepare` builds an entry once on a cache miss - /// then hands `Arc` clones to each slot, so N paper viewports / Model tiles - /// showing one identical resident set upload the wire vertex buffers exactly - /// once between them instead of once per slot. Kept trim by dropping entries - /// no slot still references (`Arc::strong_count == 1`) once it grows past a - /// small bound. - /// Block geometry that survives an edit, keyed by the definition it - /// expands rather than by anything a viewport contributes. See - /// `wire_gpu::BlockGeometryCache`. - /// - /// Shared across slots for the same reason `wire_buffer_cache` is: a - /// definition's segments are identical in every viewport that draws it, and - /// this is the largest upload the renderer makes — 59.5 MiB on the - /// reproducer. Held per slot, zooming a paper layout out until four or five - /// viewports were on screen uploaded it once each and ran a 2 GB card out - /// of memory. + pub(crate) frame_rendered: std::sync::atomic::AtomicBool, + pub(crate) gpu_error_epoch: usize, + /// Definition geometry shared across viewport slots. pub(crate) block_geometry: wire_gpu::BlockGeometryCache, + /// Shared resident batches, keyed by wire content identity. pub(crate) wire_buffer_cache: rustc_hash::FxHashMap< u64, ( @@ -5134,11 +5145,7 @@ pub struct MultiPipeline { } impl MultiPipeline { - /// Ensure at least `n` (≥1) inner pipelines exist, creating any missing - /// ones. Grow-only: extra pipelines are NOT dropped, because per-pane Model - /// shader widgets share this storage and each only touches its own slot — - /// truncating mid-frame would destroy another pane's slot. Stale inners (a - /// closed pane, or fewer paper viewports) are harmless idle GPU resources. + /// Grow slot storage without disturbing sibling shader widgets. pub(crate) fn ensure_len( &mut self, device: &wgpu::Device, @@ -5152,12 +5159,7 @@ impl MultiPipeline { } } - /// Resolve stable slots for the viewport identities in one primitive. - /// Thirty-two hot slots cover ordinary tiled/paper drawings. A cold slot - /// is recycled only after several other prepare calls, which prevents - /// sibling Model panes prepared in the same frame from evicting each - /// other. If every slot is still hot, growing is safer than a visible - /// rebuild hitch. + /// Preserve viewport slots across frames; recycle only cold slots above the limit. pub(crate) fn resolve_slots( &mut self, device: &wgpu::Device, @@ -5167,7 +5169,11 @@ impl MultiPipeline { const SOFT_LIMIT: usize = 32; const HOT_WINDOW: u64 = 8; - self.slot_clock = self.slot_clock.wrapping_add(1).max(1); + if self.frame_rendered.swap(false, std::sync::atomic::Ordering::Relaxed) + || self.slot_clock == 0 + { + self.slot_clock = self.slot_clock.wrapping_add(1).max(1); + } let now = self.slot_clock; let reserved: rustc_hash::FxHashSet = instance_ids.iter().copied().collect(); // `inner.slot_id` is updated later in `prepare`, after this whole @@ -5284,6 +5290,8 @@ impl iced::widget::shader::Pipeline for MultiPipeline { slot_by_instance: rustc_hash::FxHashMap::default(), slot_last_used: vec![0], slot_clock: 0, + frame_rendered: std::sync::atomic::AtomicBool::new(false), + gpu_error_epoch: gpu_errors_seen(), wire_buffer_cache: rustc_hash::FxHashMap::default(), } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 89c90f10a..72fd2b92d 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -364,20 +364,20 @@ impl shader::Primitive for Primitive { viewport: &Viewport, ) { let nav_prepare_started = iced::time::Instant::now(); + let errors_at_entry = crate::scene::pipeline::gpu_errors_seen(); + let recovering = pipeline.gpu_error_epoch != errors_at_entry; + if recovering { + pipeline.wire_buffer_cache.clear(); + pipeline.block_geometry.clear(); + pipeline.gpu_error_epoch = errors_at_entry; + } let scale = viewport.scale_factor() as f32; let instance_ids: Vec = self.viewports.iter().map(|vp| vp.instance_id).collect(); let slots = pipeline.resolve_slots(device, queue, &instance_ids); - // Compared after the frame's uploads: a move means the device refused - // something, which changes how patient the slot release below can be. - let errors_at_entry = crate::scene::pipeline::gpu_errors_seen(); - for (i, vp) in self.viewports.iter().enumerate() { let inner = &mut pipeline.inners[slots[i]]; - // Compared at the end of this viewport's turn. Every "we hold this - // now" latch below is stamped after its upload, whether or not the - // device accepted it — and the wire latch gates the whole upload - // block, so one rejected allocation could leave a slotpermanently empty with - // nothing that would ever rebuild it. + inner.sync_gpu_error_epoch(); + // Do not latch rejected uploads as current content. let slot_errors_before = crate::scene::pipeline::gpu_errors_seen(); // Pipeline slots are addressed by list index, but off-canvas // viewports are dropped from the list — so a slot can be reused by a @@ -1216,13 +1216,9 @@ impl shader::Primitive for Primitive { self.viewports.len(), ); } - // Give back what nobody is drawing. Gradual while the device is - // healthy; immediate once it has refused an allocation, because the - // memory a hidden viewport is holding is exactly what the visible one - // could not get — and freeing it is what lets the operator close a - // viewport or two and carry on instead of restarting. + // Memory pressure shortens cold-slot retention without evicting siblings. let errors_now = crate::scene::pipeline::gpu_errors_seen(); - let urgent = errors_now != errors_at_entry; + let urgent = recovering || errors_now != errors_at_entry; let released = pipeline.release_idle_slots(device, &slots, urgent); if released > 0 && crate::perf::enabled() { crate::perf_record!( @@ -1241,6 +1237,7 @@ impl shader::Primitive for Primitive { clip: &Rectangle, ) { let nav_render_started = iced::time::Instant::now(); + pipeline.frame_rendered.store(true, std::sync::atomic::Ordering::Relaxed); let cw = clip.width as f32; let ch = clip.height as f32; let clip_right = clip.x + clip.width;