Files
cadcraft/crates/ui-egui/tests/mspace_viewport_view.rs
Books 7fcdc6e3bc Active viewport highlight: selection on the GPU, hover cached apart, kept inside a clipped outline (#770)
* Active viewport: redraw while panning and zooming inside it, without rebuilding the layout (#688)

Inside an active MSPACE viewport, middle-drag, trackpad pan and wheel or
pinch zoom change the viewport's view through set_view_quiet, which makes
no new revision. The layout's display list, which draws model space
through each viewport, was cached on (drawing, revision, zoom band), so it
kept showing the old view until a command such as ZOOM rebuilt it: the
viewport froze, or appeared to ignore the zoom.

The active viewport's contents are now drawn apart from the layout's list,
the way model space itself is drawn. They are built once for an area
around the viewport (half its larger side of margin all round), then moved
and scaled to the current view on the GPU and drawn under the layout's own
objects. They are rebuilt when the drawing changes, when the zoom has
moved 2x from the view they were built for, when the view leaves the built
area, and once a zoom comes to rest, so dash lengths and curve detail
follow the viewport's scale again. A pan step takes about 1 ms with 70,000
polylines, where rebuilding the layout takes about 140 ms per frame.

Rectangular viewports are clipped with the scissor rectangle. A clipped
viewport's outline is drawn into a stencil buffer as a triangle fan that
flips a bit (even-odd, as the outline clips on the CPU), and its contents
draw only where the bit is set; the native window now asks eframe for an
8-bit stencil buffer, and every canvas pipeline names its format. Where
there is no stencil buffer (the CPU canvas, the web runner), a clipped
viewport's contents stay in the layout's list, whose cache now follows the
viewport's view.

Render: Options::skip_viewport leaves a viewport's contents out of a
layout, and viewport_content builds them for any area. Viewport contents
are built on all cores, like model space (130 ms to 37 ms for 70,000
polylines).

* Active viewport highlight: selection on the GPU, hover cached apart, kept inside a clipped outline

Inside an active MSPACE viewport the selection highlight was drawn as
dashed egui shapes on the CPU every frame, about 8-13 ms per frame with
7,000 polylines selected, and its geometry was cached together with the
hover, so moving the cursor over objects tessellated the whole selection
again. The selection and the hover are now cached apart, and the
selection goes through the GPU highlight path model space already uses:
about 0.1 ms per frame. In a clipped viewport it is kept inside the
outline with the stencil mask its contents use; before, it was only cut
to the outline's bounding rectangle.
2026-10-13 04:34:40 -04:00

189 lines
7.8 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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:?}");
}