diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index c0338df..0859aba 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -124,7 +124,9 @@ mod web { if !crate::wants_cpu(&query()) && let Some(rs) = &cc.wgpu_render_state { - app.set_wgpu(rs); + // eframe's web runner has no stencil buffer: clipped layout + // viewports stay in the layout's display list. + app.set_wgpu(rs, None); } if query().contains("sample") { let _ = app.run("ui.sample", serde_json::json!({})); diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 43f6b76..7101991 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -175,6 +175,9 @@ fn config_dir_from(var: impl Fn(&str) -> Option) -> Option eframe::Result { .with_title_shown(false) .with_app_id(APP_ID), persist_window: false, + // A stencil buffer clips the contents of clipped layout viewports on the GPU. + stencil_buffer: STENCIL_BITS, ..Default::default() }; if let Some(icon) = app_icon() { @@ -310,7 +315,7 @@ fn main() -> eframe::Result { app.frame_cap = Some(cadcraft_ui_egui::gpu::FrameCap::new(240)); } if let Some(rs) = &cc.wgpu_render_state { - app.set_wgpu(rs); + app.set_wgpu(rs, eframe::egui_wgpu::depth_format_from_bits(0, STENCIL_BITS)); } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 87136ed..aaed626 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -378,6 +378,7 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String view_height: 0.0, plot_style_table, view: None, + skip_viewport: None, }; let k = unit_mm * PT_PER_MM; let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0f35765..166157c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -191,11 +191,24 @@ pub struct Options { /// The area being looked at (world units of the space being built), for on-screen builds: /// hatch patterns are only generated inside it. `None` = everywhere (plots, exports, picks). pub view: Option, + /// A layout viewport whose model-space contents are left out (its border is still drawn): + /// the canvas draws the active viewport's contents itself, see [`viewport_content`]. + pub skip_viewport: Option, } impl Default for Options { fn default() -> Self { - Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false, view_height: 0.0, plot_style_table: None, view: None } + Options { + tolerance: 0.001, + min_dash: 0.0, + text: true, + fill: true, + lineweights: false, + view_height: 0.0, + plot_style_table: None, + view: None, + skip_viewport: None, + } } } @@ -408,15 +421,7 @@ fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_c fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> DisplayList { let opts = &with_point_view(d, space, opts); - let table = opts.plot_style_table.clone().or_else(|| match space { - Space::Paper(n) if !plotting => d - .layout(n) - .filter(|l| l.page.show_plot_styles) - .and_then(|l| cadcraft_doc::plot_style_table(d, &l.page.plot_style_table)) - .map(std::sync::Arc::new), - _ => None, - }); - let styler = table.map(|t| plotstyle::Styler::new(d, t)); + let styler = style_table(d, space, opts, plotting).map(|t| plotstyle::Styler::new(d, t)); let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None }; if let Space::Paper(name) = space { b.list.sheet = paper::sheet(d, name); @@ -510,6 +515,19 @@ fn append(dst: &mut DisplayList, src: DisplayList) { dst.expanded = dst.expanded.saturating_add(src.expanded); } +/// The plot style table a build draws with: the one in `opts`, else (on screen) the layout's +/// when its page setup has "Display plot styles" on. +fn style_table(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Option> { + opts.plot_style_table.clone().or_else(|| match space { + Space::Paper(n) if !plotting => d + .layout(n) + .filter(|l| l.page.show_plot_styles) + .and_then(|l| cadcraft_doc::plot_style_table(d, &l.page.plot_style_table)) + .map(std::sync::Arc::new), + _ => None, + }) +} + /// `opts` with a view height for relative point sizes: the extents height when none is given /// and points draw as figures sized relative to the view. fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Options { @@ -530,11 +548,7 @@ const MAX_VIEWPORTS: usize = 256; /// model space through its clip boundary's outline (the boundary entity draws itself, so the /// rectangle is not drawn); a viewport turned off shows only its border. fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewport) { - if vp.id == 1 || !e.common.visible || d.is_hidden_object(e.handle) { - return; - } - // The viewport's layer frozen hides its contents too (layer off hides only the border). - if d.layer(&e.common.layer).is_some_and(|l| l.frozen) { + if !viewport_shown(d, e, vp) { return; } let poly = viewport_clip(d, e.handle, vp); @@ -542,6 +556,57 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor if poly.is_none() { entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); } + if b.opts.skip_viewport == Some(e.handle) { + return; + } + match &poly { + Some(p) => contents(b, d, vp, p.bounds(), &Clip::Poly(p)), + None => { + if let Some(rect) = viewport_rect(vp) { + contents(b, d, vp, rect, &Clip::Rect(rect)); + } + } + } +} + +/// Whether a viewport draws anything: id 1 is the paper-space view itself, and a hidden viewport +/// or one on a frozen layer shows neither its border nor its contents. +fn viewport_shown(d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewport) -> bool { + vp.id != 1 && e.common.visible && !d.is_hidden_object(e.handle) && !d.layer(&e.common.layer).is_some_and(|l| l.frozen) +} + +/// A viewport's rectangle on paper; `None` for an unusable size or centre. +fn viewport_rect(vp: &cadcraft_doc::Viewport) -> Option { + let center = vp.center.xy(); + let ok = |v: f64| v.is_finite() && v > 0.0; + (ok(vp.width) && ok(vp.height) && center.is_finite()).then(|| { + let half = Vec2::new(vp.width / 2.0, vp.height / 2.0); + Bounds2::new(center - half, center + half) + }) +} + +/// Model space seen through the layout viewport `handle` of `space`, in paper units, clipped to +/// `area` (paper units) instead of to the viewport: what [`build`] draws inside that viewport, +/// over an area the caller chooses. The canvas builds the active viewport this way for an area +/// around it and draws it clipped to the viewport, so panning and zooming inside it moves the +/// geometry instead of rebuilding the layout (see [`Options::skip_viewport`]). Empty when the +/// viewport shows nothing. +pub fn viewport_content(d: &Drawing, space: &Space, handle: Handle, area: Bounds2, opts: &Options) -> DisplayList { + let Some(e) = d.space(space).and_then(|s| s.get(handle)) else { return DisplayList::default() }; + let EntityKind::Viewport(vp) = &e.kind else { return DisplayList::default() }; + if !viewport_shown(d, e, vp) || area.is_empty() || !(area.min.is_finite() && area.max.is_finite()) { + return DisplayList::default(); + } + let opts = &with_point_view(d, space, opts); + let styler = style_table(d, space, opts, false).map(|t| plotstyle::Styler::new(d, t)); + let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: styler.as_ref(), named: None, shared_budget: None }; + contents(&mut b, d, vp, area, &Clip::Rect(area)); + b.list +} + +/// Model space seen through `vp`, in paper units, appended to `b`'s list clipped to `clip`; +/// `area` (the clip's bounds on paper) picks the model objects to draw. +fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bounds2, clip: &Clip) { if b.list.expanded >= MAX_BLOCK_EXPANSION || !vp.on { return; } @@ -554,50 +619,75 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor if !ok(s) { return; } - let half = Vec2::new(vp.width / 2.0, vp.height / 2.0); - let rect = match &poly { - Some(p) => p.bounds(), - None => Bounds2::new(center - half, center + half), - }; let xf = Mat3::translate(center).then_before(Mat3::scale(s, s)).then_before(Mat3::translate(-vp.view_center)); - let mhalf = Vec2::new(rect.width(), rect.height()) / (2.0 * s); - let mcenter = vp.view_center + (rect.center() - center) / s; + let mhalf = Vec2::new(area.width(), area.height()) / (2.0 * s); + let mcenter = vp.view_center + (area.center() - center) / s; // Model window with slack: entity bounds are approximate (text, dimensions). let win = Bounds2::new(mcenter - mhalf, mcenter + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1); - let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None, shared_budget: None }; // PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in // paper space, whatever the viewport scale. let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 }; - for me in d.model.iter() { - if !matches!(me.kind, EntityKind::Ray(_) | EntityKind::XLine(_) | EntityKind::Viewport(_)) { - let eb = cadcraft_doc::entity_bounds(d, me, 0); - if !eb.is_empty() && !eb.intersects(&win) { - continue; - } - } + let ctx = || Ctx { lt_factor, ..top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) }; + let seen = |me: &Entity| { if matches!(me.kind, EntityKind::Viewport(_)) { - continue; + return false; } - entity(&mut sub, &Ctx { lt_factor, ..top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) }, me); - // Plotted text glyphs move to the layout's list; their outlines carry the glyph's - // index there in their handle's high half until clipped (see `take_viewport_layers`). - for t in std::mem::take(&mut sub.list.texts) { - let k = b.list.texts.len() as u64 + 1; - if let Some(p) = sub.list.prims.get_mut(t.prim as usize) - && k < u64::from(u32::MAX) - { - p.handle = Handle(p.handle.0 | (k << 32)); - b.list.texts.push(PlotText { prim: PlotText::NONE, ..t }); + if matches!(me.kind, EntityKind::Ray(_) | EntityKind::XLine(_)) { + return true; + } + let eb = cadcraft_doc::entity_bounds(d, me, 0); + eb.is_empty() || eb.intersects(&win) + }; + let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None, shared_budget: None }; + #[cfg(not(target_arch = "wasm32"))] + let parallel = !b.plotting && b.styles.is_none() && d.model.len() >= 2 * PARALLEL_CHUNK; + #[cfg(target_arch = "wasm32")] + let parallel = false; + if parallel { + #[cfg(not(target_arch = "wasm32"))] + { + // As `build_parallel`: chunks built side by side, joined in drawing order. + use rayon::prelude::*; + let ents: Vec<&std::sync::Arc> = d.model.iter().collect(); + let budget = std::sync::atomic::AtomicUsize::new(0); + let opts = b.opts; + let parts: Vec = ents + .par_chunks(PARALLEL_CHUNK) + .map(|chunk| { + let mut part = + Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: Some(&budget) }; + let c = ctx(); + for me in chunk.iter().filter(|me| seen(me)) { + entity(&mut part, &c, me); + } + part.list + }) + .collect(); + for part in parts { + append(&mut sub.list, part); } } + } else { + let c = ctx(); + for me in d.model.iter().filter(|me| seen(me)) { + entity(&mut sub, &c, me); + } + } + // Plotted text glyphs move to the layout's list; their outlines carry the glyph's index there + // in their handle's high half until clipped (see `take_viewport_layers`). + for t in std::mem::take(&mut sub.list.texts) { + let k = b.list.texts.len() as u64 + 1; + if let Some(p) = sub.list.prims.get_mut(t.prim as usize) + && k < u64::from(u32::MAX) + { + p.handle = Handle(p.handle.0 | (k << 32)); + b.list.texts.push(PlotText { prim: PlotText::NONE, ..t }); + } } // The contents are in paper units now: draw their pens before clipping. pen::apply(&mut sub.list, paper::paper_unit_mm(d)); b.list.expanded = b.list.expanded.saturating_add(sub.list.expanded); - match &poly { - Some(p) => append_clipped(&mut b.list, &sub.list, &Clip::Poly(p)), - None => append_clipped(&mut b.list, &sub.list, &Clip::Rect(rect)), - } + append_clipped(&mut b.list, &sub.list, clip); } /// The clip region of a clipped viewport: the outline of its clip boundary entity, when that diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 443b72e..d3527f7 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -183,6 +183,57 @@ fn layout_viewport_frozen_layer_and_id1() { assert!(!build(&d, &Space::Model, &Options::default()).prims.is_empty()); } +/// The points of each primitive drawn through a viewport. +fn viewport_points(l: &DisplayList) -> Vec> { + l.prims.iter().filter(|p| p.handle == VIEWPORT_CONTENT).map(|p| l.points(p).to_vec()).collect() +} + +#[test] +fn viewport_contents_can_be_left_out_and_built_for_any_area() { + let d = layout_with_viewport(vp(Vec2::new(5.0, 4.0), 4.0, 4.0, Vec2::new(5.0, 5.0), 8.0, 2)); + let paper = Space::Paper("Layout1".into()); + let h = d.space(&paper).unwrap().iter().find(|e| matches!(e.kind, EntityKind::Viewport(_))).unwrap().handle; + let opts = Options { tolerance: 0.01, ..Default::default() }; + // Left out of the layout: only the border remains. + let skipped = build(&d, &paper, &Options { skip_viewport: Some(h), ..opts.clone() }); + assert!(viewport_points(&skipped).is_empty()); + assert!(skipped.prims.iter().any(|p| p.handle == h)); + // Built for the viewport's own rectangle: what the layout draws there. + let rect = Bounds2::new(Vec2::new(3.0, 2.0), Vec2::new(7.0, 6.0)); + let full = build(&d, &paper, &opts); + assert!(!viewport_points(&full).is_empty()); + assert_eq!(viewport_points(&viewport_content(&d, &paper, h, rect, &opts)), viewport_points(&full)); + // Built for a larger area, clipped to it instead of the viewport: the line y=0 (paper y=1.5, + // below the viewport) is there. + let wide = viewport_content(&d, &paper, h, rect.expand(2.0), &opts); + assert!(viewport_points(&wide).iter().any(|pts| pts.iter().all(|q| (q.y - 1.5).abs() < 1e-9))); + // Not a viewport, or an empty or non-finite area: nothing. + assert!(viewport_content(&d, &paper, Handle(0xDEAD), rect, &opts).prims.is_empty()); + assert!(viewport_content(&d, &paper, h, Bounds2::EMPTY, &opts).prims.is_empty()); + let nan = Bounds2::new(Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, 1.0)); + assert!(viewport_content(&d, &paper, h, nan, &opts).prims.is_empty()); +} + +#[test] +fn viewport_contents_built_on_all_cores_match_one_thread() { + // Enough model objects for the parallel path; plots build on one thread. + let mut d = layout_with_viewport(vp(Vec2::new(5.0, 4.0), 8.0, 6.0, Vec2::new(30.0, 30.0), 60.0, 2)); + for i in 0..1200 { + let (x, y) = (f64::from(i % 40) * 1.5, f64::from(i / 40) * 2.0); + let line = EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(x, y, 0.0), b: Vec3::new(x + 1.0, y + 1.5, 0.0) }); + d.add(&Space::Model, Common::default(), line).unwrap(); + } + let paper = Space::Paper("Layout1".into()); + let screen = build(&d, &paper, &Options::default()); + let plot = build_plot(&d, &paper, &Options::default()); + let pts = |l: &DisplayList| -> Vec> { + let border: Vec = d.space(&paper).unwrap().iter().map(|e| e.handle).collect(); + l.prims.iter().filter(|p| !border.contains(&p.handle)).map(|p| l.points(p).to_vec()).collect() + }; + assert!(pts(&screen).len() > 1000); + assert_eq!(pts(&screen), pts(&plot)); +} + #[test] fn hostile_viewports_do_not_panic() { for v in [ diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0f8fa89..50b2cd8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -38,7 +38,7 @@ impl Xf { #[derive(Default)] pub struct CanvasState { pub list: Option, - key: (u64, u64, i32, i32, bool, usize), + key: (u64, u64, i32, i32, bool, usize, u64), /// The area `list` was built for (hatch patterns exist only inside it); a view leaving it /// rebuilds the list. window: Option, @@ -62,9 +62,11 @@ pub struct CanvasState { hl_origin: Vec2, /// Selected infinite lines (clipped and drawn on the CPU each frame). hl_infinite: Vec, - /// The highlight geometry of the active viewport's model space, with the key it was built for - /// (see [`viewport_highlight_tolerance`]). - vp_hl: Option<(u64, DisplayList)>, + /// The selection and hover highlights inside the active MSPACE viewport (see [`VpHighlight`]). + vp_sel: VpHighlight, + vp_hover: VpHighlight, + /// Model space seen through the active MSPACE viewport (see [`VpContent`]). + vp: VpContent, /// Indices of infinite-line primitives in `list` (drawn on the CPU in GPU mode). infinite: Vec, /// Milliseconds spent converting the display list to vertex data (last rebuild). @@ -107,6 +109,15 @@ pub struct CanvasState { pub snap_cycle: Option, } +impl CanvasState { + /// The active MSPACE viewport's contents when they are drawn apart from the layout's list + /// (see [`VpContent`]): where they are on paper at the viewport's current view, and how many + /// times they have been built. + pub fn viewport_content(&self) -> Option<(Bounds2, u64)> { + self.vp.key.and(self.vp.paper_bounds()).map(|b| (b, self.vp.builds)) + } +} + /// Tab cycling through the candidate snaps under the cursor: where it started and which /// candidate shows. Moving the cursor out of the aperture ends it. #[derive(Clone, Copy, Debug)] @@ -146,7 +157,9 @@ impl PrimIndex { } } -/// Indices of the primitives in `list` drawn for `handles` (a scan; for small lists). +/// Indices of the primitives in `list` drawn for `handles` (a scan: the reference [`PrimIndex`] +/// is tested against). +#[cfg(test)] fn prims_of(list: &DisplayList, handles: &[Handle]) -> Vec { if handles.is_empty() { return Vec::new(); @@ -169,7 +182,23 @@ fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) { let band = |px: f64| px.max(1e-300).log2().floor() as i32; // Hatch level of detail follows the finest view showing the list. let lod_band = views.iter().map(|v| band(v.1)).fold(band(px), i32::min); - let key = (st.uid, st.revision, band(px), lod_band, app.session.settings.lwdisplay, std::sync::Arc::as_ptr(&st.doc) as usize); + // The active MSPACE viewport's contents are drawn apart (see `VpContent`), so panning and + // zooming inside it leave this list alone. Without a stencil buffer a clipped viewport's + // contents stay in the list, which then follows its view: realtime navigation changes it + // without a new revision. + let split = split_viewport(st, stencil_clip(app)).map(|(h, ..)| h); + let vp_view = { + use std::hash::{Hash, Hasher}; + let mut s = std::collections::hash_map::DefaultHasher::new(); + split.hash(&mut s); + if split.is_none() + && let Some((h, vp)) = st.active_viewport() + { + (h, vp.view_center.x.to_bits(), vp.view_center.y.to_bits(), vp.view_height.to_bits()).hash(&mut s); + } + s.finish() + }; + let key = (st.uid, st.revision, band(px), lod_band, app.session.settings.lwdisplay, std::sync::Arc::as_ptr(&st.doc) as usize, vp_view); let visible = views.iter().fold(Bounds2::EMPTY, |acc, v| acc.union(&v.0)); let inside = app.canvas.window.is_none_or(|w| visible.is_empty() || w.contains_box(&visible)); if app.canvas.list.is_some() && app.canvas.key == key && inside { @@ -187,6 +216,7 @@ fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) { view_height: st.paper_view().height, plot_style_table: None, view: build_window(views), + skip_viewport: split, }; let space = st.space.clone(); app.canvas.list = Some(cadcraft_render::build(&st.doc, &space, &opts)); @@ -196,6 +226,248 @@ fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) { app.canvas.build_ms = crate::now_ms() - t0; } +/// The active MSPACE viewport when its contents are drawn apart from the layout's list (see +/// [`VpContent`]), with its clip outline when it is clipped. A clipped viewport is clipped with +/// the GPU's stencil buffer (`stencil`); without one its contents stay in the list. +fn split_viewport( + st: &cadcraft_engine::DocState, + stencil: bool, +) -> Option<(Handle, cadcraft_doc::Viewport, Option)> { + let (h, vp) = st.active_viewport()?; + let clip = cadcraft_render::viewport_clip(&st.doc, h, &vp); + (clip.is_none() || stencil).then_some((h, vp, clip)) +} + +/// Whether the canvas can clip a viewport to any outline (a GPU with a stencil buffer). +fn stencil_clip(app: &CadApp) -> bool { + app.canvas.gpu.is_some_and(|g| g.stencil.is_some()) +} + +/// How long the view inside the active viewport stays still before a zoomed [`VpContent`] is +/// rebuilt at the exact view (dash lengths and curve detail follow the viewport's scale). +const VP_SETTLE_MS: f64 = 150.0; + +/// Model space seen through the active MSPACE viewport, drawn apart from the layout's list. It is +/// built for an area around the viewport at one view and drawn clipped to the viewport, moved +/// and scaled to the current view, so panning and zooming inside the viewport redraw without +/// rebuilding anything. It is rebuilt when the drawing changes, when the zoom has moved 2x from +/// the view it was built for, when the view leaves the area, and once a zoom comes to rest. +#[derive(Default)] +struct VpContent { + list: DisplayList, + /// What the list was built from: drawing, revision, document, viewport, tessellation band + /// and lineweight display. + key: Option<(u64, u64, usize, Handle, i32, bool)>, + /// The viewport's view (centre, height) the list was built for, and the paper area it covers. + view: (Vec2, f64), + area: Bounds2, + /// The view on the previous frame and when it last changed (ms). + last_view: (Vec2, f64), + moved_at: f64, + /// Scale and offset drawing the list at the viewport's current view (see [`vp_motion`]). + motion: Option<(f64, Vec2)>, + /// Bumped on each rebuild. + builds: u64, + mesh_key: Option, + mesh_slot: crate::gpu::MeshSlot, + mesh_origin: Vec2, + /// A clipped viewport's outline on the GPU (see [`draw_vp_mask`]). + mask_key: Option, + mask_slot: crate::gpu::MeshSlot, + mask_origin: Vec2, +} + +impl VpContent { + /// Where the contents are on paper at the viewport's current view. + fn paper_bounds(&self) -> Option { + let (k, b) = self.motion?; + (!self.list.bounds.is_empty()).then(|| Bounds2::new(self.list.bounds.min * k + b, self.list.bounds.max * k + b)) + } +} + +/// How a list built for view `built` (centre, height) of viewport `vp` maps to its current +/// view, as `p = k·p0 + b` on paper. A model point m is at p0 = c + s0 (m − c0) in the list and +/// belongs at p = c + s (m − vc), with c the viewport's centre and s, s0 paper units per model +/// unit, so k = s / s0 and b = c (1 − k) + s (c0 − vc). +fn vp_motion(vp: &cadcraft_doc::Viewport, built: (Vec2, f64)) -> Option<(f64, Vec2)> { + let (s, k) = (vp.height / vp.view_height, built.1 / vp.view_height); + let b = vp.center.xy() * (1.0 - k) + (built.0 - vp.view_center) * s; + (k.is_finite() && k > 0.0 && b.is_finite()).then_some((k, b)) +} + +/// How to draw the active viewport's [`VpContent`] this frame. +struct VpFrame { + /// Draws the list at the viewport's current view. + xf: Xf, + /// The viewport's rectangle (a clipped one's outline bounds) on screen. + on_screen: Rect, + /// A clipped viewport's outline on paper, masked with the stencil buffer. + outline: Option>, +} + +/// Build or reuse the active viewport's [`VpContent`] for this frame. +fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Context) -> Option { + let lwdisplay = app.session.settings.lwdisplay; + let stencil = stencil_clip(app); + let c = &mut app.canvas.vp; + let Some((st, (h, vp, clip))) = app.session.state().ok().and_then(|st| Some((st, split_viewport(st, stencil)?))) else { + // Not working in a viewport drawn apart: let the geometry go. + if c.key.is_some() { + *c = VpContent::default(); + } + return None; + }; + let half = Vec2::new(vp.width / 2.0, vp.height / 2.0); + let rect = match &clip { + Some(p) => p.bounds(), + None => Bounds2::new(vp.center.xy() - half, vp.center.xy() + half), + }; + let px = 1.0 / xf_paper.scale.max(1e-300); + let band = px.max(1e-300).log2().floor() as i32; + let key = (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, h, band, lwdisplay); + let now = crate::now_ms(); + let view = (vp.view_center, vp.view_height); + if view != c.last_view { + c.last_view = view; + c.moved_at = now; + } + let rebuild = match vp_motion(&vp, c.view).filter(|_| c.key == Some(key)) { + Some((k, b)) => { + // The viewport in the list's own coordinates. + let visible = Bounds2::new((rect.min - b) / k, (rect.max - b) / k); + let zoomed = (k - 1.0).abs() > 1e-9; + let still = now - c.moved_at; + if zoomed && still < VP_SETTLE_MS { + ctx.request_repaint_after(std::time::Duration::from_millis((VP_SETTLE_MS - still).max(1.0) as u64)); + } + !(0.5..2.0).contains(&k) || !c.area.contains_box(&visible) || (zoomed && still >= VP_SETTLE_MS) + } + None => true, + }; + if rebuild { + let t0 = crate::now_ms(); + // The viewport with half its larger side of margin all round: panning by up to that much + // only moves the geometry. + let area = rect.expand(rect.width().max(rect.height()) * 0.5); + let opts = cadcraft_render::Options { + tolerance: 2f64.powi(band) * 0.5, + min_dash: 2f64.powi(band) * 2.0, + text: true, + fill: true, + lineweights: lwdisplay, + view_height: st.paper_view().height, + plot_style_table: None, + view: Some(area), + skip_viewport: None, + }; + c.list = cadcraft_render::viewport_content(&st.doc, &st.space, h, area, &opts); + c.key = Some(key); + c.view = view; + c.area = area; + c.builds = c.builds.wrapping_add(1); + app.canvas.build_ms = crate::now_ms() - t0; + } + let c = &mut app.canvas.vp; + let (k, b) = vp_motion(&vp, c.view)?; + c.motion = Some((k, b)); + // Paper p = k·p0 + b, so the list is drawn by the paper transform scaled by k about b. + let xf = Xf { rect: xf_paper.rect, center: (xf_paper.center - b) / k, scale: xf_paper.scale * k }; + let on_screen = Rect::from_two_pos(xf_paper.to_screen(Vec2::new(rect.min.x, rect.max.y)), xf_paper.to_screen(Vec2::new(rect.max.x, rect.min.y))); + Some(VpFrame { xf, on_screen, outline: clip.map(|p| p.polygon().to_vec()) }) +} + +/// Draw the active viewport's contents (see [`VpContent`]) as [`ensure_vp_content`] set out; +/// `p` clips them to the viewport's rectangle, and a clipped viewport's outline is masked with +/// the stencil buffer first. +fn draw_vp_content(c: &mut VpContent, gpu: bool, p: &egui::Painter, f: &VpFrame, xf_paper: &Xf, bg: Rgb, lwdisplay: bool) { + let xf = &f.xf; + if !gpu { + draw_list(p, xf, &c.list, bg, lwdisplay); + return; + } + let mut stencil = crate::gpu::Stencil::Off; + if let Some(outline) = &f.outline { + draw_vp_mask(c, p, outline, xf_paper); + stencil = crate::gpu::Stencil::Inside; + } + let origin = crate::gpu::choose_origin(&c.list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300)); + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (c.builds, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h); + h.finish() + }; + if c.mesh_key != Some(key) { + let mesh = crate::gpu::build_mesh(&c.list, origin, bg, lwdisplay); + if let Ok(mut slot) = c.mesh_slot.lock() { + *slot = Some(mesh); + } + c.mesh_key = Some(key); + c.mesh_origin = origin; + } + let center = xf.rect.center(); + p.add(egui_wgpu::Callback::new_paint_callback( + xf.rect, + crate::gpu::CanvasCallback { + key, + slot: c.mesh_slot.clone(), + offset: [(c.mesh_origin.x - xf.center.x) as f32, (c.mesh_origin.y - xf.center.y) as f32], + scale: xf.scale as f32, + center: [center.x, center.y], + layer: crate::gpu::LAYER_VIEWPORT, + xform: crate::gpu::IDENTITY_XFORM, + dash: 0.0, + stencil, + }, + )); +} + +/// Mask a clipped viewport's `outline` (paper units) in the stencil buffer: a triangle fan from +/// its first point, each triangle flipping the stencil bit, leaves the bit set inside the outline +/// (even-odd, concave or not), with no triangulation. +fn draw_vp_mask(c: &mut VpContent, p: &egui::Painter, outline: &[Vec2], xf_paper: &Xf) { + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + outline.iter().for_each(|q| (q.x.to_bits(), q.y.to_bits()).hash(&mut h)); + h.finish() + }; + if c.mask_key != Some(key) { + let mut fan = DisplayList::default(); + if let Some((&first, rest)) = outline.split_first() { + for w in rest.windows(2) { + if let [a, b] = w { + fan.tris.extend_from_slice(&[first, *a, *b]); + } + } + } + fan.bounds = Bounds2::from_points(outline.iter().copied()); + let len = u32::try_from(fan.tris.len()).unwrap_or(0); + fan.prims.push(cadcraft_render::DPrim { handle: Handle(0), color: Rgb(0, 0, 0), aci7: false, lw: 0.0, kind: Kind::Tris, start: 0, len }); + let origin = if fan.bounds.is_empty() { Vec2::ZERO } else { fan.bounds.center() }; + if let Ok(mut slot) = c.mask_slot.lock() { + *slot = Some(crate::gpu::build_mesh(&fan, origin, Rgb(0, 0, 0), false)); + } + c.mask_key = Some(key); + c.mask_origin = origin; + } + let center = xf_paper.rect.center(); + p.add(egui_wgpu::Callback::new_paint_callback( + xf_paper.rect, + crate::gpu::CanvasCallback { + key, + slot: c.mask_slot.clone(), + offset: [(c.mask_origin.x - xf_paper.center.x) as f32, (c.mask_origin.y - xf_paper.center.y) as f32], + scale: xf_paper.scale as f32, + center: [center.x, center.y], + layer: crate::gpu::LAYER_MASK, + xform: crate::gpu::IDENTITY_XFORM, + dash: 0.0, + stencil: crate::gpu::Stencil::Mask, + }, + )); +} + /// Tessellation tolerance for the highlights inside the active viewport: `scale` (pixels per /// drawing unit) snapped to its 2x zoom band, as in [`ensure_list`], so zooming and panning /// inside the band doesn't rebuild the geometry. @@ -203,6 +475,36 @@ fn viewport_highlight_tolerance(scale: f64) -> f64 { 2f64.powi((1.0 / scale.max(1e-300)).log2().floor() as i32) * 0.5 } +/// The selected or the hovered objects tessellated in model units for the active MSPACE +/// viewport, whose contents can't be looked up by handle in the layout's lists. The selection and +/// the hover are kept apart, so moving the cursor over objects doesn't tessellate the selection +/// again; each is rebuilt only when the drawing, the objects or the zoom band change. +#[derive(Default)] +struct VpHighlight { + key: Option, + list: DisplayList, + /// Bumped on each rebuild (identifies `list` for [`draw_highlight_gpu`]). + list_gen: u64, +} + +impl VpHighlight { + fn update(&mut self, st: &cadcraft_engine::DocState, handles: &[Handle], scale: f64) { + let tolerance = viewport_highlight_tolerance(scale); + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles).hash(&mut h); + h.finish() + }; + if self.key != Some(key) { + let ents = handles.iter().filter_map(|h| st.doc.model.get(*h)).map(|e| e.as_ref()); + self.list = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance, ..Default::default() }); + self.key = Some(key); + self.list_gen = self.list_gen.wrapping_add(1); + } + } +} + /// The area a display list is built for: each view with half its larger side of margin all /// round (panning by up to that much doesn't rebuild), joined. `None` (everything) when a view /// isn't usable. @@ -375,17 +677,20 @@ fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: /// Draw the selection highlight (dashed, `width` points) through the GPU: its vertex data is /// rebuilt only when the display list, the selection or the vertex origin changes. Dashing every /// selected line and tessellating it as egui shapes each frame made large selections sluggish. +/// `prims`: the selection's primitives in `list`, asked for only when the vertex data is rebuilt. +/// `stencil`: [`crate::gpu::Stencil::Inside`] keeps it inside a clipped viewport's outline. #[allow(clippy::too_many_arguments)] fn draw_highlight_gpu( p: &egui::Painter, xf: &Xf, list: &DisplayList, list_gen: u64, - prim_index: &mut PrimIndex, + prims: impl FnOnce() -> Vec, sel: &[Handle], color: Color32, width: f32, hl: (&mut Option, &crate::gpu::MeshSlot, &mut Vec2, &mut Vec), + stencil: crate::gpu::Stencil, ) { let (hl_key, hl_slot, hl_origin, hl_infinite) = hl; let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300)); @@ -396,7 +701,7 @@ fn draw_highlight_gpu( h.finish() }; if *hl_key != Some(key) { - let prims = prim_index.prims(list_gen, list, sel); + let prims = prims(); let mesh = crate::gpu::build_highlight_mesh(list, &prims, origin, color.to_array(), width); *hl_infinite = prims.into_iter().filter(|&i| list.prims.get(i).is_some_and(|p| matches!(p.kind, Kind::Infinite { .. }))).collect(); if let Ok(mut slot) = hl_slot.lock() { @@ -417,6 +722,7 @@ fn draw_highlight_gpu( layer: crate::gpu::LAYER_HIGHLIGHT, xform: crate::gpu::IDENTITY_XFORM, dash: 10.0, + stencil, }, )); } @@ -479,6 +785,7 @@ fn draw_drag_gpu( layer: crate::gpu::LAYER_DRAG, xform: [m.a as f32, m.b as f32, m.c as f32, m.d as f32, t.x as f32, t.y as f32], dash: 0.0, + stencil: crate::gpu::Stencil::Off, }, )); } @@ -517,6 +824,7 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis layer: crate::gpu::LAYER_DRAWING, xform: crate::gpu::IDENTITY_XFORM, dash: 0.0, + stencil: crate::gpu::Stencil::Off, }, )); let mut shapes = Vec::new(); @@ -532,7 +840,7 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis } /// Draw selected/hovered entities as highlight overlays: `prims` are indices into `list` (from -/// [`PrimIndex::prims`] or [`prims_of`]). +/// [`PrimIndex::prims`], or all of a [`VpHighlight`] list). fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize], color: Color32, width: f32, dashed: bool) { let mut shapes = Vec::new(); for prim in prims.iter().filter_map(|&i| list.prims.get(i)) { @@ -1088,6 +1396,15 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } }; let sel = app.session.selection(); + // The active viewport's contents, drawn apart from the layout's list, under its own objects. + // `masked`: its outline is in the stencil buffer this frame. + let mut masked = false; + if let Some(f) = ensure_vp_content(app, &xf_paper, ui.ctx()) { + let gpu = app.canvas.gpu.is_some(); + let clipped = painter.with_clip_rect(f.on_screen.intersect(rect)); + draw_vp_content(&mut app.canvas.vp, gpu, &clipped, &f, &xf_paper, bg, app.session.settings.lwdisplay); + masked = gpu && f.outline.is_some(); + } if app.canvas.gpu.is_some() { draw_list_gpu(&mut app.canvas, &painter, &xf_paper, bg, app.session.settings.lwdisplay); } else if let Some(list) = &app.canvas.list { @@ -1111,29 +1428,34 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } } if let Ok(st) = app.session.state() { - let mut hs = sel.clone(); - hs.extend(app.canvas.hover.filter(|h| !sel.contains(h))); - let ents: Vec<_> = hs.iter().filter_map(|h| st.doc.model.get(*h)).map(|e| e.as_ref()).collect(); - if !ents.is_empty() { - let tolerance = viewport_highlight_tolerance(scale); - let key = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), &hs).hash(&mut h); - h.finish() - }; - if app.canvas.vp_hl.as_ref().is_none_or(|(k, _)| *k != key) { - let built = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance, ..Default::default() }); - app.canvas.vp_hl = Some((key, built)); - } - if let Some((_, list)) = &app.canvas.vp_hl { - let clipped = painter.with_clip_rect(r.intersect(rect)); - if let Some(h) = app.canvas.hover - && !sel.contains(&h) - { - draw_highlight(&clipped, &xf, list, &prims_of(list, &[h]), t.hover, 2.0, false); - } - draw_highlight(&clipped, &xf, list, &prims_of(list, &sel), t.selection, 1.5, true); + // A clipped viewport's outline bounds (its outline is masked when `masked`). + let bounds = active_clip.as_ref().map_or(r, |c| { + let b = c.bounds(); + Rect::from_two_pos(xf_paper.to_screen(Vec2::new(b.min.x, b.max.y)), xf_paper.to_screen(Vec2::new(b.max.x, b.min.y))) + }); + let clipped = painter.with_clip_rect(bounds.intersect(rect)); + let c = &mut app.canvas; + if let Some(h) = c.hover + && !sel.contains(&h) + { + c.vp_hover.update(st, &[h], scale); + let list = &c.vp_hover.list; + draw_highlight(&clipped, &xf, list, &(0..list.prims.len()).collect::>(), t.hover, 2.0, false); + } + if !sel.is_empty() { + c.vp_sel.update(st, &sel, scale); + let list = &c.vp_sel.list; + let all = || (0..list.prims.len()).collect::>(); + if c.gpu.is_some() { + // Shares the GPU highlight layer with the layout's own selection; the high bit + // keeps this list's generations apart from the layout list's. + let list_id = c.vp_sel.list_gen | 1 << 63; + let stencil = if masked { crate::gpu::Stencil::Inside } else { crate::gpu::Stencil::Off }; + let hl = (&mut c.hl_key, &c.hl_slot, &mut c.hl_origin, &mut c.hl_infinite); + draw_highlight_gpu(&clipped, &xf, list, list_id, all, &sel, t.selection, 1.5, hl, stencil); + draw_highlight(&clipped, &xf, list, &c.hl_infinite, t.selection, 1.5, true); + } else { + draw_highlight(&clipped, &xf, list, &all(), t.selection, 1.5, true); } } } @@ -1144,7 +1466,20 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &[h]), t.hover, 2.0, false); } if gpu.is_some() && !sel.is_empty() { - draw_highlight_gpu(&painter, &xf, list, *list_gen, prim_index, &sel, t.selection, 1.5, (hl_key, hl_slot, hl_origin, &mut *hl_infinite)); + let list_id = *list_gen; + let hl = (hl_key, &*hl_slot, hl_origin, &mut *hl_infinite); + draw_highlight_gpu( + &painter, + &xf, + list, + list_id, + || prim_index.prims(list_id, list, &sel), + &sel, + t.selection, + 1.5, + hl, + crate::gpu::Stencil::Off, + ); draw_highlight(&painter, &xf, list, hl_infinite, t.selection, 1.5, true); } else { draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &sel), t.selection, 1.5, true); @@ -1787,6 +2122,36 @@ mod tests { } } + #[test] + fn viewport_highlight_is_tessellated_once_per_selection_and_zoom_band() { + use cadcraft_doc::{Circle, Common, Drawing, EntityKind, Space}; + let mut d = Drawing::new_imperial(); + let circle = |r| EntityKind::Circle(Circle { center: cadcraft_geom::Vec3::new(0.0, 0.0, 0.0), radius: r }); + let a = d.add(&Space::Model, Common::default(), circle(10.0)).unwrap(); + let b = d.add(&Space::Model, Common::default(), circle(5.0)).unwrap(); + let st = cadcraft_engine::DocState::new(d, "highlight", None); + let points = |l: &DisplayList| l.prims.iter().map(|p| l.points(p).len()).sum::(); + let (mut sel, mut hover) = (VpHighlight::default(), VpHighlight::default()); + sel.update(&st, &[a], 10.0); + let (first, segments) = (sel.list_gen, points(&sel.list)); + assert!(!sel.list.prims.is_empty()); + // Every frame of a pan, zooming within the 2x band, and the hover moving: the same list. + sel.update(&st, &[a], 10.0); + sel.update(&st, &[a], 12.0); + hover.update(&st, &[b], 12.0); + hover.update(&st, &[a], 12.0); + assert_eq!(sel.list_gen, first); + // Zooming in past the band tessellates finer. + sel.update(&st, &[a], 40.0); + assert_ne!(sel.list_gen, first); + assert!(points(&sel.list) > segments); + // A different selection is rebuilt. + let zoomed = sel.list_gen; + sel.update(&st, &[a, b], 40.0); + assert_ne!(sel.list_gen, zoomed); + assert_eq!(sel.list.prims.iter().map(|p| p.handle).collect::>().len(), 2); + } + #[test] fn prim_index_matches_a_scan_and_follows_list_rebuilds() { let a = list(&[3, 1, 3, 2, 7, 3]); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 6dcccca..ad92131 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -93,6 +93,9 @@ impl FrameCap { #[derive(Clone, Copy, Debug)] pub struct GpuTarget { pub format: wgpu::TextureFormat, + /// The stencil buffer format of the render pass, when it has one (the native window asks + /// for one): clipped viewports are clipped with it. + pub stencil: Option, } /// CPU-side vertex data for one display list, waiting to be uploaded. @@ -276,13 +279,28 @@ pub struct CanvasCallback { pub scale: f32, /// Canvas rect centre in points. pub center: [f32; 2], - /// Which buffers to draw: [`LAYER_DRAWING`] or [`LAYER_DRAG`]. + /// Which buffers to draw: [`LAYER_DRAWING`], [`LAYER_DRAG`], [`LAYER_HIGHLIGHT`] or + /// [`LAYER_VIEWPORT`]. pub layer: usize, /// Affine transform applied to the origin-relative positions before `offset`: the linear /// part (a, b, c, d as in `Mat3`) and the translation. [`IDENTITY_XFORM`] for the drawing. pub xform: [f32; 6], /// Dash period in points for wide lines (0 = solid): the selection highlight. pub dash: f32, + /// How the draw uses the stencil buffer. + pub stencil: Stencil, +} + +/// How a [`CanvasCallback`] uses the stencil buffer ([`GpuTarget::stencil`]): a clipped +/// viewport's outline is drawn as a triangle fan that flips a stencil bit ([`Stencil::Mask`], +/// even-odd, as the outline clips on the CPU), then its contents draw only where the bit is set +/// ([`Stencil::Inside`]). The stencil is cleared every frame. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Stencil { + #[default] + Off, + Mask, + Inside, } /// The drawing's display list. @@ -291,6 +309,10 @@ pub const LAYER_DRAWING: usize = 0; pub const LAYER_DRAG: usize = 1; /// The selection highlight, rebuilt only when the selection or the drawing changes. pub const LAYER_HIGHLIGHT: usize = 2; +/// Model space seen through the active layout viewport, moved to its current view each frame. +pub const LAYER_VIEWPORT: usize = 3; +/// The outline of the active viewport when it is clipped (stencil only, see [`Stencil`]). +pub const LAYER_MASK: usize = 4; pub const IDENTITY_XFORM: [f32; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; struct Batch { @@ -313,10 +335,20 @@ struct Resources { line_pipeline: wgpu::RenderPipeline, tri_pipeline: wgpu::RenderPipeline, quad_pipeline: wgpu::RenderPipeline, + /// With a stencil buffer: the pipelines of [`Stencil::Mask`] and [`Stencil::Inside`]. + clipped: Option, linear_out: bool, layers: Vec, } +struct ClipPipelines { + /// Flips the stencil bit under each triangle; draws no colour. + mask: wgpu::RenderPipeline, + line: wgpu::RenderPipeline, + tri: wgpu::RenderPipeline, + quad: wgpu::RenderPipeline, +} + const UNIFORM_SIZE: u64 = 80; const SHADER: &str = r#" @@ -446,7 +478,31 @@ impl Resources { }); Layer { uniform, bind_group, key: None, lines: Vec::new(), tris: Vec::new(), quads: Vec::new(), breaks: Vec::new() } }; - let pipeline = |label: &str, vs: &str, topology: wgpu::PrimitiveTopology, buffer: wgpu::VertexBufferLayout<'static>| { + // A render pass with a stencil buffer needs every pipeline to name its format; only the + // clipped-viewport pipelines read or write it. + let face = |compare, pass_op| wgpu::StencilFaceState { + compare, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::Keep, + pass_op, + }; + let stencil_state = |s: Stencil| { + target.stencil.map(|format| { + let stencil = match s { + Stencil::Off => wgpu::StencilState::default(), + Stencil::Mask => { + let f = face(wgpu::CompareFunction::Always, wgpu::StencilOperation::Invert); + wgpu::StencilState { front: f, back: f, read_mask: 1, write_mask: 1 } + } + Stencil::Inside => { + let f = face(wgpu::CompareFunction::Equal, wgpu::StencilOperation::Keep); + wgpu::StencilState { front: f, back: f, read_mask: 1, write_mask: 0 } + } + }; + wgpu::DepthStencilState { format, depth_write_enabled: None, depth_compare: None, stencil, bias: wgpu::DepthBiasState::default() } + }) + }; + let pipeline = |label: &str, vs: &str, topology: wgpu::PrimitiveTopology, buffer: wgpu::VertexBufferLayout<'static>, s: Stencil| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), layout: Some(&layout), @@ -457,7 +513,7 @@ impl Resources { compilation_options: Default::default(), }, primitive: wgpu::PrimitiveState { topology, ..Default::default() }, - depth_stencil: None, + depth_stencil: stencil_state(s), multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false }, fragment: Some(wgpu::FragmentState { module: &module, @@ -465,7 +521,7 @@ impl Resources { targets: &[Some(wgpu::ColorTargetState { format: target.format, blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, + write_mask: if s == Stencil::Mask { wgpu::ColorWrites::empty() } else { wgpu::ColorWrites::ALL }, })], compilation_options: Default::default(), }), @@ -473,12 +529,19 @@ impl Resources { cache: None, }) }; + use wgpu::PrimitiveTopology::{LineList, TriangleList}; Resources { - line_pipeline: pipeline("cad_lines", "vs_line", wgpu::PrimitiveTopology::LineList, vertex_layout()), - tri_pipeline: pipeline("cad_fills", "vs_line", wgpu::PrimitiveTopology::TriangleList, vertex_layout()), - quad_pipeline: pipeline("cad_quads", "vs_quad", wgpu::PrimitiveTopology::TriangleList, instance_layout()), + line_pipeline: pipeline("cad_lines", "vs_line", LineList, vertex_layout(), Stencil::Off), + tri_pipeline: pipeline("cad_fills", "vs_line", TriangleList, vertex_layout(), Stencil::Off), + quad_pipeline: pipeline("cad_quads", "vs_quad", TriangleList, instance_layout(), Stencil::Off), + clipped: target.stencil.map(|_| ClipPipelines { + mask: pipeline("cad_clip_mask", "vs_line", TriangleList, vertex_layout(), Stencil::Mask), + line: pipeline("cad_clip_lines", "vs_line", LineList, vertex_layout(), Stencil::Inside), + tri: pipeline("cad_clip_fills", "vs_line", TriangleList, vertex_layout(), Stencil::Inside), + quad: pipeline("cad_clip_quads", "vs_quad", TriangleList, instance_layout(), Stencil::Inside), + }), linear_out: target.format.is_srgb(), - layers: vec![layer(), layer(), layer()], + layers: (0..=LAYER_MASK).map(|_| layer()).collect(), } } } @@ -561,16 +624,17 @@ impl GpuFaults { /// Create the canvas pipelines in the renderer's callback resources, and report wgpu errors and /// device loss to `faults` instead of panicking. Call once with the app's render state (eframe -/// `CreationContext::wgpu_render_state`). -pub fn install(rs: &egui_wgpu::RenderState, faults: &Arc) -> GpuTarget { +/// `CreationContext::wgpu_render_state`) and the format of its render pass's stencil buffer, +/// if it has one (eframe `NativeOptions::stencil_buffer`). +pub fn install(rs: &egui_wgpu::RenderState, faults: &Arc, stencil: Option) -> GpuTarget { let f = Arc::clone(faults); rs.device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| f.error(e.to_string()))); let f = Arc::clone(faults); rs.device.set_device_lost_callback(move |reason, message| f.device_lost(reason, message)); - let target = GpuTarget { format: rs.target_format }; + let target = GpuTarget { format: rs.target_format, stencil: stencil.filter(|s| s.has_stencil_aspect()) }; let res = Resources::new(&rs.device, target); rs.renderer.write().callback_resources.insert(res); - log::info!("gpu canvas: target {:?}", rs.target_format); + log::info!("gpu canvas: target {:?}, stencil {:?}", rs.target_format, target.stencil); target } @@ -623,14 +687,24 @@ impl egui_wgpu::CallbackTrait for CanvasCallback { // Whole-screen device pixels; egui already set the scissor to the canvas clip rect. pass.set_viewport(0.0, 0.0, sw as f32, sh as f32, 0.0, 1.0); pass.set_bind_group(0, &res.bind_group, &[]); + let (tri, line, quad) = match (self.stencil, &shared.clipped) { + (Stencil::Off, _) => (&shared.tri_pipeline, &shared.line_pipeline, &shared.quad_pipeline), + // The outline is all triangles. + (Stencil::Mask, Some(c)) => (&c.mask, &c.mask, &c.mask), + (Stencil::Inside, Some(c)) => (&c.tri, &c.line, &c.quad), + // No stencil buffer: the canvas never asks for these. + (_, None) => return, + }; + if self.stencil != Stencil::Off { + pass.set_stencil_reference(1); + } + let mask = self.stencil == Stencil::Mask; // Draw-order groups split at each wipeout; without wipeouts there is one group. let total = |bs: &[Batch]| bs.iter().map(|b| b.count as usize).sum::(); - let all = [total(&res.tris), total(&res.lines), total(&res.quads)]; + let all = [total(&res.tris), if mask { 0 } else { total(&res.lines) }, if mask { 0 } else { total(&res.quads) }]; let mut from = [0usize; 3]; for to in res.breaks.iter().copied().chain(std::iter::once(all)) { - for (i, (pipeline, batches)) in - [(&shared.tri_pipeline, &res.tris), (&shared.line_pipeline, &res.lines), (&shared.quad_pipeline, &res.quads)].into_iter().enumerate() - { + for (i, (pipeline, batches)) in [(tri, &res.tris), (line, &res.lines), (quad, &res.quads)].into_iter().enumerate() { let parts = batch_ranges(batches.iter().map(|b| b.count), from[i]..to[i]); if parts.is_empty() { continue; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8993f17..0996a58 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -264,9 +264,11 @@ impl CadApp { } /// Draw the canvas on the GPU with the app's wgpu render state (eframe's - /// `CreationContext::wgpu_render_state`). Without it the canvas draws on the CPU. - pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState) { - self.canvas.gpu = Some(gpu::install(rs, &self.gpu_faults)); + /// `CreationContext::wgpu_render_state`). Without it the canvas draws on the CPU. `stencil`: + /// the format of the render pass's stencil buffer when the app asked for one (eframe + /// `NativeOptions::stencil_buffer`); clipped viewports are clipped with it. + pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, stencil: Option) { + self.canvas.gpu = Some(gpu::install(rs, &self.gpu_faults, stencil)); } pub fn with_control(mut self, rx: Receiver) -> Self { diff --git a/crates/ui-egui/tests/gpu_viewport_stencil.rs b/crates/ui-egui/tests/gpu_viewport_stencil.rs new file mode 100644 index 0000000..e4b8283 --- /dev/null +++ b/crates/ui-egui/tests/gpu_viewport_stencil.rs @@ -0,0 +1,168 @@ +//! A clipped layout viewport's contents and selection highlight are clipped on the GPU (#688): +//! the outline is drawn into the stencil buffer as a triangle fan that flips a bit (even-odd), +//! and they draw only where it is set. Renders through the canvas pipelines into an offscreen +//! colour + stencil target and reads the pixels back. + +use std::sync::Arc; + +use cadcraft_engine::doc::Handle; +use cadcraft_engine::geom::{Bounds2, Vec2}; +use cadcraft_engine::render::{DPrim, DisplayList, Kind}; +use cadcraft_ui_egui::gpu::{self, CanvasCallback, GpuFaults, LAYER_HIGHLIGHT, LAYER_MASK, LAYER_VIEWPORT, MeshSlot, Stencil}; +use egui_wgpu::wgpu; +use egui_wgpu::{CallbackTrait, ScreenDescriptor}; + +const SIZE: u32 = 64; +const STENCIL: wgpu::TextureFormat = wgpu::TextureFormat::Stencil8; + +/// A display list of one black filled polygon given as triangles. +fn tris(pts: Vec) -> DisplayList { + let len = pts.len() as u32; + let bounds = Bounds2::from_points(pts.iter().copied()); + let prim = DPrim { handle: Handle(1), color: cadcraft_color::Rgb(0, 0, 0), aci7: false, lw: 0.0, kind: Kind::Tris, start: 0, len }; + DisplayList { prims: vec![prim], tris: pts, bounds, ..Default::default() } +} + +/// A triangle fan over `outline` (what the canvas masks a clipped viewport with). +fn fan(outline: &[Vec2]) -> DisplayList { + let mut t = Vec::new(); + for w in outline[1..].windows(2) { + t.extend_from_slice(&[outline[0], w[0], w[1]]); + } + tris(t) +} + +/// A callback drawing `list` on `layer`, one world unit per pixel, world (32, 32) at the centre. +fn callback(list: &DisplayList, layer: usize, key: u64, stencil: Stencil) -> CanvasCallback { + let slot: MeshSlot = Arc::default(); + *slot.lock().unwrap() = Some(gpu::build_mesh(list, Vec2::ZERO, cadcraft_color::Rgb(255, 255, 255), false)); + CanvasCallback { key, slot, offset: [-32.0, -32.0], scale: 1.0, center: [32.0, 32.0], layer, xform: gpu::IDENTITY_XFORM, dash: 0.0, stencil } +} + +/// Render the callbacks in order into a white target with a cleared stencil buffer; returns +/// whether each pixel came out dark, row by row from the top. +fn render(rs: &egui_wgpu::RenderState, callbacks: &[CanvasCallback]) -> Vec { + let (device, queue) = (&rs.device, &rs.queue); + let extent = wgpu::Extent3d { width: SIZE, height: SIZE, depth_or_array_layers: 1 }; + let texture = |format, usage| { + device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + }; + let color = texture(rs.target_format, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC); + let stencil = texture(STENCIL, wgpu::TextureUsages::RENDER_ATTACHMENT); + let (color_view, stencil_view) = (color.create_view(&Default::default()), stencil.create_view(&Default::default())); + let screen = ScreenDescriptor { size_in_pixels: [SIZE, SIZE], pixels_per_point: 1.0 }; + let mut encoder = device.create_command_encoder(&Default::default()); + let mut renderer = rs.renderer.write(); + for cb in callbacks { + let _ = cb.prepare(device, queue, &screen, &mut encoder, &mut renderer.callback_resources); + } + { + let mut pass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &color_view, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), store: wgpu::StoreOp::Store }, + depth_slice: None, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &stencil_view, + depth_ops: None, + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Discard }), + }), + ..Default::default() + }) + .forget_lifetime(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(SIZE as f32, SIZE as f32)); + for cb in callbacks { + let info = egui::PaintCallbackInfo { viewport: rect, clip_rect: rect, pixels_per_point: 1.0, screen_size_px: [SIZE, SIZE] }; + cb.paint(info, &mut pass, &renderer.callback_resources); + } + } + let row = SIZE * 4; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: u64::from(row * SIZE), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + color.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(SIZE) }, + }, + extent, + ); + queue.submit([encoder.finish()]); + buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(std::time::Duration::from_secs(10)) }).unwrap(); + let pixels = buffer.slice(..).get_mapped_range().unwrap(); + pixels.chunks(4).map(|p| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]) < 300).collect() +} + +fn dark(px: &[bool], x: u32, y: u32) -> bool { + px[(y * SIZE + x) as usize] +} + +#[test] +fn clipped_viewport_contents_draw_only_inside_their_outline() { + let rs = egui_kittest::wgpu::create_render_state(egui_kittest::wgpu::default_wgpu_setup(), Default::default()); + let faults = Arc::new(GpuFaults::default()); + let target = gpu::install(&rs, &faults, Some(STENCIL)); + assert_eq!(target.stencil, Some(STENCIL)); + // The contents: a black square over the whole target. + let square = |key| { + let (a, b, c, d) = (Vec2::new(0.0, 0.0), Vec2::new(64.0, 0.0), Vec2::new(64.0, 64.0), Vec2::new(0.0, 64.0)); + callback(&tris(vec![a, b, c, a, c, d]), LAYER_VIEWPORT, key, Stencil::Inside) + }; + + // A circle of radius 20 about the centre (screen and world agree at the centre; y is up). + let circle: Vec = + (0..=64).map(|i| f64::from(i) * std::f64::consts::TAU / 64.0).map(|a| Vec2::new(32.0 + 20.0 * a.cos(), 32.0 + 20.0 * a.sin())).collect(); + let px = render(&rs, &[callback(&fan(&circle), LAYER_MASK, 1, Stencil::Mask), square(2)]); + assert!(dark(&px, 32, 32) && dark(&px, 45, 32) && dark(&px, 32, 18)); + assert!(!dark(&px, 4, 4) && !dark(&px, 60, 60) && !dark(&px, 58, 32) && !dark(&px, 32, 58)); + + // A concave L: the fan covers the notch twice, which leaves it unmasked. + let l = [(8.0, 8.0), (56.0, 8.0), (56.0, 24.0), (24.0, 24.0), (24.0, 56.0), (8.0, 56.0), (8.0, 8.0)].map(|(x, y)| Vec2::new(x, y)); + let px = render(&rs, &[callback(&fan(&l), LAYER_MASK, 3, Stencil::Mask), square(4)]); + // World (16, 40) is screen (16, 24); world (40, 16) is screen (40, 48); the notch, world + // (40, 40), is screen (40, 24). + assert!(dark(&px, 16, 24) && dark(&px, 40, 48), "inside both arms of the L"); + assert!(!dark(&px, 40, 24), "the notch is outside"); + assert!(!dark(&px, 2, 2) && !dark(&px, 60, 60)); + + // The selection highlight inside a clipped viewport stays inside its outline too: a wide + // line across the whole target at world y 32, masked by the circle. + let line = DisplayList { + prims: vec![DPrim { handle: Handle(1), color: cadcraft_color::Rgb(0, 0, 0), aci7: false, lw: 0.0, kind: Kind::Polyline, start: 0, len: 2 }], + verts: vec![Vec2::new(0.0, 32.0), Vec2::new(64.0, 32.0)], + bounds: Bounds2::new(Vec2::new(0.0, 32.0), Vec2::new(64.0, 32.0)), + ..Default::default() + }; + let slot: MeshSlot = Arc::default(); + *slot.lock().unwrap() = Some(gpu::build_highlight_mesh(&line, &[0], Vec2::ZERO, [0, 0, 0, 255], 3.0)); + let highlight = CanvasCallback { slot, layer: LAYER_HIGHLIGHT, ..callback(&line, LAYER_HIGHLIGHT, 6, Stencil::Inside) }; + let px = render(&rs, &[callback(&fan(&circle), LAYER_MASK, 1, Stencil::Mask), highlight]); + assert!(dark(&px, 32, 32) && dark(&px, 20, 32) && dark(&px, 44, 32), "inside the circle"); + assert!(!dark(&px, 4, 32) && !dark(&px, 60, 32), "outside it"); + + // Without a mask, Off draws everywhere. + let mut off = square(5); + off.stencil = Stencil::Off; + let px = render(&rs, &[off]); + assert!(px.iter().all(|d| *d)); + // wgpu reported nothing (validation errors land in the faults instead of panicking). + assert_eq!(faults.take(), None); +} diff --git a/crates/ui-egui/tests/mspace_viewport_view.rs b/crates/ui-egui/tests/mspace_viewport_view.rs new file mode 100644 index 0000000..5696650 --- /dev/null +++ b/crates/ui-egui/tests/mspace_viewport_view.rs @@ -0,0 +1,188 @@ +//! Panning and zooming inside the active MSPACE viewport (#688). Its contents are drawn apart +//! from the layout's list, built for an area around the viewport and moved to the current view, +//! so realtime navigation redraws them without rebuilding anything. They used to stay frozen: +//! realtime navigation changes the viewport's view without a new revision, and the layout's list +//! kept showing the old view until some command rebuilt it. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Circle, Common, Drawing, EntityKind, Line, Space, Viewport}; +use cadcraft_engine::geom::{Bounds2, Vec2, Vec3}; +use cadcraft_engine::render::VIEWPORT_CONTENT; +use cadcraft_ui_egui::{CadApp, Services}; +use egui::vec2; +use egui_kittest::Harness; +use serde_json::json; + +/// Model space: a line from (0,0) to (20,0). Layout1: an 8×6 viewport centred at (5,4) on paper, +/// looking at model (10,0) 40 units high (0.15 paper units per model unit), so the line spans +/// paper x 3.5..6.5 at y 4. `clipped`: the viewport is clipped by a circle of radius 2.9. +fn harness(clipped: bool) -> Harness<'static, CadApp> { + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(20.0, 0.0, 0.0) })).unwrap(); + let paper = Space::Paper("Layout1".into()); + let clip = + clipped.then(|| d.add(&paper, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(5.0, 4.0, 0.0), radius: 2.9 })).unwrap()); + d.add( + &paper, + Common::default(), + EntityKind::Viewport(Viewport { + center: Vec3::new(5.0, 4.0, 0.0), + width: 8.0, + height: 6.0, + view_center: Vec2::new(10.0, 0.0), + view_height: 40.0, + id: 2, + locked: false, + frozen_layers: Vec::new(), + layer_colors: Vec::new(), + clip, + on: true, + }), + ) + .unwrap(); + let mut session = Session::empty(); + session.open_drawing(d, "MSPACE view regression", None); + session.execute("layout.set", &json!({"name":"Layout1"})).unwrap(); + session.execute("view.set", &json!({"center":[5,4],"height":10})).unwrap(); + session.execute("mspace", &json!({})).unwrap(); + let mut app = CadApp::new(session, Services::default()); + app.canvas.zoom_pending = false; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + // The first view change after opening copies the drawing, still shared with its saved + // state; the copy is a new document to the canvas and builds once. Get it out of the way. + { + let st = h.state_mut().session.state_mut().unwrap(); + let v = st.view(); + st.set_view_quiet(v); + } + h.run_steps(2); + h +} + +/// Where the viewport's contents are on paper, and how many times they were built. +fn content(h: &Harness<'_, CadApp>) -> (Bounds2, u64) { + h.state().canvas.viewport_content().expect("the active viewport's contents") +} + +/// The model geometry the layout's own list draws through viewports, on paper. +fn in_layout_list(h: &Harness<'_, CadApp>) -> Bounds2 { + let list = h.state().canvas.list.as_ref().expect("a display list"); + list.prims + .iter() + .filter(|p| p.handle == VIEWPORT_CONTENT) + .fold(Bounds2::EMPTY, |b, p| b.union(&Bounds2::from_points(list.points(p).iter().copied()))) +} + +fn revision(h: &Harness<'_, CadApp>) -> u64 { + h.state().session.state().unwrap().revision +} + +/// A middle-drag or trackpad pan step: the quiet view change of realtime navigation. +fn pan(h: &mut Harness<'_, CadApp>, dx: f64) { + let st = h.state_mut().session.state_mut().unwrap(); + let v = st.view(); + st.set_view_quiet(cadcraft_engine::View { center: v.center - Vec2::new(dx, 0.0), height: v.height }); +} + +/// A wheel step zooming in by `f` about the viewport's view centre. +fn zoom(h: &mut Harness<'_, CadApp>, f: f64) { + let about = h.state().session.state().unwrap().active_viewport().unwrap().1.view_center; + h.state_mut().session.zoom_about(f, about).unwrap(); +} + +/// The line, on paper at y 4, spans x `min_x..max_x`. +fn spans(b: Bounds2, min_x: f64, max_x: f64) -> bool { + (b.min.x - min_x).abs() < 1e-9 && (b.max.x - max_x).abs() < 1e-9 && (b.min.y - 4.0).abs() < 1e-9 +} + +#[test] +fn panning_inside_the_active_viewport_moves_its_contents_without_rebuilding() { + let mut h = harness(false); + let (before, builds) = content(&h); + assert!(spans(before, 3.5, 6.5), "{before:?}"); + // The layout's own list leaves the active viewport's contents out. + assert!(in_layout_list(&h).is_empty()); + let rev = revision(&h); + // Panning the model view 4 units left moves the contents 4 × 0.15 = 0.6 paper units right. + for _ in 0..2 { + pan(&mut h, 2.0); + h.run_steps(2); + } + assert_eq!(revision(&h), rev, "a realtime pan makes no new revision"); + let (after, after_builds) = content(&h); + assert!(spans(after, 4.1, 7.1), "{after:?}"); + assert_eq!(after_builds, builds, "panning within the built area only moves the geometry"); + // Panning past the area built around the viewport (half its larger side, 4 paper units = + // 26.7 model units) builds it again around the new view. + pan(&mut h, 30.0); + h.run_steps(2); + let (far, far_builds) = content(&h); + assert!(far_builds > builds); + assert!(spans(far, 8.6, 11.6), "{far:?}"); +} + +#[test] +fn zooming_inside_the_active_viewport_rebuilds_per_2x_and_once_it_settles() { + let mut h = harness(false); + let (_, builds) = content(&h); + let rev = revision(&h); + // One wheel step of √2 scales the built geometry: the line spans 5 ± 1.5√2 on paper. + zoom(&mut h, std::f64::consts::SQRT_2); + h.run_steps(2); + let half = 1.5 * std::f64::consts::SQRT_2; + let (b, n) = content(&h); + assert!(spans(b, 5.0 - half, 5.0 + half), "{b:?}"); + assert_eq!(n, builds, "zooming within 2x of the built view only scales it"); + // At rest it is built again at the exact view (dash lengths and curve detail follow the + // viewport's scale); the geometry stays where it is. + std::thread::sleep(std::time::Duration::from_millis(200)); + h.run_steps(2); + let (b, n) = content(&h); + assert_eq!(n, builds + 1); + assert!(spans(b, 5.0 - half, 5.0 + half), "{b:?}"); + // Two quick steps of √2 (2x from the built view) build again straight away: the line + // doubles to 5 ± 3√2 (the viewport spans 1..9). + zoom(&mut h, std::f64::consts::SQRT_2); + h.run_steps(1); + zoom(&mut h, std::f64::consts::SQRT_2); + h.run_steps(1); + let (b, n2) = content(&h); + assert_eq!(n2, n + 1); + assert!(spans(b, 5.0 - 2.0 * half, 5.0 + 2.0 * half), "{b:?}"); + assert_eq!(revision(&h), rev); +} + +#[test] +fn leaving_the_viewport_puts_its_contents_back_in_the_layout() { + let mut h = harness(false); + pan(&mut h, 4.0); + h.run_steps(2); + h.state_mut().session.execute("pspace", &json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().canvas.viewport_content().is_none()); + let b = in_layout_list(&h); + assert!(spans(b, 4.1, 7.1), "{b:?}"); +} + +#[test] +fn without_a_stencil_buffer_a_clipped_viewport_follows_realtime_navigation_in_the_layout_list() { + let mut h = harness(true); + // No stencil buffer to clip its outline with (the CPU canvas, the web): the layout's list + // keeps drawing its contents. On the GPU see `gpu_viewport_stencil.rs`. + assert!(h.state().canvas.viewport_content().is_none()); + assert!(spans(in_layout_list(&h), 3.5, 6.5)); + // Realtime navigation makes no new revision; the list follows the viewport's view anyway. + for _ in 0..2 { + pan(&mut h, 2.0); + h.run_steps(2); + } + let b = in_layout_list(&h); + assert!(spans(b, 4.1, 7.1), "{b:?}"); +}