mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Render: cropped shadow and blur rasters are kept across pans within a margin
A Drop Shadow, Gaussian Blur, Feather or Photoshop-style filter raster too large to keep whole is cropped to the view. It was drawn again only while a later frame's rect, panned back, lay inside the frame it was made in, so every pan that showed new pixels made it again. For a Drop Shadow on a 6000 x 4000 pt image in a 2560 x 1440 view at 100% to 400%, a pan took 60 to 84 ms; with Gaussian Blur or Feather, a pan took 158 to 217 ms. When such a raster is made again at the zoom and rotation of the one kept for the object, it now also covers a margin around the view: up to half the view's longer side, while the raster stays within the 8.4 million pixel budget at full size (441 pixels on each side of a 2560 x 1440 view for a 20 pt blur at 100%). Later pans that keep the view inside the margin draw that raster. The first frame at a zoom, a zoom step and the frames of a drag make the raster without the margin, as before. A shadow made over an earlier cropped shadow covers only the rect the earlier one was made for. A kept raster is drawn again only after a pan by whole device pixels, but the wheel, a trackpad and the scroll bars scrolled the view by amounts that are not whole pixels. The canvas now moves the view by whole device pixels when it scrolls or is dragged. A scroll by the wheel, a trackpad, a touch screen or a scroll bar is rounded, and the part of a pixel left over is added to the next scroll of the same view; it is dropped after a zoom, a turn, Fit or a document switch. A Hand tool, Space or middle-button drag and a scroll bar thumb drag round the distance from where the drag began. The silhouette of a shadow or glow raster is now drawn with art culled against the raster's own area instead of the view, as filtered content already is. Before, a group member outside the view cast no shadow into a raster kept whole, so a pan that showed the member drew it without its shadow. A cropped raster kept across the margin would have done the same. The moved silhouette that a drag compares is drawn the same way. Docs: docs/target-app-parity.md.
This commit is contained in:
+97
-29
@@ -687,7 +687,8 @@ fn linear_key(view: Affine) -> [u64; 4] {
|
||||
}
|
||||
|
||||
/// The pan of the view a raster was cropped for, or made at a lower resolution than the view's, and
|
||||
/// the device-pixel rect of the frame it was made in ([`PixelEntry::view`], [`ShadowEntry::view`]).
|
||||
/// the device-pixel rect it was made for in that view: the frame's, or around it the margin of
|
||||
/// [`pan_margin`] ([`PixelEntry::view`], [`ShadowEntry::view`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ViewCrop {
|
||||
pan: Vec2,
|
||||
@@ -701,7 +702,7 @@ impl ViewCrop {
|
||||
|
||||
/// Does the raster draw in a frame through `view` (of the same zoom and rotation) onto
|
||||
/// `screen`? When the view is panned by whole pixels and the frame's rect, panned back, lies
|
||||
/// within the one the raster was made in: the same pixels in the same places, as when a frame
|
||||
/// within the one the raster was made for: the same pixels in the same places, as when a frame
|
||||
/// is drawn again only in a part of it.
|
||||
fn serves(&self, view: Affine, screen: Rect) -> bool {
|
||||
let d = view.translation() - self.pan;
|
||||
@@ -709,6 +710,30 @@ impl ViewCrop {
|
||||
let s = screen - whole;
|
||||
(d - whole).hypot() < 1e-6 && s.x0 >= self.screen.x0 && s.y0 >= self.screen.y0 && s.x1 <= self.screen.x1 && s.y1 <= self.screen.y1
|
||||
}
|
||||
|
||||
/// The rect the raster was made for, in the device pixels of a frame through `view` that it
|
||||
/// serves ([`Self::serves`]).
|
||||
fn served(&self, view: Affine) -> Rect {
|
||||
let d = view.translation() - self.pan;
|
||||
self.screen + Vec2::new(d.x.round(), d.y.round())
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (device pixels, on each side) past a `w` × `h` view a raster cropped to it, and `m`
|
||||
/// pixels around that, is made when it is made again at the same zoom and rotation, so that later
|
||||
/// pans of up to that many pixels draw it: the most that keeps the raster within `budget` pixels and
|
||||
/// `side` pixels a side, with two pixels to spare for rounding, and at most half the view's longer
|
||||
/// side. 0 when the view and the `m` pixels around it alone leave no room.
|
||||
fn pan_margin((w, h): (f64, f64), m: f64, budget: f64, side: f64) -> f64 {
|
||||
let (a, b) = (w + 2.0 * m, h + 2.0 * m);
|
||||
// The largest `e` with (a + 2e) × (b + 2e) within the budget, less two pixels for the rounding
|
||||
// of the raster's edges to whole pixels.
|
||||
let e = (((a - b).powi(2) + 4.0 * budget).sqrt() - (a + b)) / 4.0 - 2.0;
|
||||
// (Also for a view or spread that isn't a number.)
|
||||
if !e.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
e.min((side - a.max(b)) / 2.0 - 2.0).min(w.max(h) / 2.0).floor().max(0.0)
|
||||
}
|
||||
|
||||
/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while
|
||||
@@ -816,8 +841,9 @@ struct Shadow {
|
||||
mode: vectorcraft_doc::color::BlendMode,
|
||||
/// Made before its object moved ([`ShadowEntry::moved`]).
|
||||
moved: bool,
|
||||
/// Cropped to the view, or made over a raster that is ([`ShadowEntry::view`]).
|
||||
cropped: bool,
|
||||
/// For a raster cropped to the view, or made over a raster that is ([`ShadowEntry::view`]), the
|
||||
/// rect it was made for, in this frame's device pixels ([`ViewCrop::served`]).
|
||||
cropped: Option<Rect>,
|
||||
}
|
||||
|
||||
impl ShadowEntry {
|
||||
@@ -1099,9 +1125,12 @@ impl Renderer {
|
||||
/// what can show in the `screen` (device pixels): the view, and around it the blur's spread and
|
||||
/// `around` (document units: how far the shadows and glows stacked over this one look), made
|
||||
/// coarser to fit the budget where the blur hides the coarser grid. Such a raster, and one
|
||||
/// made over it (`over_cropped`), is reused only in the same view. `None` when the effect isn't
|
||||
/// a shadow/glow or the raster would still be too large (caller falls back to
|
||||
/// [`Self::draw_shadow_filter`]).
|
||||
/// made over it, is reused while a pan keeps the view inside the rect it was made for: the
|
||||
/// view, and when the raster kept for the object was made at the same zoom and rotation and no
|
||||
/// drag is on, the view with [`pan_margin`] around it, within `over` (the rect, in device
|
||||
/// pixels, that the cropped rasters of the shadows and glows it is made over were made for;
|
||||
/// `None` when it is made over none). `None` when the effect isn't a shadow/glow or the raster
|
||||
/// would still be too large (caller falls back to [`Self::draw_shadow_filter`]).
|
||||
/// While dragging on a multithreaded context, a raster that has to be made again counts as too
|
||||
/// large when it would hold more than [`DRAG_PIXEL_BUDGET`] pixels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1113,7 +1142,7 @@ impl Renderer {
|
||||
fx: &RasterFx,
|
||||
reach: Rect,
|
||||
owner: Option<&Arc<Node>>,
|
||||
(screen, around, over_cropped): (Rect, f64, bool),
|
||||
(screen, around, over): (Rect, f64, Option<Rect>),
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) -> Option<Shadow> {
|
||||
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
|
||||
@@ -1128,7 +1157,6 @@ impl Renderer {
|
||||
// What the silhouette reads: as the raster kept for `n` found it, else found now.
|
||||
let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads);
|
||||
let inputs = self.raster_inputs(f, n, reads);
|
||||
let in_view = ViewCrop::of(f.view, screen);
|
||||
let exact = |e: &ShadowEntry| {
|
||||
e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v.serves(f.view, screen))
|
||||
};
|
||||
@@ -1141,7 +1169,7 @@ impl Renderer {
|
||||
e.stamp = stamp;
|
||||
self.stats.moved += usize::from(e.moved);
|
||||
let p = f.view * e.anchor;
|
||||
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.is_some())
|
||||
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.map(|v| v.served(f.view)))
|
||||
}
|
||||
None => {
|
||||
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
|
||||
@@ -1162,18 +1190,23 @@ impl Renderer {
|
||||
};
|
||||
// A glow too large for the budget is made coarser, where its blur hides the coarser grid: the filter
|
||||
// layer the caller falls back to can't make its silhouette solid.
|
||||
let full = region(whole, matches!(fx, RasterFx::OuterGlow { .. }), 4.0e6).filter(|_| !over_cropped);
|
||||
let full = region(whole, matches!(fx, RasterFx::OuterGlow { .. }), 4.0e6).filter(|_| over.is_none());
|
||||
// Else it is cropped to what can show in the view, within the budget of the filter
|
||||
// layer it would otherwise be drawn through, and made coarser in the same way.
|
||||
// layer it would otherwise be drawn through, and made coarser in the same way. Made
|
||||
// again at this zoom and rotation (after a pan), it covers a margin around the view
|
||||
// too, so that later pans within the margin draw it.
|
||||
let ((x0, y0, w, h, scale), cropped) = match full {
|
||||
Some(r) => (r, false),
|
||||
Some(r) => (r, None),
|
||||
None => {
|
||||
let m = spread + around / f.px;
|
||||
let r = whole.intersect(screen.inflate(m, m));
|
||||
let again = self.shadows.get(&key).is_some_and(|e| e.linear == linear) && !f.opts.dragging;
|
||||
let e = if again { pan_margin((screen.width(), screen.height()), m, PIXEL_BUDGET, 8192.0) } else { 0.0 };
|
||||
let room = over.map_or(screen.inflate(e, e), |o| screen.inflate(e, e).intersect(o));
|
||||
let r = whole.intersect(room.inflate(m, m));
|
||||
if r.width() <= 0.0 || r.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
(region(r, true, PIXEL_BUDGET)?, true)
|
||||
(region(r, true, PIXEL_BUDGET)?, Some(room))
|
||||
}
|
||||
};
|
||||
// During a drag, a larger shadow on a multithreaded context is filtered within the
|
||||
@@ -1185,7 +1218,10 @@ impl Renderer {
|
||||
// Silhouette (the object's painted alpha, and its colors under Darkness), offset by
|
||||
// the shadow distance.
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
let crop = Frame { mt: false, view: Affine::scale(1.0 / scale) * Affine::translate((-x0, -y0)) * f.view, ..*f };
|
||||
let view = Affine::scale(1.0 / scale) * Affine::translate((-x0, -y0)) * f.view;
|
||||
// Art is culled against the raster's own area: members of a group outside the view cast their shadows in it.
|
||||
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
|
||||
let crop = Frame { mt: false, view, visible, ..*f };
|
||||
let before = self.stats.moved;
|
||||
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
|
||||
// A silhouette drawn over moved rasters makes a moved shadow.
|
||||
@@ -1234,7 +1270,7 @@ impl Renderer {
|
||||
let p = f.view * anchor;
|
||||
let node = Drawn::of(n, owner);
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
let view = cropped.then_some(in_view);
|
||||
let view = cropped.map(|room| ViewCrop::of(f.view, room));
|
||||
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, stamp });
|
||||
self.trim_fx_rasters(FX_RASTER_BUDGET);
|
||||
(image, x0, y0, scale, moved, cropped)
|
||||
@@ -1345,7 +1381,10 @@ impl Renderer {
|
||||
let anchor = reach.origin();
|
||||
let p = f.view * anchor;
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
let crop = Frame { mt: false, view: Affine::scale(1.0 / scale) * Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view, ..*f };
|
||||
let view = Affine::scale(1.0 / scale) * Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view;
|
||||
// Culled against the raster's area, as `shadow_raster` draws the silhouette.
|
||||
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
|
||||
let crop = Frame { mt: false, view, visible, ..*f };
|
||||
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
@@ -1684,7 +1723,8 @@ impl Renderer {
|
||||
for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) {
|
||||
// A raster cropped to the view covers what the shadows and glows stacked over it read.
|
||||
let around: f64 = rfx.iter().skip(i + 1).filter(|x| x.is_below()).map(looks).sum();
|
||||
let over_cropped = made.iter().any(|(_, s)| s.as_ref().is_some_and(|s| s.cropped));
|
||||
// The rect that the cropped rasters of the earlier ones (drawn into this one's silhouette) were all made for.
|
||||
let over = made.iter().filter_map(|(_, s)| s.as_ref()?.cropped).reduce(|a, b| a.intersect(b));
|
||||
let shadow = {
|
||||
let earlier = &made;
|
||||
let view = f.view;
|
||||
@@ -1701,7 +1741,7 @@ impl Renderer {
|
||||
}
|
||||
paint(r, c, fr);
|
||||
};
|
||||
let view = (screen, around, over_cropped);
|
||||
let view = (screen, around, over);
|
||||
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), view, &mut art))
|
||||
};
|
||||
stacked = match fx {
|
||||
@@ -1773,9 +1813,11 @@ impl Renderer {
|
||||
/// resolution. A whole raster at full resolution is cached while the object, the
|
||||
/// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and
|
||||
/// while interacting after the content only moved ([`Self::move_pixel_content`]). A cropped or
|
||||
/// lower-resolution raster is cached while the object, the view (its pan included) and what the
|
||||
/// content reads stay the same ([`PixelEntry::view`]); a lower-resolution one is kept resampled to
|
||||
/// the view's pixels ([`Self::resampled`]).
|
||||
/// lower-resolution raster is cached while the object, the zoom and what the content reads stay
|
||||
/// the same and a pan keeps the view inside the rect it was made for ([`PixelEntry::view`]): the
|
||||
/// view, and for a raster cropped to it that is made again at the same zoom and rotation, outside
|
||||
/// a drag and for content without a [`Kept`] image, the view with [`pan_margin`] around it. A
|
||||
/// lower-resolution one is kept resampled to the view's pixels ([`Self::resampled`]).
|
||||
/// While dragging, content without such a raster to draw renders within [`DRAG_PIXEL_BUDGET`]
|
||||
/// pixels and is not cached.
|
||||
fn pixel_content(
|
||||
@@ -1815,8 +1857,20 @@ impl Renderer {
|
||||
let fits = full.area() <= PIXEL_BUDGET;
|
||||
// Device pixels from a pixel to the content deciding it (`None`: anywhere).
|
||||
let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px));
|
||||
let linear = linear_key(f.view);
|
||||
let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink));
|
||||
// Made again at this zoom and rotation (after a pan), a raster cropped to the view covers a
|
||||
// margin around the view too, so that later pans within the margin draw it.
|
||||
let again = key.and_then(|key| self.pixel_fx.get(&key)).is_some_and(|e| e.linear == linear) && content.kept.is_none() && !f.opts.dragging;
|
||||
let room = match margin {
|
||||
Some(m) if !fits && again => {
|
||||
let e = pan_margin((screen.width(), screen.height()), m, PIXEL_BUDGET, f64::from(crate::MAX_RASTER_SIDE));
|
||||
screen.inflate(e, e)
|
||||
}
|
||||
_ => screen,
|
||||
};
|
||||
let region = match margin {
|
||||
Some(m) if !fits => full.intersect(screen.inflate(m, m)),
|
||||
Some(m) if !fits => full.intersect(room.inflate(m, m)),
|
||||
_ => full,
|
||||
};
|
||||
let (x0, y0) = (region.x0.floor(), region.y0.floor());
|
||||
@@ -1825,11 +1879,10 @@ impl Renderer {
|
||||
let side = f64::from(crate::MAX_RASTER_SIDE);
|
||||
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
|
||||
let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side));
|
||||
// The cached raster: of the whole content at full size at this zoom, else of this view.
|
||||
let linear = linear_key(f.view);
|
||||
// The cached raster: of the whole content at full size at this zoom, else of this view (and
|
||||
// the margin around it).
|
||||
let whole = fits && k >= 1.0;
|
||||
let in_view = (!whole).then(|| ViewCrop::of(f.view, screen));
|
||||
let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink));
|
||||
let in_view = (!whole).then(|| ViewCrop::of(f.view, room));
|
||||
let stamp = self.stamp;
|
||||
let channels = match f.ink {
|
||||
Ink::Cmy => effects::pixel::Channels::CmyPlane,
|
||||
@@ -2414,7 +2467,7 @@ mod tests {
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, Node};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::{DRAG_PIXEL_BUDGET, Drawn, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within};
|
||||
use super::{DRAG_PIXEL_BUDGET, Drawn, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin};
|
||||
use crate::tests_fontchange::fonts_steady;
|
||||
use crate::{RenderOptions, Rendered, Renderer};
|
||||
|
||||
@@ -2459,6 +2512,21 @@ mod tests {
|
||||
assert_eq!(filter_region_within(small, (1600, 1000), 4.0, DRAG_PIXEL_BUDGET), filter_region(small, (1600, 1000), 4.0));
|
||||
}
|
||||
|
||||
/// The margin a raster cropped to the view is made again with keeps it within the pixel budget,
|
||||
/// a pixel of rounding on each edge included, and within the side limit; it is at most half the
|
||||
/// view's longer side, and 0 when the view and its spread alone fill the budget.
|
||||
#[test]
|
||||
fn the_margin_for_pans_stays_within_the_pixel_budget() {
|
||||
for ((w, h), m, want) in [((2560.0, 1440.0), 32.0, 441.0), ((640.0, 360.0), 32.0, 320.0), ((3840.0, 2160.0), 32.0, 0.0)] {
|
||||
let e = pan_margin((w, h), m, PIXEL_BUDGET, 8192.0);
|
||||
assert_eq!(e, want, "{w} × {h}");
|
||||
let (rw, rh) = (w + 2.0 * (m + e) + 2.0, h + 2.0 * (m + e) + 2.0);
|
||||
assert!(e == 0.0 || rw * rh <= PIXEL_BUDGET, "{w} × {h}: {rw} × {rh}");
|
||||
}
|
||||
assert_eq!(pan_margin((2000.0, 10.0), 0.0, 1e9, 3000.0), 498.0, "within the side limit");
|
||||
assert_eq!(pan_margin((2000.0, 10.0), f64::NAN, PIXEL_BUDGET, 8192.0), 0.0);
|
||||
}
|
||||
|
||||
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let id = d.alloc_id();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Effect rasters cropped to the view, or made at a lower resolution, are kept while the view
|
||||
//! stays as it is: dragging other art over an object whose effects reach far past the view (a wide
|
||||
//! blur, or any blur zoomed in) draws its raster from the last frame.
|
||||
//! blur, or any blur zoomed in) draws its raster from the last frame. A cropped raster made again
|
||||
//! after a pan is kept while later pans stay within the margin it is made with.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, ImageBlob, ImageObject, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use crate::{RenderOptions, Renderer};
|
||||
@@ -110,6 +111,209 @@ fn rasters_cropped_to_the_view_follow_a_pan_and_a_zoom() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 6000 × 4000 pt image (300 × 200 pixels, 20 pt a pixel) under `effects`.
|
||||
fn large_image(effects: &[(&str, serde_json::Value)]) -> Document {
|
||||
let img = image::RgbaImage::from_fn(300, 200, |x, y| image::Rgba([(x * 7) as u8, (y * 13) as u8, ((x + y) * 3) as u8, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut d = Document::new(6000.0, 4000.0);
|
||||
d.images.insert("viewraster-pan".into(), ImageBlob::new("image/png", png));
|
||||
let im = ImageObject {
|
||||
key: "viewraster-pan".into(),
|
||||
width: 300,
|
||||
height: 200,
|
||||
xf: Affine::scale(20.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.appearance.effects = effects.iter().map(|(id, p)| Effect { id: (*id).into(), params: p.clone(), visible: true }).collect();
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// At 100 %, the Drop Shadow, Gaussian Blur and Feather rasters of a large image are cropped to the view. The pan
|
||||
/// that makes one again at the same zoom makes it with a margin around the view, and later pans within the margin
|
||||
/// (by up to 259 pixels from the frame that made them, and back) draw it without making any offscreen raster: each
|
||||
/// such frame makes as many offscreen pixels as the same view drawn again, and draws as a fresh renderer draws it,
|
||||
/// within a level a channel: an image sampled into a raster that starts at another pixel rounds by up to a level, as it
|
||||
/// does in the frames of two fresh renderers whose views differ in size.
|
||||
#[test]
|
||||
fn pans_within_a_cropped_rasters_margin_draw_it_kept() {
|
||||
let size = (640, 360);
|
||||
let cases: Vec<Case> = vec![
|
||||
("Drop Shadow", vec![("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7}))], 1.0),
|
||||
("Gaussian Blur", vec![("blur.gaussian", json!({"radius": 20}))], 1.0),
|
||||
("Feather", vec![("stylize.feather", json!({"radius": 20}))], 1.0),
|
||||
(
|
||||
"Drop Shadow over a Drop Shadow",
|
||||
vec![("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7})), ("stylize.dropShadow", json!({"blur": 10, "x": -5, "y": 9}))],
|
||||
1.0,
|
||||
),
|
||||
];
|
||||
let counted = |r: &mut Renderer, d: &Document, xf: Affine, opts: &RenderOptions| {
|
||||
crate::OFFSCREEN_PIXELS.with(|n| n.set(0));
|
||||
let image = r.render(d, size.0, size.1, xf, opts);
|
||||
(image, crate::OFFSCREEN_PIXELS.with(|n| n.get()))
|
||||
};
|
||||
for (name, effects, zoom) in cases {
|
||||
let d = large_image(&effects);
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let at = |dx: f64, dy: f64| Affine::translate((320.0 + dx, 180.0 + dy)) * Affine::scale(zoom) * Affine::translate((-3000.0, -2000.0));
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 2;
|
||||
r.render(&d, size.0, size.1, at(0.0, 0.0), &opts);
|
||||
// The pan that makes the rasters again, and the same view drawn again.
|
||||
r.render(&d, size.0, size.1, at(37.0, 0.0), &opts);
|
||||
let (_, kept) = counted(&mut r, &d, at(37.0, 0.0), &opts);
|
||||
for (dx, dy) in [(74.0, 0.0), (74.0, -37.0), (296.0, -37.0), (0.0, 0.0), (-222.0, 37.0)] {
|
||||
let (image, made) = counted(&mut r, &d, at(dx, dy), &opts);
|
||||
assert_eq!(made, kept, "{name}: the pan to ({dx}, {dy}) drew the rasters kept");
|
||||
let mut fresh = Renderer::new();
|
||||
fresh.threads = 2;
|
||||
let still = fresh.render(&d, size.0, size.1, at(dx, dy), &opts);
|
||||
let worst = image.pixels.iter().zip(&still.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
assert!(worst <= 1, "{name}: ({dx}, {dy}): a channel differs by {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls by amounts that are not whole pixels, as the wheel and a trackpad give them, moved in whole pixels with the part left
|
||||
/// over carried to the next, as the canvas moves them: after the scroll that makes the Drop Shadow's cropped raster
|
||||
/// again with its margin, every later scroll draws it kept. The same scrolls moved as given make it again in every
|
||||
/// frame.
|
||||
#[test]
|
||||
fn fractional_scrolls_moved_in_whole_pixels_draw_a_cropped_raster_kept() {
|
||||
let size = (640, 360);
|
||||
let d = large_image(&[("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7}))]);
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let at = |dx: f64, dy: f64| Affine::translate((320.0 + dx, 180.0 + dy)) * Affine::translate((-3000.0, -2000.0));
|
||||
let counted = |r: &mut Renderer, xf: Affine| {
|
||||
crate::OFFSCREEN_PIXELS.with(|n| n.set(0));
|
||||
r.render(&d, size.0, size.1, xf, &opts);
|
||||
crate::OFFSCREEN_PIXELS.with(|n| n.get())
|
||||
};
|
||||
let scrolls = [(0.37, 1.13), (-2.71, 0.0), (0.0, 0.45), (1.9, -0.83), (0.21, 0.21), (13.6, -4.4), (-0.5, 0.5), (7.77, 2.22)];
|
||||
for whole in [true, false] {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 2;
|
||||
r.render(&d, size.0, size.1, at(0.0, 0.0), &opts);
|
||||
// The scroll that makes the raster again with its margin, and the same view drawn again.
|
||||
r.render(&d, size.0, size.1, at(37.0, 0.0), &opts);
|
||||
let kept = counted(&mut r, at(37.0, 0.0));
|
||||
let (mut given, mut moved): ((f64, f64), (f64, f64)) = ((37.0, 0.0), (37.0, 0.0));
|
||||
let mut made_again = 0;
|
||||
for (k, (x, y)) in scrolls.into_iter().enumerate() {
|
||||
given = (given.0 + x, given.1 + y);
|
||||
moved = if whole { (given.0.round(), given.1.round()) } else { given };
|
||||
let made = counted(&mut r, at(moved.0, moved.1));
|
||||
if whole {
|
||||
assert_eq!(made, kept, "scroll {k} to {moved:?} drew the raster kept");
|
||||
} else if made > kept {
|
||||
made_again += 1;
|
||||
}
|
||||
}
|
||||
assert!((moved.0 - given.0).abs() <= 0.5 && (moved.1 - given.1).abs() <= 0.5, "moved {moved:?} for {given:?}");
|
||||
if !whole {
|
||||
assert_eq!(made_again, scrolls.len(), "scrolls by amounts that are not whole pixels make the raster again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's shadow raster holds the shadows of the members outside the view: a pan that brings a member into view
|
||||
/// draws its shadow from the raster kept as a fresh renderer draws it, for a raster of the group's whole reach (two
|
||||
/// 100 pt squares) and for one cropped to the view and the margin a pan adds (two 3000 × 2000 pt rectangles).
|
||||
#[test]
|
||||
fn a_groups_shadow_raster_holds_the_members_outside_the_view() {
|
||||
let size = (640, 360);
|
||||
let blue = || Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0);
|
||||
// The two members, the point the view is centered on, and the pans after the first frame.
|
||||
let cases = [
|
||||
("whole", [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(600.0, 0.0, 700.0, 100.0)], (220.0, 50.0), vec![-300.0]),
|
||||
("cropped", [Rect::new(0.0, 0.0, 3000.0, 2000.0), Rect::new(3400.0, 0.0, 6400.0, 2000.0)], (3000.0, 1000.0), vec![-37.0, -237.0]),
|
||||
];
|
||||
let mut wrong = vec![];
|
||||
for (name, members, (cx, cy), pans) in cases {
|
||||
let mut d = Document::new(6400.0, 2000.0);
|
||||
let members = members.iter().map(|r| std::sync::Arc::new(Node::path(d.alloc_id(), shapes::rectangle(*r), blue()))).collect();
|
||||
let mut group = Node::group(d.alloc_id(), members);
|
||||
group.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({"blur": 20, "x": 7, "y": 7}), visible: true });
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, group).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let at = |dx: f64| Affine::translate((320.0 + dx, 180.0)) * Affine::translate((-cx, -cy));
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 2;
|
||||
r.render(&d, size.0, size.1, at(0.0), &opts);
|
||||
for dx in pans {
|
||||
let image = r.render(&d, size.0, size.1, at(dx), &opts);
|
||||
let mut fresh = Renderer::new();
|
||||
fresh.threads = 2;
|
||||
if image.pixels != fresh.render(&d, size.0, size.1, at(dx), &opts).pixels {
|
||||
wrong.push(format!("{name}: panned by {dx}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:?}");
|
||||
}
|
||||
|
||||
/// While interacting, a group with a member outside the view that only moved draws its shadow from the raster of the
|
||||
/// frame before, where a fresh renderer draws it: the moved silhouette holds that member too.
|
||||
#[test]
|
||||
fn a_moved_group_with_a_member_outside_the_view_draws_its_shadow_kept() {
|
||||
let size = (640, 360);
|
||||
let blue = || Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0);
|
||||
let mut base = Document::new(700.0, 100.0);
|
||||
let members = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(600.0, 0.0, 700.0, 100.0)]
|
||||
.iter()
|
||||
.map(|r| std::sync::Arc::new(Node::path(base.alloc_id(), shapes::rectangle(*r), blue())))
|
||||
.collect();
|
||||
let mut group = Node::group(base.alloc_id(), members);
|
||||
group.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({"blur": 20, "x": 7, "y": 7}), visible: true });
|
||||
let (id, layer) = (group.id, base.layers[0].id);
|
||||
base.insert(Some(layer), 0, group).unwrap();
|
||||
let view = Affine::translate((100.0, 130.0));
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let live = RenderOptions { interacting: true, ..opts.clone() };
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 2;
|
||||
r.render(&base, size.0, size.1, view, &opts);
|
||||
let mut moved = base.clone();
|
||||
moved.node_mut(id).unwrap().transform(Affine::translate((3.0, 2.0)), false);
|
||||
let frame = r.render(&moved, size.0, size.1, view, &live);
|
||||
assert!(r.stats.moved > 0, "drawn from the raster of the frame before");
|
||||
let mut fresh = Renderer::new();
|
||||
fresh.threads = 2;
|
||||
assert!(frame.pixels == fresh.render(&moved, size.0, size.1, view, &opts).pixels);
|
||||
}
|
||||
|
||||
/// A shadow made over an earlier cropped shadow covers only the rect the earlier one was made for. In a 2000 × 1200
|
||||
/// view of the image's right edge, the pixel budget gives the earlier shadow, which also covers what the later one
|
||||
/// reads 60 pixels below, a margin of 548 pixels and the later one 643: a pan of 600 pixels past the first makes both
|
||||
/// again, and the frame draws as a fresh renderer draws it.
|
||||
#[test]
|
||||
fn a_shadow_over_a_cropped_shadow_keeps_to_the_earlier_ones_margin() {
|
||||
let size = (2000, 1200);
|
||||
let effects = [("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7})), ("stylize.dropShadow", json!({"blur": 10, "x": -5, "y": -60}))];
|
||||
let d = large_image(&effects);
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let at = |dy: f64| Affine::translate((1000.0, 600.0 + dy)) * Affine::translate((-6000.0, -2000.0));
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 2;
|
||||
r.render(&d, size.0, size.1, at(0.0), &opts);
|
||||
r.render(&d, size.0, size.1, at(37.0), &opts);
|
||||
for dy in [37.0 - 600.0, 37.0 + 600.0] {
|
||||
let image = r.render(&d, size.0, size.1, at(dy), &opts);
|
||||
let mut fresh = Renderer::new();
|
||||
fresh.threads = 2;
|
||||
let still = fresh.render(&d, size.0, size.1, at(dy), &opts);
|
||||
let worst = image.pixels.iter().zip(&still.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
assert_eq!(worst, 0, "panned to {dy}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Time a drag of the square over each backdrop in a 2560 × 1440 view: the first frame renders as
|
||||
/// before a drag, the next eight as a drag's frames (`interacting` and `dragging`, as the canvas
|
||||
/// renders them), then the frame after the drag. The hashes of that frame and of a fresh renderer's
|
||||
|
||||
+195
-14
@@ -993,11 +993,8 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
{
|
||||
zoom_about(vm, rect, p, vm.zoom * factor);
|
||||
}
|
||||
if scroll != egui::Vec2::ZERO
|
||||
&& let Some(vm) = app.view_mut()
|
||||
{
|
||||
let d = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }.delta_to_doc(scroll);
|
||||
vm.center -= d;
|
||||
if scroll != egui::Vec2::ZERO {
|
||||
scroll_view(app, ui, scroll);
|
||||
}
|
||||
if let Some(p) = hover
|
||||
&& let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view)
|
||||
@@ -1154,9 +1151,10 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p));
|
||||
match d {
|
||||
Drag::Pan { start, center, .. } => {
|
||||
let d = xf.delta_to_doc(p - start);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
if let Some(vm) = app.view_mut() {
|
||||
vm.center = center - d;
|
||||
vm.center = center;
|
||||
pan_pixels(vm, ppp, whole_pixels(p - start, ppp));
|
||||
}
|
||||
}
|
||||
Drag::RotateView { start_angle, start_rot, snaps } => {
|
||||
@@ -1405,6 +1403,53 @@ fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64) {
|
||||
vm.center += before - Xf::new(rect, vm).to_doc(p);
|
||||
}
|
||||
|
||||
/// Move view `vm`'s content by `(dx, dy)` device pixels along the screen's axes, at `ppp` device
|
||||
/// pixels a point: the canvas's frame moves by exactly those pixels.
|
||||
fn pan_pixels(vm: &mut View, ppp: f32, (dx, dy): (f64, f64)) {
|
||||
let (sn, cs) = vm.rotation.to_radians().sin_cos();
|
||||
let k = vm.zoom * f64::from(ppp);
|
||||
vm.center -= vectorcraft_geom::Vec2::new((dx * cs + dy * sn) / k, (-dx * sn + dy * cs) / k);
|
||||
}
|
||||
|
||||
/// Screen points `d` in the nearest whole device pixels, at `ppp` device pixels a point.
|
||||
fn whole_pixels(d: egui::Vec2, ppp: f32) -> (f64, f64) {
|
||||
((f64::from(d.x) * f64::from(ppp)).round(), (f64::from(d.y) * f64::from(ppp)).round())
|
||||
}
|
||||
|
||||
/// What the scrolls of the view have left to move ([`scroll_view`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct ScrollRest {
|
||||
/// Device pixels along the screen's axes, at most half a pixel each.
|
||||
x: f64,
|
||||
y: f64,
|
||||
/// The document, the view's center, zoom and rotation, and the device pixels a point that the
|
||||
/// last scroll left.
|
||||
after: Option<(u64, Point, f64, f64, f32)>,
|
||||
}
|
||||
|
||||
/// Scroll the active view so that its content moves `by` screen points (the wheel, a trackpad, a
|
||||
/// touch screen, a scroll bar), in whole device pixels: the part of a pixel left over is added to
|
||||
/// the next scroll. It is dropped when the view changed in between (a zoom, a turn, Fit, a pan
|
||||
/// of another kind, another document) or the device pixels a point did.
|
||||
fn scroll_view(app: &mut VectorcraftApp, ui: &Ui, by: egui::Vec2) {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let Some(doc) = app.session.active().map(|st| st.uid) else { return };
|
||||
let Some(vm) = app.view_mut() else { return };
|
||||
let id = egui::Id::unique("canvas-scroll-rest");
|
||||
let rest = scroll_by(vm, doc, ppp, by, ui.data(|d| d.get_temp(id)).unwrap_or_default());
|
||||
ui.data_mut(|d| d.insert_temp(id, rest));
|
||||
}
|
||||
|
||||
/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point, after the
|
||||
/// scrolls that left `rest`: what this scroll leaves.
|
||||
fn scroll_by(vm: &mut View, doc: u64, ppp: f32, by: egui::Vec2, rest: ScrollRest) -> ScrollRest {
|
||||
let (x, y) = if rest.after == Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) { (rest.x, rest.y) } else { (0.0, 0.0) };
|
||||
let (x, y) = (x + f64::from(by.x) * f64::from(ppp), y + f64::from(by.y) * f64::from(ppp));
|
||||
let (dx, dy) = (x.round(), y.round());
|
||||
pan_pixels(vm, ppp, (dx, dy));
|
||||
ScrollRest { x: x - dx, y: y - dy, after: Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) }
|
||||
}
|
||||
|
||||
/// How far a trackpad rotation turns before it turns the view, in degrees.
|
||||
const ROTATE_DEAD_ZONE: f64 = 5.0;
|
||||
/// How long after its last rotation event a trackpad rotation ends, in seconds.
|
||||
@@ -1515,7 +1560,8 @@ fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui:
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(egui::Rect::from_min_max(outer.max - vec2(SCROLL_BAR, SCROLL_BAR), outer.max), 0.0, t.ruler);
|
||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||||
// Screen points the view moves this frame, or the centre a dragged thumb puts it at.
|
||||
// Screen points the view moves this frame, or the center a dragged thumb's drag began at and
|
||||
// the screen points it has moved the view since.
|
||||
let mut moved = egui::Vec2::ZERO;
|
||||
let mut center = None;
|
||||
for (k, (track, span)) in scroll_tracks(rect, outer).into_iter().zip(scroll_spans(&xf, content)).enumerate() {
|
||||
@@ -1541,7 +1587,7 @@ fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui:
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let by = ((along(p) - d.from) * d.ratio).clamp(d.min, d.max);
|
||||
center = Some(d.center + xf.delta_to_doc(axis * by));
|
||||
center = Some((d.center, axis * by));
|
||||
}
|
||||
if !resp.dragged() || resp.drag_stopped() {
|
||||
ui.data_mut(|dd| dd.remove::<ThumbDrag>(id));
|
||||
@@ -1572,12 +1618,15 @@ fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui:
|
||||
let hot = drag.is_some() || hover.is_some_and(|p| thumb.contains(p));
|
||||
painter.rect_filled(thumb, CornerRadius::same(3), if hot { t.text_dim } else { t.text_disabled });
|
||||
}
|
||||
let Some(vm) = app.view_mut() else { return };
|
||||
if let Some(c) = center {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
if let Some((c, by)) = center
|
||||
&& let Some(vm) = app.view_mut()
|
||||
{
|
||||
vm.center = c;
|
||||
pan_pixels(vm, ppp, whole_pixels(-by, ppp));
|
||||
}
|
||||
if moved != egui::Vec2::ZERO {
|
||||
vm.center += xf.delta_to_doc(moved);
|
||||
scroll_view(app, ui, -moved);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6962,11 +7011,12 @@ mod tests {
|
||||
assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view");
|
||||
assert_eq!(cursor(&app), Some(Cursor::Move), "frame {k}: the cursor stays while the view moves");
|
||||
}
|
||||
// The wheel's smooth scrolling runs on for some frames; then the view rests.
|
||||
// The wheel's smooth scrolling runs on for some frames; then the view rests. A frame of it
|
||||
// that scrolls less than half a device pixel leaves the view where it is.
|
||||
for _ in 0..200 {
|
||||
let before = app.view().unwrap().center;
|
||||
outlines(&mut app, vec![], 0.016);
|
||||
if app.view().unwrap().center == before {
|
||||
if app.view().unwrap().center == before && !ctx.input(|i| i.is_scrolling()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -6975,6 +7025,137 @@ mod tests {
|
||||
assert_eq!(cursor(&app), Some(Cursor::Arrow), "still again: the pointer at {:?} is past the rectangle", app.hover_doc);
|
||||
}
|
||||
|
||||
/// The wheel, a scroll bar and the Hand tool move the view by whole device pixels at 1.25
|
||||
/// device pixels a point, in a zoomed and turned view: every frame's render view is the first
|
||||
/// one's panned by whole pixels, which the renderer's cropped effect rasters need to be drawn
|
||||
/// again, and the wheel's and the scroll bar's scrolls add up to what they were given within a
|
||||
/// device pixel, as does the drag.
|
||||
#[test]
|
||||
fn scrolls_and_pans_move_the_view_by_whole_device_pixels() {
|
||||
const PPP: f32 = 1.25;
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let mut time = 0.0;
|
||||
let mut frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
time += 0.016;
|
||||
let mut input = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() };
|
||||
input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(PPP);
|
||||
ctx.run_ui(input, |ui| show(app, ui)).textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, vec![]);
|
||||
{
|
||||
let v = app.view_mut().unwrap();
|
||||
v.zoom = 4.0;
|
||||
v.rotation = 30.0;
|
||||
v.center = Point::new(200.3, 150.7);
|
||||
}
|
||||
frame(&mut app, vec![]);
|
||||
// The pan of the canvas's render view (the translation of the view it renders through, less
|
||||
// the half of its size), in device pixels.
|
||||
let pan = |app: &VectorcraftApp| {
|
||||
let v = app.view().unwrap();
|
||||
(Affine::rotate(v.rotation.to_radians()) * Affine::scale(v.zoom * f64::from(PPP)) * Affine::translate(-v.center.to_vec2())).translation()
|
||||
};
|
||||
let wheel = |d: egui::Vec2| egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: d,
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
let whole = |by: vectorcraft_geom::Vec2| (by - vectorcraft_geom::Vec2::new(by.x.round(), by.y.round())).hypot() < 1e-6;
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let track = scroll_tracks(rect, screen)[0].center();
|
||||
let deltas = [vec2(0.37, 11.13), vec2(-12.71, 0.0), vec2(0.0, 7.45), vec2(21.9, -3.83), vec2(0.21, 0.21)];
|
||||
for (name, at) in [("the wheel", rect.center()), ("the horizontal scroll bar", track)] {
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(at)]);
|
||||
let start = pan(&app);
|
||||
let mut given = vectorcraft_geom::Vec2::ZERO;
|
||||
for (k, d) in deltas.iter().enumerate() {
|
||||
frame(&mut app, vec![wheel(*d)]);
|
||||
// Over the scroll bar, the wheel scrolls along it.
|
||||
given += if at == track {
|
||||
vectorcraft_geom::Vec2::new(f64::from(d.x + d.y), 0.0)
|
||||
} else {
|
||||
vectorcraft_geom::Vec2::new(f64::from(d.x), f64::from(d.y))
|
||||
};
|
||||
let by = pan(&app) - start;
|
||||
assert!(whole(by), "{name}, scroll {k}: the view moved {by:?} device pixels");
|
||||
}
|
||||
// The wheel's smooth scrolling runs on for some frames.
|
||||
for k in 0..60 {
|
||||
frame(&mut app, vec![]);
|
||||
let by = pan(&app) - start;
|
||||
assert!(whole(by), "{name}, frame {k} after the scrolls: the view moved {by:?} device pixels");
|
||||
}
|
||||
let by = pan(&app) - start;
|
||||
let want = given * f64::from(PPP);
|
||||
assert!(by.hypot() > 10.0, "{name}: the view moved {by:?} device pixels");
|
||||
assert!((by.x - want.x).abs() <= 1.0 && (by.y - want.y).abs() <= 1.0, "{name}: moved {by:?} device pixels for {want:?}");
|
||||
}
|
||||
// The horizontal scroll bar's thumb dragged to points between device pixels: the view moves
|
||||
// `ratio` points for each point the thumb moves, the other way.
|
||||
let (_, span, from) = scroll_bar(&mut app, 0);
|
||||
let ratio = f64::from(span.ratio(scroll_tracks(rect, screen)[0].width()));
|
||||
assert!(ratio > 1.0, "at 400% the view moves further than the thumb: {ratio}");
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(from)]);
|
||||
let start = pan(&app);
|
||||
frame(&mut app, vec![primary(from, true)]);
|
||||
for (k, dx) in [7.37_f32, 12.81, 3.33].into_iter().enumerate() {
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(from + vec2(dx, 0.0))]);
|
||||
let by = pan(&app) - start;
|
||||
assert!(whole(by), "thumb drag {k}: the view moved {by:?} device pixels");
|
||||
let want = -f64::from(dx) * ratio * f64::from(PPP);
|
||||
assert!((by.x - want).abs() <= 1.0 && by.y.abs() <= 1.0, "thumb drag {k}: moved {by:?} device pixels for {want}");
|
||||
}
|
||||
frame(&mut app, vec![primary(from + vec2(3.33, 0.0), false)]);
|
||||
// A Hand tool drag to points between device pixels.
|
||||
app.select_tool("hand");
|
||||
let p0 = rect.center() + vec2(0.3, 0.1);
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(p0)]);
|
||||
let start = pan(&app);
|
||||
frame(&mut app, vec![primary(p0, true)]);
|
||||
for (k, d) in [vec2(13.37, -7.61), vec2(40.19, 3.33), vec2(-5.55, 22.71)].into_iter().enumerate() {
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(p0 + d)]);
|
||||
let by = pan(&app) - start;
|
||||
assert!(whole(by), "drag {k}: the view moved {by:?} device pixels");
|
||||
let want = vectorcraft_geom::Vec2::new(f64::from(d.x), f64::from(d.y)) * f64::from(PPP);
|
||||
assert!((by.x - want.x).abs() <= 1.0 && (by.y - want.y).abs() <= 1.0, "drag {k}: moved {by:?} device pixels for {want:?}");
|
||||
}
|
||||
frame(&mut app, vec![primary(p0 + vec2(-5.55, 22.71), false)]);
|
||||
}
|
||||
|
||||
/// The part of a device pixel a scroll leaves is added to the next scroll of the same view, and
|
||||
/// dropped when the view was zoomed, turned or moved in between, when the next scroll is in
|
||||
/// another document, or when the device pixels a point changed.
|
||||
#[test]
|
||||
fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() {
|
||||
let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0 };
|
||||
let mut v = view;
|
||||
let rest = scroll_by(&mut v, 1, 1.0, vec2(0.4, -0.3), ScrollRest::default());
|
||||
assert_eq!(v.center, view.center, "less than half a pixel does not move the view");
|
||||
assert!((rest.x - 0.4).abs() < 1e-6 && (rest.y + 0.3).abs() < 1e-6);
|
||||
let mut again = v;
|
||||
scroll_by(&mut again, 1, 1.0, vec2(0.4, -0.3), rest);
|
||||
assert_eq!(again.center, Point::new(99.0, 101.0), "the second scroll adds the first's part");
|
||||
let changed = [
|
||||
("zoomed", View { zoom: 2.0, ..v }, 1, 1.0),
|
||||
("turned", View { rotation: 90.0, ..v }, 1, 1.0),
|
||||
("fitted or moved", View { center: Point::new(150.0, 120.0), ..v }, 1, 1.0),
|
||||
("another document", v, 2, 1.0),
|
||||
("another display scale", v, 1, 1.25),
|
||||
];
|
||||
for (name, before, doc, ppp) in changed {
|
||||
let mut after = before;
|
||||
let left = scroll_by(&mut after, doc, ppp, vec2(0.4 / ppp, -0.3 / ppp), rest);
|
||||
assert_eq!(after.center, before.center, "{name}: the first scroll's part is dropped");
|
||||
assert!((left.x - 0.4).abs() < 1e-6 && (left.y + 0.3).abs() < 1e-6, "{name}: {left:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// View › Snap to Glyph toggles through the View menu command (#3124).
|
||||
#[test]
|
||||
fn snap_to_glyph_menu_command_toggles_the_view_flag() {
|
||||
|
||||
@@ -1037,6 +1037,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
- **Dragging objects with raster effects when zoomed in (#2558):** during a drag with a tool on the canvas, the renderer draws filtered content (Gaussian Blur, Feather and the Photoshop-style filters) within 1,000,000 pixels, at a lower resolution where it would need more, unless it can draw the content from an earlier frame's raster. In the desktop app it does the same for drop shadows, outer glows and inner glows. No raster made that way is kept. The frame after the drag renders them as a still frame does, and a dialog's preview, exports and printing render as before. `zoomed_drag_perf` times a drag's frames at 100% to 6400%.
|
||||
- **Dragging other art over a wide blur (#2558):** an effect raster too large to keep whole (a wide blur, a shadow or glow, or any blur zoomed in) is cropped to what can show in the view, or made at a lower resolution and resampled to the view's pixels, and kept while the view stays the same, so dragging other art over the object draws its effects from the still frame instead of re-rendering them at the drag's lower resolution. A drag stays on the render worker once the worker drew the frame shown, since only that renderer holds the rasters. `tests_viewraster` covers each effect, and `a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame` the canvas on and off the worker. Not yet: a worker frame shows one display frame after the overlays, and the first frame of an interaction can land on the renderer that has not made the rasters yet (follow-up: render every canvas frame on the worker, waiting briefly for fast ones).
|
||||
- **Panning over a cropped effect raster:** when a pan makes a shadow, glow or filtered content raster cropped to the view again at the same zoom and rotation, the renderer makes it over a margin around the view too (up to half the view's longer side, within 8.4 million pixels), and later pans that keep the view inside that margin draw the raster instead of making it again. Zooming and the first frame at a zoom make the raster without the margin. The canvas pans by whole device pixels: the wheel, a trackpad, a touch screen and the scroll bars add the part of a pixel left over to the next scroll of the same view, and Hand tool and scroll bar thumb drags round the distance dragged. A shadow or glow raster holds the shadows of group members outside the view, so a pan that shows a member draws its shadow. `pans_within_a_cropped_rasters_margin_draw_it_kept` covers Drop Shadow, Gaussian Blur and Feather on a large image at 100%. `a_groups_shadow_raster_holds_the_members_outside_the_view` covers a group's members, `fractional_scrolls_moved_in_whole_pixels_draw_a_cropped_raster_kept` covers scrolls by fractions of a pixel, and `scrolls_and_pans_move_the_view_by_whole_device_pixels` covers the canvas.
|
||||
|
||||
- **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user