mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 11:54:36 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18941ed8e1 | ||
|
|
0e31516761 | ||
|
|
730a2ea918 |
No files matched your search
+161
-79
@@ -44,6 +44,10 @@ pub(crate) use resources::RenderResources;
|
||||
|
||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||
|
||||
/// Above this many uncached tiles, a preserved-target render yields instead of
|
||||
/// painting everything in one blocking call.
|
||||
const MAX_SYNC_TILES_ON_PRESERVED_TARGET: usize = 4;
|
||||
|
||||
#[repr(u8)]
|
||||
pub enum FrameType {
|
||||
None = 0,
|
||||
@@ -358,9 +362,8 @@ pub(crate) struct RenderState {
|
||||
pending_nodes: Vec<NodeRenderState>,
|
||||
pub current_tile: Option<tiles::Tile>,
|
||||
pub render_area: Rect,
|
||||
// render_area expanded by surface margins — used for visibility checks so that
|
||||
// shapes in the margin zone are rendered (needed for background blur sampling).
|
||||
pub render_area_with_margins: Rect,
|
||||
/// Region a shape must touch to be painted for the current tile.
|
||||
pub cull_area: Rect,
|
||||
pub tile_viewbox: tiles::TileViewbox,
|
||||
pub tiles: tiles::TileHashMap,
|
||||
pub pending_tiles: PendingTiles,
|
||||
@@ -482,14 +485,17 @@ impl RenderState {
|
||||
/// - **Top-level only**: cache entries are built for direct children of the root.
|
||||
/// - **Moved node**: only allow cache reuse for *pure translations* (no scale/rotate/skew),
|
||||
/// because other transforms would require resampling and can diverge from the live render.
|
||||
/// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so
|
||||
/// we don't show stale content while something moves over/inside it.
|
||||
/// - **Other cached nodes**: reusable unless the crop holds stale pixels of the moving
|
||||
/// content (`moved_bounds_before`), or the movers paint *under* this node and now
|
||||
/// overlap it (`movers_paint_above`).
|
||||
fn should_use_cached_top_level_during_interactive(
|
||||
&mut self,
|
||||
node_id: Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
moved_ids: &[Uuid],
|
||||
moved_bounds: Option<Rect>,
|
||||
moved_bounds_before: Option<Rect>,
|
||||
movers_paint_above: bool,
|
||||
) -> bool {
|
||||
if !self.backbuffer_crop_cache.contains_key(&node_id) {
|
||||
return false;
|
||||
@@ -526,19 +532,26 @@ impl RenderState {
|
||||
.is_some_and(|s| s.is_safe_for_drag_crop_cache(tree));
|
||||
}
|
||||
|
||||
let Some(src_doc_bounds) = self
|
||||
.backbuffer_crop_cache
|
||||
.get(&node_id)
|
||||
.map(|crop| crop.src_doc_bounds)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// The crop was captured before the gesture, so it still holds the movers where they
|
||||
// started: reusing it there would paint a ghost. This also covers a mover that is a
|
||||
// descendant of this node.
|
||||
if moved_bounds_before.is_some_and(|before| before.intersects(src_doc_bounds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match moved_bounds {
|
||||
// Something is actually moving/resizing. If the moving content overlaps this
|
||||
// cached crop, do not use the cached pixels for this frame. We intentionally
|
||||
// keep the cache entry: overlap is typically transient during drag, and once
|
||||
// the moving content leaves the area the crop becomes valid again (stationary
|
||||
// shape unchanged).
|
||||
Some(moved) => {
|
||||
let intersects = self
|
||||
.backbuffer_crop_cache
|
||||
.get(&node_id)
|
||||
.is_some_and(|crop| moved.intersects(crop.src_doc_bounds));
|
||||
!intersects
|
||||
}
|
||||
// Overlap only matters when a mover paints *under* this node, where the crop
|
||||
// would cover it (the crop is a backbuffer crop, so it carries the backdrop as
|
||||
// it was). Movers that paint above are drawn after the blit and stay visible.
|
||||
Some(moved) => movers_paint_above || !moved.intersects(src_doc_bounds),
|
||||
|
||||
// Interactive-transform mode is active but nothing is moving (no modifiers):
|
||||
// e.g. editing a text shape inside this board reflows its content without
|
||||
@@ -576,7 +589,7 @@ impl RenderState {
|
||||
pending_nodes: vec![],
|
||||
current_tile: None,
|
||||
render_area: Rect::new_empty(),
|
||||
render_area_with_margins: Rect::new_empty(),
|
||||
cull_area: Rect::new_empty(),
|
||||
tiles,
|
||||
tile_viewbox: tiles::TileViewbox::new_with_interest(
|
||||
&viewbox,
|
||||
@@ -1080,16 +1093,12 @@ impl RenderState {
|
||||
}
|
||||
|
||||
let fast_mode = self.options.is_fast_mode();
|
||||
// Decide *now* (at the first real cache blit) whether we need to clear Cache.
|
||||
// This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI),
|
||||
// while still preventing stale pixels from surviving across full-quality renders.
|
||||
// Nothing writes Cache any more; the flag is what `start_render_loop`
|
||||
// reads to decide whether `cached_viewbox` may advance.
|
||||
if !fast_mode && !self.cache_cleared_this_render {
|
||||
self.surfaces.clear_cache(self.background_color);
|
||||
self.cache_cleared_this_render = true;
|
||||
}
|
||||
// In fast mode the viewport is moving (pan/zoom) so Cache surface
|
||||
// positions would be wrong — only save to the tile HashMap.
|
||||
let tile_rect = self.get_current_aligned_tile_bounds()?;
|
||||
|
||||
let current_tile = *self
|
||||
.current_tile
|
||||
@@ -1103,8 +1112,6 @@ impl RenderState {
|
||||
self.surfaces.draw_current_tile_into_tile_atlas(
|
||||
&self.tile_viewbox,
|
||||
¤t_tile,
|
||||
&tile_rect,
|
||||
fast_mode,
|
||||
self.render_area,
|
||||
);
|
||||
|
||||
@@ -1695,23 +1702,34 @@ impl RenderState {
|
||||
|
||||
let inner_shadows = shape.inner_shadow_paints();
|
||||
let blur_filter = shape.image_filter(1.);
|
||||
let mut paragraphs_with_shadows =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): (
|
||||
Vec<_>,
|
||||
Vec<_>,
|
||||
) = shape
|
||||
.visible_strokes()
|
||||
.rev()
|
||||
.map(|stroke| {
|
||||
text::stroke_paragraph_builder_group_from_text(
|
||||
text_content,
|
||||
stroke,
|
||||
&shape.selrect(),
|
||||
Some(true),
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
|
||||
// Safe to leave empty: every consumer is inside `for shadow in
|
||||
// <list>` or guarded by `!skip_drop_shadows`.
|
||||
let has_shadow_passes = if parent_shadows.is_some() {
|
||||
!skip_drop_shadows
|
||||
} else {
|
||||
!drop_shadows.is_empty() || !inner_shadows.is_empty()
|
||||
};
|
||||
|
||||
let mut paragraphs_with_shadows = Vec::new();
|
||||
let mut stroke_paragraphs_with_shadows_list = Vec::new();
|
||||
if has_shadow_passes {
|
||||
paragraphs_with_shadows =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
stroke_paragraphs_with_shadows_list = shape
|
||||
.visible_strokes()
|
||||
.rev()
|
||||
.map(|stroke| {
|
||||
text::stroke_paragraph_builder_group_from_text(
|
||||
text_content,
|
||||
stroke,
|
||||
&shape.selrect(),
|
||||
Some(true),
|
||||
)
|
||||
.0
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(parent_shadows) = parent_shadows {
|
||||
if !skip_drop_shadows {
|
||||
@@ -2001,16 +2019,31 @@ impl RenderState {
|
||||
self.current_tile = Some(tile);
|
||||
let scale = self.get_scale();
|
||||
self.render_area = tiles::get_tile_rect(tile, scale);
|
||||
// One device pixel of slack for edges that land on the boundary. Callers
|
||||
// test bounds that already carry stroke/shadow/blur bleed.
|
||||
let epsilon = 1.0 / scale;
|
||||
self.cull_area = skia::Rect::from_ltrb(
|
||||
self.render_area.left - epsilon,
|
||||
self.render_area.top - epsilon,
|
||||
self.render_area.right + epsilon,
|
||||
self.render_area.bottom + epsilon,
|
||||
);
|
||||
self.surfaces.update_render_context(self.render_area, scale);
|
||||
}
|
||||
|
||||
/// Widens the cull area to the surface margins, for tiles whose content
|
||||
/// samples the backdrop (`render_background_blur` caps sigma to `margin / 3`).
|
||||
fn widen_cull_area_for_backdrop(&mut self) {
|
||||
let scale = self.get_scale();
|
||||
let margins = self.surfaces.margins();
|
||||
let margin_w = margins.width as f32 / scale;
|
||||
let margin_h = margins.height as f32 / scale;
|
||||
self.render_area_with_margins = skia::Rect::from_ltrb(
|
||||
self.cull_area = skia::Rect::from_ltrb(
|
||||
self.render_area.left - margin_w,
|
||||
self.render_area.top - margin_h,
|
||||
self.render_area.right + margin_w,
|
||||
self.render_area.bottom + margin_h,
|
||||
);
|
||||
self.surfaces.update_render_context(self.render_area, scale);
|
||||
}
|
||||
|
||||
fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) {
|
||||
@@ -2260,6 +2293,9 @@ impl RenderState {
|
||||
|
||||
self.surfaces.gc();
|
||||
|
||||
if self.current_tile.is_some() && !self.pending_nodes.is_empty() {
|
||||
}
|
||||
|
||||
self.pending_nodes.clear();
|
||||
if self.pending_nodes.capacity() < tree.len() {
|
||||
self.pending_nodes
|
||||
@@ -2376,8 +2412,15 @@ impl RenderState {
|
||||
} else {
|
||||
// Keep progressive yielding, except for a localized shape edit on a
|
||||
// stable viewbox (e.g. recoloring) which renders in one frame.
|
||||
let allow_stop =
|
||||
!preserve_target || self.zoom_changed() || self.options.is_interactive_transform();
|
||||
// "Localized" is a tile count: a gesture commit invalidates the whole
|
||||
// viewport, and rendering that without yielding blocks the thread
|
||||
// handling pointerup until every tile is done.
|
||||
let queued_uncached = self.pending_tiles.visible_uncached.len()
|
||||
+ self.pending_tiles.interest_uncached.len();
|
||||
let allow_stop = !preserve_target
|
||||
|| self.zoom_changed()
|
||||
|| self.options.is_interactive_transform()
|
||||
|| queued_uncached > MAX_SYNC_TILES_ON_PRESERVED_TARGET;
|
||||
frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?;
|
||||
|
||||
// This is an option to debug frames.
|
||||
@@ -2530,7 +2573,7 @@ impl RenderState {
|
||||
let saved_focus_mode = self.focus_mode.clone();
|
||||
let saved_export_context = self.export_context;
|
||||
let saved_render_area = self.render_area;
|
||||
let saved_render_area_with_margins = self.render_area_with_margins;
|
||||
let saved_cull_area = self.cull_area;
|
||||
let saved_current_tile = self.current_tile;
|
||||
let saved_pending_nodes = std::mem::take(&mut self.pending_nodes);
|
||||
let saved_nested_fills = std::mem::take(&mut self.nested_fills);
|
||||
@@ -2560,7 +2603,7 @@ impl RenderState {
|
||||
|
||||
self.surfaces.resize_export_surface(scale, extrect);
|
||||
self.render_area = extrect;
|
||||
self.render_area_with_margins = extrect;
|
||||
self.cull_area = extrect;
|
||||
self.surfaces.update_render_context(extrect, scale);
|
||||
|
||||
// `resize_export_surface` swaps in a brand-new (zeroed, i.e.
|
||||
@@ -2601,7 +2644,7 @@ impl RenderState {
|
||||
self.focus_mode = saved_focus_mode;
|
||||
self.export_context = saved_export_context;
|
||||
self.render_area = saved_render_area;
|
||||
self.render_area_with_margins = saved_render_area_with_margins;
|
||||
self.cull_area = saved_cull_area;
|
||||
self.current_tile = saved_current_tile;
|
||||
self.pending_nodes = saved_pending_nodes;
|
||||
self.nested_fills = saved_nested_fills;
|
||||
@@ -3065,7 +3108,7 @@ impl RenderState {
|
||||
// Account for the shadow offset so the temporary surface fully contains the shifted blur.
|
||||
bounds.offset(world_offset);
|
||||
// Early cull if the shadow bounds are outside the render area.
|
||||
if !bounds.intersects(self.render_area_with_margins) && target_surface != SurfaceId::Export
|
||||
if !bounds.intersects(self.cull_area) && target_surface != SurfaceId::Export
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -3389,44 +3432,70 @@ impl RenderState {
|
||||
target_surface = SurfaceId::Export;
|
||||
}
|
||||
|
||||
// During interactive transforms we compute the union of the current bounds of all
|
||||
// modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap
|
||||
// guard to decide when cached top-level crops are unsafe to reuse (something is moving
|
||||
// over/inside them), without doing expensive ancestor walks per node.
|
||||
// Bounds of the moving shapes (doc space @ 100% zoom), as cheap overlap guards for
|
||||
// cached top-level crops. Current and pre-modifier bounds are kept apart: the
|
||||
// pre-modifier union says where the crops hold stale pixels, the current union says
|
||||
// what the movers now cover. Unioning them would make a drag that started far away
|
||||
// poison every crop between the start and the cursor.
|
||||
//
|
||||
// `modifier_ids` is pre-computed once here and reused throughout the loop to avoid
|
||||
// repeated allocations (formerly O(N_shapes) HashMap builds) per node.
|
||||
let modifier_ids = tree.modifier_ids();
|
||||
let moved_bounds = if self.options.is_interactive_transform() && !modifier_ids.is_empty() {
|
||||
let mut acc: Option<Rect> = None;
|
||||
let interactive_moving =
|
||||
self.options.is_interactive_transform() && !modifier_ids.is_empty();
|
||||
let mut moved_bounds: Option<Rect> = None;
|
||||
let mut moved_bounds_before: Option<Rect> = None;
|
||||
if interactive_moving {
|
||||
let join = |acc: &mut Option<Rect>, r: Rect| match acc {
|
||||
None => *acc = Some(r),
|
||||
Some(prev) => {
|
||||
prev.join(r);
|
||||
}
|
||||
};
|
||||
for id in modifier_ids.iter() {
|
||||
// Current (post-modifier) bounds
|
||||
if let Some(s) = tree.get(id) {
|
||||
let r = self.get_cached_extrect(s, tree, 1.0);
|
||||
acc = Some(match acc {
|
||||
None => r,
|
||||
Some(mut prev) => {
|
||||
prev.join(r);
|
||||
prev
|
||||
}
|
||||
});
|
||||
join(&mut moved_bounds, r);
|
||||
}
|
||||
|
||||
// Pre-modifier bounds: important so cached top-level crops that still contain the
|
||||
// shape at its original position are considered "unsafe" even after the shape
|
||||
// has moved away (e.g. dragging a child out of a clipped frame).
|
||||
if let Some(raw) = tree.get_raw(id) {
|
||||
let r0 = self.get_cached_extrect(raw, tree, 1.0);
|
||||
acc = Some(match acc {
|
||||
None => r0,
|
||||
Some(mut prev) => {
|
||||
prev.join(r0);
|
||||
prev
|
||||
}
|
||||
});
|
||||
join(&mut moved_bounds_before, r0);
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
// `children_ids(false)` reverses, so index 0 is the topmost root and a *lower* index
|
||||
// paints later. A crop stays reusable under an overlapping mover only when every
|
||||
// mover paints above it, i.e. sits at a strictly lower index.
|
||||
let root_paint_index: HashMap<Uuid, usize> = if interactive_moving {
|
||||
tree.get(&Uuid::nil())
|
||||
.map(|root| {
|
||||
root.children_ids(false)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, id)| (id, i))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let index_of_root = |id: &Uuid| root_paint_index.get(id).copied();
|
||||
let top_level_ancestor = |mut id: Uuid| -> Option<Uuid> {
|
||||
for _ in 0..64 {
|
||||
let parent = tree.get_raw(&id).and_then(|s| s.parent_id)?;
|
||||
if parent == Uuid::nil() {
|
||||
return Some(id);
|
||||
}
|
||||
id = parent;
|
||||
}
|
||||
None
|
||||
};
|
||||
let moved_max_root_index = if interactive_moving {
|
||||
modifier_ids
|
||||
.iter()
|
||||
.map(|id| top_level_ancestor(*id).and_then(|top| index_of_root(&top)))
|
||||
.try_fold(0usize, |acc, idx| idx.map(|i| acc.max(i)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -3510,11 +3579,11 @@ impl RenderState {
|
||||
|| if is_container || has_effects {
|
||||
let element_extrect =
|
||||
extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale));
|
||||
element_extrect.intersects(self.render_area_with_margins)
|
||||
element_extrect.intersects(self.cull_area)
|
||||
&& !transformed_element.visually_insignificant(scale, tree)
|
||||
} else {
|
||||
let selrect = transformed_element.selrect();
|
||||
selrect.intersects(self.render_area_with_margins)
|
||||
selrect.intersects(self.cull_area)
|
||||
&& !transformed_element.visually_insignificant(scale, tree)
|
||||
};
|
||||
|
||||
@@ -3532,13 +3601,22 @@ impl RenderState {
|
||||
// draw it directly from Backbuffer crop on the current tile surface and skip
|
||||
// traversing/rendering the subtree.
|
||||
if self.options.is_interactive_transform() {
|
||||
let movers_paint_above = match (moved_max_root_index, index_of_root(&node_id)) {
|
||||
(Some(moved_idx), Some(node_idx)) => moved_idx < node_idx,
|
||||
_ => false,
|
||||
};
|
||||
let use_cached = self.should_use_cached_top_level_during_interactive(
|
||||
node_id,
|
||||
tree,
|
||||
modifier_ids,
|
||||
moved_bounds,
|
||||
moved_bounds_before,
|
||||
movers_paint_above,
|
||||
);
|
||||
|
||||
if !use_cached && self.backbuffer_crop_cache.contains_key(&node_id) {
|
||||
}
|
||||
|
||||
if use_cached {
|
||||
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) {
|
||||
let crop_image = &crop.image;
|
||||
@@ -3925,6 +4003,10 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
if tile_has_bg_blur {
|
||||
self.widen_cull_area_for_backdrop();
|
||||
}
|
||||
|
||||
if !valid_ids.is_empty() {
|
||||
self.current_tile_had_shapes = true;
|
||||
}
|
||||
|
||||
@@ -155,6 +155,12 @@ pub fn render_text_shadows(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Before `canvas_and_mark_dirty`: an empty-but-dirty layer still costs a
|
||||
// full surface composite.
|
||||
if shadows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let canvas = render_state
|
||||
.surfaces
|
||||
.canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::TextDropShadows));
|
||||
|
||||
@@ -1208,8 +1208,6 @@ impl Surfaces {
|
||||
&mut self,
|
||||
tile_viewbox: &TileViewbox,
|
||||
tile: &Tile,
|
||||
tile_rect: &skia::Rect,
|
||||
skip_cache_surface: bool,
|
||||
tile_doc_rect: skia::Rect,
|
||||
) {
|
||||
let gpu_state = get_gpu_state();
|
||||
@@ -1231,18 +1229,6 @@ impl Surfaces {
|
||||
let mut current = self.current.clone();
|
||||
draw_surface_src_rect_to_dst(&mut current, self.tile_atlas.canvas(), src, dst, sampling);
|
||||
|
||||
if !skip_cache_surface {
|
||||
// Optional legacy Cache surface fill (debug). Pan/zoom preview
|
||||
// uses DocAtlas + tile-atlas textures via render_from_cache.
|
||||
let mut current = self.current.clone();
|
||||
draw_surface_src_rect_to_dst(
|
||||
&mut current,
|
||||
self.cache.canvas(),
|
||||
src,
|
||||
*tile_rect,
|
||||
sampling,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_cached_tile_surface(&self, tile: Tile) -> bool {
|
||||
@@ -1385,35 +1371,37 @@ impl Surfaces {
|
||||
);
|
||||
let src_rect_f = skia::Rect::from(src_rect);
|
||||
|
||||
let backbuffer_canvas = self.backbuffer.canvas();
|
||||
|
||||
// Draw background
|
||||
// let mut paint = skia::Paint::default();
|
||||
// paint.set_color(color);
|
||||
// backbuffer_canvas.draw_rect(tile_rect, &paint);
|
||||
|
||||
// Draw current surface directly to target (no snapshot)
|
||||
self.current.draw(
|
||||
backbuffer_canvas,
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
),
|
||||
sampling_options,
|
||||
None,
|
||||
let origin = (
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
);
|
||||
|
||||
// Also draw to cache for render_from_cache
|
||||
// Clipped to the tile: `current` is a whole tile surface with margins on
|
||||
// every side, cleared to the background colour, so an unclipped draw
|
||||
// repaints a 256px halo of background over the neighbouring tiles.
|
||||
// Rounded out because the viewbox offset can be fractional, and a clip
|
||||
// that rounds inwards would leave a seam between adjacent tiles.
|
||||
let clip = skia::Rect::from_ltrb(
|
||||
tile_rect.left.floor(),
|
||||
tile_rect.top.floor(),
|
||||
tile_rect.right.ceil(),
|
||||
tile_rect.bottom.ceil(),
|
||||
);
|
||||
|
||||
let backbuffer_canvas = self.backbuffer.canvas();
|
||||
backbuffer_canvas.save();
|
||||
backbuffer_canvas.clip_rect(clip, None, false);
|
||||
self.current
|
||||
.draw(backbuffer_canvas, origin, sampling_options, None);
|
||||
backbuffer_canvas.restore();
|
||||
|
||||
if draw_on_cache == DrawOnCache::Yes {
|
||||
self.current.draw(
|
||||
self.cache.canvas(),
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
),
|
||||
sampling_options,
|
||||
None,
|
||||
);
|
||||
let cache_canvas = self.cache.canvas();
|
||||
cache_canvas.save();
|
||||
cache_canvas.clip_rect(clip, None, false);
|
||||
self.current
|
||||
.draw(cache_canvas, origin, sampling_options, None);
|
||||
cache_canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user