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.
This commit is contained in:
parent
b19188919d
commit
7fcdc6e3bc
|
|
@ -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!({}));
|
||||
|
|
|
|||
|
|
@ -175,6 +175,9 @@ fn config_dir_from(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<s
|
|||
|
||||
const APP_ID: &str = "ai.storyteller.cadcraft";
|
||||
|
||||
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/cadcraft-macos-512.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
|
@ -248,6 +251,8 @@ fn main() -> 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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<Bounds2>,
|
||||
/// 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<Handle>,
|
||||
}
|
||||
|
||||
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<std::sync::Arc<cadcraft_doc::PlotStyleTable>> {
|
||||
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<Bounds2> {
|
||||
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<Entity>> = d.model.iter().collect();
|
||||
let budget = std::sync::atomic::AtomicUsize::new(0);
|
||||
let opts = b.opts;
|
||||
let parts: Vec<DisplayList> = 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
|
||||
|
|
|
|||
|
|
@ -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<Vec<Vec2>> {
|
||||
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<Vec<Vec2>> {
|
||||
let border: Vec<Handle> = 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 [
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ impl Xf {
|
|||
#[derive(Default)]
|
||||
pub struct CanvasState {
|
||||
pub list: Option<DisplayList>,
|
||||
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<Bounds2>,
|
||||
|
|
@ -62,9 +62,11 @@ pub struct CanvasState {
|
|||
hl_origin: Vec2,
|
||||
/// Selected infinite lines (clipped and drawn on the CPU each frame).
|
||||
hl_infinite: Vec<usize>,
|
||||
/// 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<usize>,
|
||||
/// Milliseconds spent converting the display list to vertex data (last rebuild).
|
||||
|
|
@ -107,6 +109,15 @@ pub struct CanvasState {
|
|||
pub snap_cycle: Option<SnapCycle>,
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
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<cadcraft_render::clip::PolyClip>)> {
|
||||
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<u64>,
|
||||
mesh_slot: crate::gpu::MeshSlot,
|
||||
mesh_origin: Vec2,
|
||||
/// A clipped viewport's outline on the GPU (see [`draw_vp_mask`]).
|
||||
mask_key: Option<u64>,
|
||||
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<Bounds2> {
|
||||
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<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
/// 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<VpFrame> {
|
||||
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<u64>,
|
||||
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<usize>,
|
||||
sel: &[Handle],
|
||||
color: Color32,
|
||||
width: f32,
|
||||
hl: (&mut Option<u64>, &crate::gpu::MeshSlot, &mut Vec2, &mut Vec<usize>),
|
||||
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::<Vec<_>>(), 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::<Vec<_>>();
|
||||
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::<usize>();
|
||||
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::<std::collections::BTreeSet<_>>().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prim_index_matches_a_scan_and_follows_list_rebuilds() {
|
||||
let a = list(&[3, 1, 3, 2, 7, 3]);
|
||||
|
|
|
|||
|
|
@ -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<wgpu::TextureFormat>,
|
||||
}
|
||||
|
||||
/// 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<ClipPipelines>,
|
||||
linear_out: bool,
|
||||
layers: Vec<Layer>,
|
||||
}
|
||||
|
||||
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<GpuFaults>) -> 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<GpuFaults>, stencil: Option<wgpu::TextureFormat>) -> 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::<usize>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<egui_wgpu::wgpu::TextureFormat>) {
|
||||
self.canvas.gpu = Some(gpu::install(rs, &self.gpu_faults, stencil));
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
|
|
|
|||
|
|
@ -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<Vec2>) -> 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<bool> {
|
||||
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<Vec2> =
|
||||
(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);
|
||||
}
|
||||
|
|
@ -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:?}");
|
||||
}
|
||||
Loading…
Reference in New Issue