mirror of
https://github.com/penpot/penpot.git
synced 2026-06-29 18:52:11 +00:00
2654 lines
107 KiB
Rust
2654 lines
107 KiB
Rust
mod background_blur;
|
|
pub mod cache;
|
|
mod debug;
|
|
pub mod drag_crop;
|
|
pub mod drop_shadow;
|
|
pub mod enter_exit;
|
|
pub mod export;
|
|
mod fills;
|
|
pub mod filters;
|
|
pub mod focus_mode;
|
|
mod fonts;
|
|
pub mod gpu_state;
|
|
pub mod grid_layout;
|
|
mod images;
|
|
pub mod layer_blur;
|
|
mod options;
|
|
pub mod pdf;
|
|
pub mod rulers;
|
|
mod shadows;
|
|
pub mod shape_renderer;
|
|
mod strokes;
|
|
mod surfaces;
|
|
pub mod text;
|
|
pub mod text_editor;
|
|
mod ui;
|
|
mod vector;
|
|
pub mod view_mode;
|
|
pub mod walk;
|
|
|
|
use skia_safe::{self as skia, Matrix, RRect, Rect};
|
|
use std::borrow::Cow;
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use options::RenderOptions;
|
|
pub use surfaces::{SurfaceId, Surfaces};
|
|
|
|
pub use drag_crop::InteractiveDragCrop;
|
|
pub use focus_mode::FocusMode;
|
|
pub(crate) use walk::{get_simplified_children, sort_z_index};
|
|
pub use walk::{ClipStack, NodeRenderState, RenderStats};
|
|
|
|
use crate::error::{Error, Result};
|
|
use crate::shapes::{
|
|
all_with_ancestors, Blur, BlurType, Fill, Layout, Shadow, Shape, Stroke, StrokeKind,
|
|
TextContent, Type,
|
|
};
|
|
use crate::state::{RulerState, ShapesPoolMutRef, ShapesPoolRef};
|
|
use crate::tiles::{self, PendingTiles, TileRect};
|
|
use crate::uuid::Uuid;
|
|
use crate::view::Viewbox;
|
|
use crate::wapi;
|
|
use crate::{get_gpu_state, performance};
|
|
|
|
pub use fonts::*;
|
|
pub use images::*;
|
|
|
|
#[repr(u8)]
|
|
pub enum FrameType {
|
|
None = 0,
|
|
Partial = 1,
|
|
Full = 2,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[repr(u8)]
|
|
pub enum RenderFlag {
|
|
None = 0,
|
|
Partial = 1,
|
|
Full = 2,
|
|
}
|
|
|
|
pub(crate) struct RenderState {
|
|
pub options: RenderOptions,
|
|
stats: RenderStats,
|
|
pub surfaces: Surfaces,
|
|
pub fonts: FontStore,
|
|
pub viewbox: Viewbox,
|
|
pub cached_viewbox: Viewbox,
|
|
pub images: ImageStore,
|
|
pub background_color: skia::Color,
|
|
// Stack of nodes pending to be rendered.
|
|
pending_nodes: Vec<NodeRenderState>,
|
|
pub current_tile: Option<tiles::Tile>,
|
|
pub sampling_options: skia::SamplingOptions,
|
|
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,
|
|
pub tile_viewbox: tiles::TileViewbox,
|
|
pub tiles: tiles::TileHashMap,
|
|
pub pending_tiles: PendingTiles,
|
|
// nested_fills maintains a stack of group fills that apply to nested shapes
|
|
// without their own fill definitions. This is necessary because in SVG, a group's `fill`
|
|
// can affect its child elements if they don't specify one themselves. If the planned
|
|
// migration to remove group-level fills is completed, this code should be removed.
|
|
// Frames contained in groups must reset this nested_fills stack pushing a new empty vector.
|
|
pub nested_fills: Vec<Vec<Fill>>,
|
|
pub nested_blurs: Vec<Option<Blur>>, // FIXME: why is this an option?
|
|
pub cached_layer_blur: Option<Option<Blur>>,
|
|
pub nested_shadows: Vec<Vec<Shadow>>,
|
|
pub show_grid: Option<Uuid>,
|
|
pub empty_grid_frame_ids: HashSet<Uuid>,
|
|
pub rulers: RulerState,
|
|
pub focus_mode: FocusMode,
|
|
/// Viewer-only whitelist for fixed-scroll layer passes.
|
|
pub include_filter: Option<HashSet<Uuid>>,
|
|
/// Frame id passed as `base_object` for viewer renders; always traversed.
|
|
pub viewer_render_root: Option<Uuid>,
|
|
pub viewer_visible_set: Option<HashSet<Uuid>>,
|
|
pub touched_ids: HashSet<Uuid>,
|
|
/// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.)
|
|
/// where we must render shapes without inheriting ancestor layer blurs. Toggle it through
|
|
/// `with_nested_blurs_suppressed` to ensure it's always restored.
|
|
pub ignore_nested_blurs: bool,
|
|
/// Preview render mode - when true, uses simplified rendering for progressive loading
|
|
pub preview_mode: bool,
|
|
pub export_context: Option<(Rect, f32)>,
|
|
/// Cleared at the beginning of a render pass; set to true after we clear Cache the first
|
|
/// time we are about to blit a tile into Cache for this pass.
|
|
pub cache_cleared_this_render: bool,
|
|
/// True if the current tile had shapes assigned to it when we
|
|
/// started rendering it. Lets us distinguish a genuinely empty
|
|
/// tile (skip composite, just clear) from a tile whose walker
|
|
/// finished its work in a previous PAF and is now being resumed
|
|
/// (must composite to present the work). Reset when current_tile
|
|
/// changes.
|
|
pub current_tile_had_shapes: bool,
|
|
/// During interactive transforms we keep `Target` between rAFs. Seed the
|
|
/// interactive backdrop exactly once per gesture (first rAF) so we don't
|
|
/// repeatedly overwrite tiles that have already been updated.
|
|
pub interactive_target_seeded: bool,
|
|
/// When true, the next `start_render_loop` keeps the last presented `Target`
|
|
/// pixels instead of clearing the canvas. Set after incremental shape updates
|
|
/// (e.g. adding a rect) so the workspace stays visible while only affected
|
|
/// tiles are re-rendered asynchronously.
|
|
pub preserve_target_during_render: bool,
|
|
/// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during
|
|
/// drag, entries for the moved top-level selection are ensured here
|
|
pub backbuffer_crop_cache: HashMap<Uuid, InteractiveDragCrop>,
|
|
}
|
|
|
|
impl RenderState {
|
|
/// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an
|
|
/// interactive transform (drag/resize/rotate).
|
|
///
|
|
/// We only reuse cached pixels when it is safe and visually correct:
|
|
/// - **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.
|
|
fn should_use_cached_top_level_during_interactive(
|
|
&mut self,
|
|
node_id: Uuid,
|
|
tree: ShapesPoolRef,
|
|
moved_ids: &[Uuid],
|
|
moved_bounds: Option<Rect>,
|
|
) -> bool {
|
|
drag_crop::should_use_cached_top_level_during_interactive(
|
|
self,
|
|
node_id,
|
|
tree,
|
|
moved_ids,
|
|
moved_bounds,
|
|
)
|
|
}
|
|
|
|
pub fn try_new(width: i32, height: i32) -> Result<RenderState> {
|
|
// This needs to be done once per WebGL context.
|
|
let sampling_options =
|
|
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest);
|
|
|
|
let fonts = FontStore::try_new()?;
|
|
let surfaces = Surfaces::try_new(
|
|
(width, height),
|
|
sampling_options,
|
|
tiles::get_tile_dimensions(),
|
|
)?;
|
|
|
|
// This is used multiple times everywhere so instead of creating new instances every
|
|
// time we reuse this one.
|
|
|
|
let viewbox = Viewbox::new(width as f32, height as f32);
|
|
let tiles = tiles::TileHashMap::new();
|
|
let options = RenderOptions::default();
|
|
|
|
Ok(Self {
|
|
options,
|
|
stats: RenderStats::new(),
|
|
surfaces,
|
|
fonts,
|
|
viewbox,
|
|
cached_viewbox: Viewbox::new(0., 0.),
|
|
images: ImageStore::new(),
|
|
background_color: skia::Color::TRANSPARENT,
|
|
pending_nodes: vec![],
|
|
current_tile: None,
|
|
sampling_options,
|
|
render_area: Rect::new_empty(),
|
|
render_area_with_margins: Rect::new_empty(),
|
|
tiles,
|
|
tile_viewbox: tiles::TileViewbox::new_with_interest(
|
|
&viewbox,
|
|
options.dpr_viewport_interest_area_threshold,
|
|
),
|
|
pending_tiles: PendingTiles::new(),
|
|
nested_fills: vec![],
|
|
nested_blurs: vec![],
|
|
cached_layer_blur: None,
|
|
nested_shadows: vec![],
|
|
show_grid: None,
|
|
empty_grid_frame_ids: HashSet::default(),
|
|
rulers: RulerState::default(),
|
|
focus_mode: FocusMode::new(),
|
|
include_filter: None,
|
|
viewer_render_root: None,
|
|
viewer_visible_set: None,
|
|
touched_ids: HashSet::default(),
|
|
ignore_nested_blurs: false,
|
|
preview_mode: false,
|
|
export_context: None,
|
|
cache_cleared_this_render: false,
|
|
current_tile_had_shapes: false,
|
|
interactive_target_seeded: false,
|
|
preserve_target_during_render: false,
|
|
backbuffer_crop_cache: HashMap::default(),
|
|
})
|
|
}
|
|
|
|
/// Combines every visible layer blur currently active (ancestors + shape)
|
|
/// into a single equivalent blur. Layer blur radii compound by adding their
|
|
/// variances (σ² = radius²), so we:
|
|
/// 1. Convert each blur radius into variance via `blur_variance`.
|
|
/// 2. Sum all variances.
|
|
/// 3. Convert the total variance back to a radius with `blur_from_variance`.
|
|
///
|
|
/// This keeps blur math consistent everywhere we need to merge blur sources.
|
|
fn combined_layer_blur(&mut self, shape_blur: Option<Blur>) -> Option<Blur> {
|
|
layer_blur::combined_layer_blur(&self.nested_blurs, &mut self.cached_layer_blur, shape_blur)
|
|
}
|
|
|
|
fn frame_clip_layer_blur(shape: &Shape) -> Option<Blur> {
|
|
layer_blur::frame_clip_layer_blur(shape)
|
|
}
|
|
|
|
/// Renders background blur effect directly to the given target surface.
|
|
/// Must be called BEFORE any save_layer for the shape's own opacity/blend,
|
|
/// so that the backdrop blur is independent of the shape's visual properties.
|
|
fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) {
|
|
background_blur::render_background_blur(self, shape, target_surface)
|
|
}
|
|
|
|
/// Runs `f` with `ignore_nested_blurs` temporarily forced to `true`.
|
|
/// Certain off-screen passes (e.g. shadow masks) must render shapes without
|
|
/// inheriting ancestor blur. This helper guarantees the flag is restored.
|
|
fn with_nested_blurs_suppressed<F, R>(&mut self, f: F) -> Result<R>
|
|
where
|
|
F: FnOnce(&mut RenderState) -> Result<R>,
|
|
{
|
|
let previous = self.ignore_nested_blurs;
|
|
self.ignore_nested_blurs = true;
|
|
let result = f(self)?;
|
|
self.ignore_nested_blurs = previous;
|
|
Ok(result)
|
|
}
|
|
|
|
pub fn fonts(&self) -> &FontStore {
|
|
&self.fonts
|
|
}
|
|
|
|
pub fn fonts_mut(&mut self) -> &mut FontStore {
|
|
&mut self.fonts
|
|
}
|
|
|
|
pub fn add_image(&mut self, id: Uuid, is_thumbnail: bool, image_data: &[u8]) -> Result<()> {
|
|
self.images.add(id, is_thumbnail, image_data)
|
|
}
|
|
|
|
/// Adds an image from an existing WebGL texture, avoiding re-decoding
|
|
pub fn add_image_from_gl_texture(
|
|
&mut self,
|
|
id: Uuid,
|
|
is_thumbnail: bool,
|
|
texture_id: u32,
|
|
width: i32,
|
|
height: i32,
|
|
) -> Result<()> {
|
|
self.images
|
|
.add_image_from_gl_texture(id, is_thumbnail, texture_id, width, height)
|
|
}
|
|
|
|
pub fn has_image(&self, id: &Uuid, is_thumbnail: bool) -> bool {
|
|
self.images.contains(id, is_thumbnail)
|
|
}
|
|
|
|
pub fn set_debug_flags(&mut self, debug: u32) {
|
|
self.options.flags = debug;
|
|
}
|
|
|
|
pub fn set_dpr(&mut self, dpr: f32) -> Result<()> {
|
|
// Only when this function returns true (it means the value
|
|
// was properly changed) the rest of the functions is called.
|
|
if self.options.set_dpr(dpr) {
|
|
self.tile_viewbox
|
|
.set_interest(self.options.dpr_viewport_interest_area_threshold);
|
|
self.resize(
|
|
self.viewbox.width().floor() as i32,
|
|
self.viewbox.height().floor() as i32,
|
|
)?;
|
|
self.fonts.set_scale_debug_font(dpr);
|
|
self.viewbox.set_dpr(dpr);
|
|
self.surfaces.set_dpr(dpr);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_antialias_threshold(&mut self, value: f32) {
|
|
self.options.set_antialias_threshold(value);
|
|
}
|
|
|
|
pub fn set_viewport_interest_area_threshold(&mut self, value: i32) {
|
|
// Only when this function returns true (it means the value
|
|
// was changed properly) the tile_viewbox.set_interest is called.
|
|
if self.options.set_viewport_interest_area_threshold(value) {
|
|
// The TileViewbox stores its own copy of `interest` (set at
|
|
// construction). Without propagating, options change wouldn't
|
|
// affect pending_tiles generation.
|
|
self.tile_viewbox
|
|
.set_interest(self.options.dpr_viewport_interest_area_threshold);
|
|
}
|
|
}
|
|
|
|
pub fn set_node_batch_threshold(&mut self, value: i32) {
|
|
self.options.set_node_batch_threshold(value);
|
|
}
|
|
|
|
pub fn set_max_blocking_time_ms(&mut self, value: i32) {
|
|
self.options.set_max_blocking_time_ms(value);
|
|
}
|
|
|
|
pub fn set_blur_downscale_threshold(&mut self, value: f32) {
|
|
self.options.set_blur_downscale_threshold(value);
|
|
}
|
|
|
|
pub fn set_background_color(&mut self, color: skia::Color) {
|
|
self.background_color = color;
|
|
}
|
|
|
|
pub fn set_preview_mode(&mut self, enabled: bool) {
|
|
self.preview_mode = enabled;
|
|
}
|
|
|
|
pub fn resize(&mut self, width: i32, height: i32) -> Result<()> {
|
|
let dpr_width = (width as f32 * self.options.dpr).floor() as i32;
|
|
let dpr_height = (height as f32 * self.options.dpr).floor() as i32;
|
|
self.surfaces.resize(dpr_width, dpr_height)?;
|
|
self.viewbox.set_wh(width as f32, height as f32);
|
|
self.tile_viewbox.update(&self.viewbox);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn flush(&mut self) {
|
|
self.surfaces.flush(SurfaceId::Backbuffer);
|
|
}
|
|
|
|
pub fn flush_and_submit(&mut self) {
|
|
self.surfaces.flush_and_submit(SurfaceId::Target);
|
|
}
|
|
|
|
/// Copy the clean (no UI overlay) Backbuffer to Target, draw UI/debug overlays
|
|
/// on top of Target, then present. Backbuffer is left clean so it can be reused
|
|
/// as-is across interactive-transform frames without stale overlay pixels.
|
|
pub fn present_frame(&mut self, tree: ShapesPoolRef) {
|
|
// Viewer masked passes render a partial scene onto a transparent backbuffer.
|
|
// SrcOver would keep pass-1 pixels wherever the backbuffer stays transparent.
|
|
if self.viewer_masked_pass() {
|
|
self.surfaces.clear_target(skia::Color::TRANSPARENT);
|
|
self.surfaces.copy_backbuffer_to_target_replace();
|
|
} else {
|
|
self.surfaces
|
|
.copy_backbuffer_to_target(self.background_color);
|
|
}
|
|
|
|
if self.options.is_debug_visible() {
|
|
debug::render(self);
|
|
}
|
|
if !self.preview_mode {
|
|
ui::render(self, tree);
|
|
}
|
|
debug::render_wasm_label(self);
|
|
self.surfaces.flush_and_submit(SurfaceId::Target);
|
|
}
|
|
|
|
/// Renders only the canvas background and UI surface (rulers/frame), without
|
|
/// rebuilding or drawing any shape tiles. Used to show the viewport frame
|
|
/// immediately before shape tiles are built (e.g., right after a DPR change).
|
|
pub fn render_ui_only(&mut self, tree: ShapesPoolRef) {
|
|
self.surfaces
|
|
.canvas(SurfaceId::Target)
|
|
.clear(self.background_color);
|
|
ui::render(self, tree);
|
|
self.flush_and_submit();
|
|
}
|
|
|
|
/// Blurs the Backbuffer into Target and draws the rulers sharp on top, for
|
|
/// capturing an already-blurred page-transition snapshot. `blur_radius` is in
|
|
/// CSS pixels, scaled by DPR to match the device-resolution capture.
|
|
pub fn render_blurred_snapshot(&mut self, tree: ShapesPoolRef, blur_radius: f32) {
|
|
let sigma = (blur_radius * self.options.dpr).max(0.0);
|
|
self.surfaces
|
|
.canvas(SurfaceId::Target)
|
|
.clear(self.background_color);
|
|
|
|
let mut paint = skia::Paint::default();
|
|
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
|
|
paint.set_image_filter(filter);
|
|
}
|
|
self.surfaces
|
|
.draw_into(SurfaceId::Backbuffer, SurfaceId::Target, Some(&paint));
|
|
ui::render(self, tree);
|
|
self.surfaces.flush_and_submit(SurfaceId::Target);
|
|
}
|
|
|
|
pub fn reset_canvas(&mut self) {
|
|
self.surfaces.reset(self.background_color);
|
|
self.surfaces.clear_backbuffer(self.background_color);
|
|
self.surfaces.clear_target(self.background_color);
|
|
}
|
|
|
|
/// Drop cached tile textures before a one-shot `render_sync_shape` render.
|
|
pub fn prepare_sync_shape_render(&mut self) {
|
|
self.surfaces.clear_tile_atlas();
|
|
self.surfaces.invalidate_tile_cache();
|
|
}
|
|
|
|
/// NOTE:
|
|
/// This is currently not being used, but it's set there for testing purposes on
|
|
/// upcoming tasks
|
|
pub fn render_loading_overlay(&mut self) {
|
|
let canvas = self.surfaces.canvas(SurfaceId::Backbuffer);
|
|
let skia::ISize { width, height } = canvas.base_layer_size();
|
|
|
|
canvas.save();
|
|
|
|
// Full-screen background rect
|
|
let rect = skia::Rect::from_wh(width as f32, height as f32);
|
|
let mut bg_paint = skia::Paint::default();
|
|
bg_paint.set_color(self.background_color);
|
|
bg_paint.set_style(skia::PaintStyle::Fill);
|
|
canvas.draw_rect(rect, &bg_paint);
|
|
|
|
// Centered "Loading…" text
|
|
let mut text_paint = skia::Paint::default();
|
|
text_paint.set_color(skia::Color::GRAY);
|
|
text_paint.set_anti_alias(true);
|
|
|
|
let font = self.fonts.debug_font();
|
|
// FIXME
|
|
let text = "Loading…";
|
|
let (text_width, _) = font.measure_str(text, None);
|
|
let metrics = font.metrics();
|
|
let text_height = metrics.1.cap_height;
|
|
let x = (width as f32 - text_width) / 2.0;
|
|
let y = (height as f32 + text_height) / 2.0;
|
|
canvas.draw_str(text, skia::Point::new(x, y), font, &text_paint);
|
|
|
|
canvas.restore();
|
|
self.flush_and_submit();
|
|
}
|
|
|
|
pub fn apply_render_to_final_canvas(&mut self) -> Result<()> {
|
|
cache::apply_render_to_final_canvas(self)
|
|
}
|
|
|
|
/// This function draws the "surface stack" into the specified "target" surface.
|
|
pub fn draw_shape_surface_stack_into(&mut self, shape: Option<&Shape>, target: SurfaceId) {
|
|
performance::begin_measure!("apply_drawing_to_render_canvas");
|
|
|
|
let paint = skia::Paint::default();
|
|
|
|
// Only draw surfaces that have content (dirty flag optimization)
|
|
if self.surfaces.is_dirty(SurfaceId::TextDropShadows) {
|
|
self.surfaces
|
|
.draw_into(SurfaceId::TextDropShadows, target, Some(&paint));
|
|
}
|
|
|
|
if self.surfaces.is_dirty(SurfaceId::Fills) {
|
|
self.surfaces
|
|
.draw_into(SurfaceId::Fills, target, Some(&paint));
|
|
}
|
|
|
|
let mut render_overlay_below_strokes = false;
|
|
if let Some(shape) = shape {
|
|
render_overlay_below_strokes = shape.has_fills();
|
|
}
|
|
|
|
if render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) {
|
|
self.surfaces
|
|
.draw_into(SurfaceId::InnerShadows, target, Some(&paint));
|
|
}
|
|
|
|
if self.surfaces.is_dirty(SurfaceId::Strokes) {
|
|
self.surfaces
|
|
.draw_into(SurfaceId::Strokes, target, Some(&paint));
|
|
}
|
|
|
|
if !render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) {
|
|
self.surfaces
|
|
.draw_into(SurfaceId::InnerShadows, target, Some(&paint));
|
|
}
|
|
|
|
if self.surfaces.is_dirty(SurfaceId::DropShadows) {
|
|
self.surfaces
|
|
.draw_into(SurfaceId::DropShadows, target, Some(&paint));
|
|
}
|
|
|
|
// Build mask of dirty surfaces that need clearing
|
|
let mut dirty_surfaces_to_clear = 0u32;
|
|
if self.surfaces.is_dirty(SurfaceId::Strokes) {
|
|
dirty_surfaces_to_clear |= SurfaceId::Strokes as u32;
|
|
}
|
|
if self.surfaces.is_dirty(SurfaceId::Fills) {
|
|
dirty_surfaces_to_clear |= SurfaceId::Fills as u32;
|
|
}
|
|
if self.surfaces.is_dirty(SurfaceId::InnerShadows) {
|
|
dirty_surfaces_to_clear |= SurfaceId::InnerShadows as u32;
|
|
}
|
|
if self.surfaces.is_dirty(SurfaceId::TextDropShadows) {
|
|
dirty_surfaces_to_clear |= SurfaceId::TextDropShadows as u32;
|
|
}
|
|
if self.surfaces.is_dirty(SurfaceId::DropShadows) {
|
|
dirty_surfaces_to_clear |= SurfaceId::DropShadows as u32;
|
|
}
|
|
|
|
if dirty_surfaces_to_clear != 0 {
|
|
self.surfaces.apply_mut(dirty_surfaces_to_clear, |s| {
|
|
s.canvas().clear(skia::Color::TRANSPARENT);
|
|
});
|
|
// Clear dirty flags for surfaces we just cleared
|
|
self.surfaces.clear_dirty(dirty_surfaces_to_clear);
|
|
}
|
|
}
|
|
|
|
pub fn clear_focus_mode(&mut self) {
|
|
self.focus_mode.clear();
|
|
}
|
|
|
|
pub fn set_focus_mode(&mut self, shapes: Vec<Uuid>) {
|
|
self.focus_mode.set_shapes(shapes);
|
|
}
|
|
|
|
pub fn clear_include_filter(&mut self) {
|
|
self.include_filter = None;
|
|
}
|
|
|
|
pub fn set_include_filter(&mut self, shapes: Vec<Uuid>) {
|
|
self.include_filter = Some(shapes.into_iter().collect());
|
|
}
|
|
|
|
fn viewer_masked_pass(&self) -> bool {
|
|
view_mode::viewer_masked_pass(&self.include_filter)
|
|
}
|
|
|
|
fn get_inherited_drop_shadows(&self) -> Option<Vec<skia_safe::Paint>> {
|
|
let drop_shadows: Vec<skia_safe::Paint> = self
|
|
.nested_shadows
|
|
.iter()
|
|
.flat_map(|shadows| shadows.iter())
|
|
.filter(|shadow| !shadow.hidden() && shadow.style() == crate::shapes::ShadowStyle::Drop)
|
|
.map(|shadow| {
|
|
let mut paint = skia_safe::Paint::default();
|
|
let filter = shadow.get_drop_shadow_filter();
|
|
paint.set_image_filter(filter);
|
|
paint
|
|
})
|
|
.collect();
|
|
|
|
if drop_shadows.is_empty() {
|
|
None
|
|
} else {
|
|
Some(drop_shadows)
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn render_shape(
|
|
&mut self,
|
|
shape: &Shape,
|
|
clip_bounds: Option<ClipStack>,
|
|
fills_surface_id: SurfaceId,
|
|
strokes_surface_id: SurfaceId,
|
|
innershadows_surface_id: SurfaceId,
|
|
text_drop_shadows_surface_id: SurfaceId,
|
|
apply_to_current_surface: bool,
|
|
offset: Option<(f32, f32)>,
|
|
parent_shadows: Option<Vec<skia_safe::Paint>>,
|
|
outset: Option<f32>,
|
|
target_surface: SurfaceId,
|
|
) -> Result<()> {
|
|
#[cfg(feature = "stats")]
|
|
self.stats.count(shape.id);
|
|
|
|
let surface_ids = fills_surface_id as u32
|
|
| strokes_surface_id as u32
|
|
| innershadows_surface_id as u32
|
|
| text_drop_shadows_surface_id as u32;
|
|
|
|
// Only save canvas state if we have clipping or transforms
|
|
// For simple shapes without clipping, skip expensive save/restore
|
|
let needs_save =
|
|
clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity();
|
|
|
|
if needs_save {
|
|
self.surfaces.apply_mut(surface_ids, |s| {
|
|
s.canvas().save();
|
|
});
|
|
}
|
|
let fast_mode = self.options.is_fast_mode();
|
|
// Skip anti-aliasing entirely during fast_mode (interactive
|
|
// gestures + pan/zoom). AA edge sampling is per-pixel and adds
|
|
// up across many shapes; reverts to full quality on commit.
|
|
let antialias = !fast_mode
|
|
&& shape.should_use_antialias(self.get_scale_fast(), self.options.antialias_threshold);
|
|
let skip_effects = fast_mode;
|
|
|
|
let has_nested_fills = self
|
|
.nested_fills
|
|
.last()
|
|
.is_some_and(|fills| !fills.is_empty());
|
|
let has_inherited_blur = !self.ignore_nested_blurs
|
|
&& self.nested_blurs.iter().flatten().any(|blur| {
|
|
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
|
|
});
|
|
let can_render_directly = apply_to_current_surface
|
|
&& clip_bounds.is_none()
|
|
&& offset.is_none()
|
|
&& parent_shadows.is_none()
|
|
&& !shape.needs_layer()
|
|
&& shape.blur.is_none()
|
|
&& shape.background_blur.is_none()
|
|
&& !has_inherited_blur
|
|
&& shape.shadows.is_empty()
|
|
&& shape.transform.is_identity()
|
|
&& matches!(
|
|
shape.shape_type,
|
|
Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_)
|
|
)
|
|
&& !(shape.fills.is_empty() && has_nested_fills)
|
|
&& !shape
|
|
.svg_attrs
|
|
.as_ref()
|
|
.is_some_and(|attrs| attrs.fill_none)
|
|
&& target_surface != SurfaceId::Export;
|
|
|
|
if can_render_directly {
|
|
let scale = self.get_scale_fast();
|
|
let translation = self
|
|
.surfaces
|
|
.get_render_context_translation(self.render_area, scale);
|
|
|
|
self.surfaces.apply_mut(target_surface as u32, |s| {
|
|
let canvas = s.canvas();
|
|
canvas.save();
|
|
canvas.scale((scale, scale));
|
|
canvas.translate(translation);
|
|
});
|
|
|
|
fills::render(self, shape, &shape.fills, antialias, target_surface, None)?;
|
|
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
|
|
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
|
|
strokes::render(
|
|
self,
|
|
shape,
|
|
&visible_strokes,
|
|
Some(target_surface),
|
|
antialias,
|
|
outset,
|
|
)?;
|
|
|
|
self.surfaces.apply_mut(target_surface as u32, |s| {
|
|
s.canvas().restore();
|
|
});
|
|
|
|
if self.options.is_debug_visible() {
|
|
let shape_selrect_bounds = self.get_shape_selrect_bounds(shape);
|
|
debug::render_debug_shape(self, Some(shape_selrect_bounds), None);
|
|
}
|
|
|
|
if needs_save {
|
|
self.surfaces.apply_mut(surface_ids, |s| {
|
|
s.canvas().restore();
|
|
});
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// set clipping
|
|
if let Some(clips) = clip_bounds.as_ref() {
|
|
let scale = self.get_scale_fast();
|
|
for (mut bounds, corners, transform, _inverse_transform) in clips.iter() {
|
|
self.surfaces.apply_mut(surface_ids, |s| {
|
|
s.canvas().concat(transform);
|
|
});
|
|
|
|
// Outset clip by ~0.5 to include edge pixels that
|
|
// aliased clip misclassifies as outside (causing artifacts).
|
|
let outset = 0.5 / scale;
|
|
bounds.outset((outset, outset));
|
|
|
|
// Hard clip edge (antialias = false) to avoid alpha seam when clipping
|
|
// semi-transparent content larger than the frame.
|
|
if let Some(corners) = corners {
|
|
let rrect = RRect::new_rect_radii(bounds, corners);
|
|
self.surfaces.apply_mut(surface_ids, |s| {
|
|
s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false);
|
|
});
|
|
} else {
|
|
self.surfaces.apply_mut(surface_ids, |s| {
|
|
s.canvas().clip_rect(bounds, skia::ClipOp::Intersect, false);
|
|
});
|
|
}
|
|
|
|
// This renders a red line around clipped
|
|
// shapes (frames).
|
|
if self.options.is_debug_visible() {
|
|
let mut paint = skia::Paint::default();
|
|
paint.set_style(skia::PaintStyle::Stroke);
|
|
paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
|
|
paint.set_stroke_width(4.);
|
|
self.surfaces
|
|
.canvas(fills_surface_id)
|
|
.draw_rect(bounds, &paint);
|
|
}
|
|
|
|
// Uncomment to debug the render_position_data
|
|
// if let Type::Text(text_content) = &shape.shape_type {
|
|
// text::render_position_data(self, fills_surface_id, &shape, text_content);
|
|
// }
|
|
|
|
self.surfaces.apply_mut(surface_ids, |s| {
|
|
s.canvas()
|
|
.concat(&transform.invert().unwrap_or(Matrix::default()));
|
|
});
|
|
}
|
|
}
|
|
|
|
// We don't want to change the value in the global state
|
|
let mut shape: Cow<Shape> = Cow::Borrowed(shape);
|
|
let shape = shape.to_mut();
|
|
|
|
// Background blur is stored separately (shape.background_blur) and is
|
|
// rendered before the save_layer in render_background_blur(), so here
|
|
// shape.blur only ever holds a layer blur.
|
|
|
|
let frame_has_blur = Self::frame_clip_layer_blur(shape).is_some();
|
|
let shape_has_blur = shape.blur.is_some();
|
|
|
|
if self.ignore_nested_blurs {
|
|
if frame_has_blur && shape_has_blur {
|
|
shape.set_blur(None);
|
|
}
|
|
} else if !frame_has_blur {
|
|
if let Some(blur) = self.combined_layer_blur(shape.blur) {
|
|
shape.set_blur(Some(blur));
|
|
}
|
|
} else if shape_has_blur {
|
|
shape.set_blur(None);
|
|
}
|
|
if skip_effects {
|
|
shape.set_blur(None);
|
|
}
|
|
|
|
// For non-text, non-SVG shapes in the normal rendering path, apply blur
|
|
// via a single save_layer on each render surface
|
|
// Clip correctness is preserved
|
|
let blur_sigma_for_layers: Option<f32> = if !skip_effects
|
|
&& apply_to_current_surface
|
|
&& fills_surface_id == SurfaceId::Fills
|
|
&& !matches!(shape.shape_type, Type::Text(_))
|
|
&& !matches!(shape.shape_type, Type::SVGRaw(_))
|
|
{
|
|
if let Some(blur) = shape.blur.filter(|b| !b.hidden) {
|
|
shape.set_blur(None);
|
|
Some(blur.sigma())
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let center = shape.center();
|
|
let mut matrix = shape.transform;
|
|
matrix.post_translate(center);
|
|
matrix.pre_translate(-center);
|
|
|
|
// Apply the additional transformation matrix if exists
|
|
if let Some(offset) = offset {
|
|
matrix.pre_translate(offset);
|
|
}
|
|
|
|
match &shape.shape_type {
|
|
Type::SVGRaw(sr) => {
|
|
if let Some(svg_transform) = shape.svg_transform() {
|
|
matrix.pre_concat(&svg_transform);
|
|
}
|
|
|
|
self.surfaces
|
|
.canvas_and_mark_dirty(fills_surface_id)
|
|
.concat(&matrix);
|
|
|
|
if let Some(svg) = shape.svg.as_ref() {
|
|
svg.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
|
|
} else {
|
|
let font_manager = skia::FontMgr::from(self.fonts().font_provider().clone());
|
|
let dom_result = skia::svg::Dom::from_str(&sr.content, font_manager);
|
|
match dom_result {
|
|
Ok(dom) => {
|
|
dom.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
|
|
shape.set_svg(dom);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error parsing SVG. Error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Type::Text(stored_text_content) => {
|
|
self.surfaces.apply_mut(surface_ids, |s| {
|
|
s.canvas().concat(&matrix);
|
|
});
|
|
|
|
// Skip the paragraph-cloning `new_bounds` when shape size is unchanged.
|
|
let selrect = shape.selrect();
|
|
let stored_bounds = stored_text_content.bounds();
|
|
let bounds_match = (stored_bounds.width() - selrect.width()).abs() < 0.01
|
|
&& (stored_bounds.height() - selrect.height()).abs() < 0.01;
|
|
let rebound_text_content = if bounds_match {
|
|
None
|
|
} else {
|
|
Some(stored_text_content.new_bounds(selrect))
|
|
};
|
|
let text_content: &TextContent =
|
|
rebound_text_content.as_ref().unwrap_or(stored_text_content);
|
|
let count_inner_strokes = shape.count_visible_inner_strokes();
|
|
// Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge.
|
|
let text_fill_inset =
|
|
(count_inner_strokes > 0).then(|| 1.0 / self.get_scale_fast());
|
|
let text_stroke_blur_outset =
|
|
Stroke::max_bounds_width(shape.visible_strokes(), false);
|
|
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
|
let stroke_kinds: Vec<StrokeKind> =
|
|
shape.visible_strokes().rev().map(|s| s.kind).collect();
|
|
let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape
|
|
.visible_strokes()
|
|
.rev()
|
|
.map(|stroke| {
|
|
text::stroke_paragraph_builder_group_from_text(
|
|
text_content,
|
|
stroke,
|
|
&shape.selrect(),
|
|
None,
|
|
)
|
|
})
|
|
.unzip();
|
|
if skip_effects {
|
|
// Fast path: render fills and strokes only (skip shadows/blur).
|
|
text::render(
|
|
Some(self),
|
|
None,
|
|
shape,
|
|
&mut paragraph_builders,
|
|
Some(fills_surface_id),
|
|
None,
|
|
None,
|
|
text_fill_inset,
|
|
None,
|
|
)?;
|
|
|
|
for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list
|
|
.iter_mut()
|
|
.zip(stroke_opacities.iter())
|
|
.enumerate()
|
|
{
|
|
if stroke_kinds[i] == StrokeKind::Inner {
|
|
let mut mask_builders = text_content.paragraph_builder_group_opaque();
|
|
let mut fill_builders =
|
|
text_content.paragraph_builder_group_from_text(None);
|
|
text::render_inner_stroke(
|
|
Some(self),
|
|
None,
|
|
shape,
|
|
&mut mask_builders,
|
|
stroke_paragraphs,
|
|
&mut fill_builders,
|
|
Some(strokes_surface_id),
|
|
None,
|
|
text_stroke_blur_outset,
|
|
*layer_opacity,
|
|
)?;
|
|
} else {
|
|
text::render_with_bounds_outset(
|
|
Some(self),
|
|
None,
|
|
shape,
|
|
stroke_paragraphs,
|
|
Some(strokes_surface_id),
|
|
None,
|
|
None,
|
|
text_stroke_blur_outset,
|
|
None,
|
|
*layer_opacity,
|
|
)?;
|
|
}
|
|
}
|
|
} else {
|
|
let mut drop_shadows = shape.drop_shadow_paints();
|
|
|
|
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
|
|
drop_shadows.extend(inherited_shadows);
|
|
}
|
|
|
|
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();
|
|
|
|
if let Some(parent_shadows) = parent_shadows {
|
|
if !shape.has_visible_strokes() {
|
|
for shadow in parent_shadows {
|
|
text::render(
|
|
Some(self),
|
|
None,
|
|
shape,
|
|
&mut paragraphs_with_shadows,
|
|
text_drop_shadows_surface_id.into(),
|
|
Some(&shadow),
|
|
blur_filter.as_ref(),
|
|
None,
|
|
None,
|
|
)?;
|
|
}
|
|
} else {
|
|
shadows::render_text_shadows(
|
|
self,
|
|
shape,
|
|
&mut paragraphs_with_shadows,
|
|
&mut stroke_paragraphs_with_shadows_list,
|
|
text_drop_shadows_surface_id.into(),
|
|
&parent_shadows,
|
|
&blur_filter,
|
|
&stroke_kinds,
|
|
text_content,
|
|
)?;
|
|
}
|
|
} else {
|
|
// 1. Text drop shadows
|
|
if !shape.has_visible_strokes() {
|
|
for shadow in &drop_shadows {
|
|
text::render(
|
|
Some(self),
|
|
None,
|
|
shape,
|
|
&mut paragraphs_with_shadows,
|
|
text_drop_shadows_surface_id.into(),
|
|
Some(shadow),
|
|
blur_filter.as_ref(),
|
|
None,
|
|
None,
|
|
)?;
|
|
}
|
|
}
|
|
|
|
// 2. Text fills
|
|
text::render(
|
|
Some(self),
|
|
None,
|
|
shape,
|
|
&mut paragraph_builders,
|
|
Some(fills_surface_id),
|
|
None,
|
|
blur_filter.as_ref(),
|
|
text_fill_inset,
|
|
None,
|
|
)?;
|
|
|
|
// 3. Stroke drop shadows
|
|
shadows::render_text_shadows(
|
|
self,
|
|
shape,
|
|
&mut paragraphs_with_shadows,
|
|
&mut stroke_paragraphs_with_shadows_list,
|
|
text_drop_shadows_surface_id.into(),
|
|
&drop_shadows,
|
|
&blur_filter,
|
|
&stroke_kinds,
|
|
text_content,
|
|
)?;
|
|
|
|
// 4. Stroke fills
|
|
for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list
|
|
.iter_mut()
|
|
.zip(stroke_opacities.iter())
|
|
.enumerate()
|
|
{
|
|
if stroke_kinds[i] == StrokeKind::Inner {
|
|
let mut mask_builders =
|
|
text_content.paragraph_builder_group_opaque();
|
|
let mut fill_builders =
|
|
text_content.paragraph_builder_group_from_text(None);
|
|
text::render_inner_stroke(
|
|
Some(self),
|
|
None,
|
|
shape,
|
|
&mut mask_builders,
|
|
stroke_paragraphs,
|
|
&mut fill_builders,
|
|
Some(strokes_surface_id),
|
|
blur_filter.as_ref(),
|
|
text_stroke_blur_outset,
|
|
*layer_opacity,
|
|
)?;
|
|
} else {
|
|
text::render_with_bounds_outset(
|
|
Some(self),
|
|
None,
|
|
shape,
|
|
stroke_paragraphs,
|
|
Some(strokes_surface_id),
|
|
None,
|
|
blur_filter.as_ref(),
|
|
text_stroke_blur_outset,
|
|
None,
|
|
*layer_opacity,
|
|
)?;
|
|
}
|
|
}
|
|
|
|
// 5. Stroke inner shadows
|
|
shadows::render_text_shadows(
|
|
self,
|
|
shape,
|
|
&mut paragraphs_with_shadows,
|
|
&mut stroke_paragraphs_with_shadows_list,
|
|
Some(innershadows_surface_id),
|
|
&inner_shadows,
|
|
&blur_filter,
|
|
&stroke_kinds,
|
|
text_content,
|
|
)?;
|
|
|
|
// 6. Fill Inner shadows
|
|
if !shape.has_visible_strokes() {
|
|
for shadow in &inner_shadows {
|
|
text::render(
|
|
Some(self),
|
|
None,
|
|
shape,
|
|
&mut paragraphs_with_shadows,
|
|
Some(innershadows_surface_id),
|
|
Some(shadow),
|
|
blur_filter.as_ref(),
|
|
None,
|
|
None,
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
self.surfaces.apply_mut(surface_ids, |s| {
|
|
s.canvas().concat(&matrix);
|
|
});
|
|
|
|
// Wrap ALL fill/stroke/shadow rendering so a single GPU blur pass calls
|
|
let blur_filter_for_layers: Option<skia::ImageFilter> = blur_sigma_for_layers
|
|
.and_then(|sigma| skia::image_filters::blur((sigma, sigma), None, None, None));
|
|
if let Some(ref filter) = blur_filter_for_layers {
|
|
let mut layer_paint = skia::Paint::default();
|
|
layer_paint.set_image_filter(filter.clone());
|
|
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint);
|
|
self.surfaces
|
|
.canvas(fills_surface_id)
|
|
.save_layer(&layer_rec);
|
|
self.surfaces
|
|
.canvas(strokes_surface_id)
|
|
.save_layer(&layer_rec);
|
|
self.surfaces
|
|
.canvas(innershadows_surface_id)
|
|
.save_layer(&layer_rec);
|
|
}
|
|
|
|
let shape = &shape;
|
|
|
|
if shape.fills.is_empty()
|
|
&& !matches!(shape.shape_type, Type::Group(_))
|
|
&& !matches!(shape.shape_type, Type::Frame(_))
|
|
&& !shape
|
|
.svg_attrs
|
|
.as_ref()
|
|
.is_some_and(|attrs| attrs.fill_none)
|
|
{
|
|
if let Some(fills_to_render) = self.nested_fills.last() {
|
|
let fills_to_render = fills_to_render.clone();
|
|
fills::render(
|
|
self,
|
|
shape,
|
|
&fills_to_render,
|
|
antialias,
|
|
fills_surface_id,
|
|
outset,
|
|
)?;
|
|
}
|
|
} else {
|
|
fills::render(
|
|
self,
|
|
shape,
|
|
&shape.fills,
|
|
antialias,
|
|
fills_surface_id,
|
|
outset,
|
|
)?;
|
|
}
|
|
|
|
// Skip stroke rendering for clipped frames - they are drawn in render_shape_exit
|
|
// over the children. Drawing twice would cause incorrect opacity blending.
|
|
let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content;
|
|
if !skip_strokes {
|
|
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
|
|
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
|
|
strokes::render(
|
|
self,
|
|
shape,
|
|
&visible_strokes,
|
|
Some(strokes_surface_id),
|
|
antialias,
|
|
outset,
|
|
)?;
|
|
if !skip_effects {
|
|
for stroke in &visible_strokes {
|
|
shadows::render_stroke_inner_shadows(
|
|
self,
|
|
shape,
|
|
stroke,
|
|
antialias,
|
|
innershadows_surface_id,
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !skip_effects {
|
|
shadows::render_fill_inner_shadows(
|
|
self,
|
|
shape,
|
|
antialias,
|
|
innershadows_surface_id,
|
|
);
|
|
}
|
|
|
|
if blur_filter_for_layers.is_some() {
|
|
self.surfaces.canvas(innershadows_surface_id).restore();
|
|
self.surfaces.canvas(strokes_surface_id).restore();
|
|
self.surfaces.canvas(fills_surface_id).restore();
|
|
}
|
|
}
|
|
};
|
|
|
|
if self.options.is_debug_visible() {
|
|
let shape_selrect_bounds = self.get_shape_selrect_bounds(shape);
|
|
debug::render_debug_shape(self, Some(shape_selrect_bounds), None);
|
|
}
|
|
|
|
if apply_to_current_surface {
|
|
self.draw_shape_surface_stack_into(Some(shape), target_surface);
|
|
}
|
|
|
|
// Only restore if we saved (optimization for simple shapes)
|
|
if needs_save {
|
|
self.surfaces.apply_mut(surface_ids, |s| {
|
|
s.canvas().restore();
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn update_render_context(&mut self, tile: tiles::Tile) {
|
|
self.current_tile = Some(tile);
|
|
let scale = self.get_scale();
|
|
self.render_area = tiles::get_tile_rect(tile, 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.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) {
|
|
drag_crop::rebuild_backbuffer_crop_cache(self, tree)
|
|
}
|
|
|
|
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
|
|
cache::render_from_cache(self, shapes)
|
|
}
|
|
|
|
/// Render a preview of the shapes during loading.
|
|
/// This rebuilds tiles for touched shapes and renders synchronously.
|
|
pub fn render_preview(&mut self, tree: ShapesPoolRef, timestamp: i32) -> Result<()> {
|
|
let _start = performance::begin_timed_log!("render_preview");
|
|
performance::begin_measure!("render_preview");
|
|
|
|
// Enable fast_mode during preview to skip expensive effects (blur, shadows).
|
|
// Restore the previous state afterward so the final render is full quality.
|
|
let current_fast_mode = self.options.is_fast_mode();
|
|
self.options.set_fast_mode(true);
|
|
|
|
// Skip tile rebuilding during preview - we'll do it at the end
|
|
// Just rebuild tiles for touched shapes and render synchronously
|
|
self.rebuild_touched_tiles(tree);
|
|
|
|
// Use the sync render path
|
|
self.start_render_loop(None, tree, timestamp, true)?;
|
|
|
|
self.options.set_fast_mode(current_fast_mode);
|
|
|
|
performance::end_measure!("render_preview");
|
|
performance::end_timed_log!("render_preview", _start);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Clears all the necessary vecs and hashmaps.
|
|
/// Also garbage collects surfaces.
|
|
fn clear(&mut self, tree: ShapesPoolRef) {
|
|
#[cfg(feature = "stats")]
|
|
self.stats.clear();
|
|
|
|
self.surfaces.gc();
|
|
|
|
self.pending_nodes.clear();
|
|
self.pending_nodes.reserve(tree.len());
|
|
|
|
// Clear nested state stacks to avoid residual fills/blurs from previous renders
|
|
// being incorrectly applied to new frames
|
|
self.nested_fills.clear();
|
|
self.nested_blurs.clear();
|
|
self.cached_layer_blur = None;
|
|
self.nested_shadows.clear();
|
|
|
|
// reorder by distance to the center.
|
|
self.current_tile = None;
|
|
|
|
self.empty_grid_frame_ids.clear();
|
|
if self.show_grid.is_some() {
|
|
for shape in tree.iter() {
|
|
if shape.id.is_nil() || !shape.children.is_empty() {
|
|
continue;
|
|
}
|
|
if let Type::Frame(frame) = &shape.shape_type {
|
|
if matches!(frame.layout, Some(Layout::GridLayout(_, _))) && !shape.deleted() {
|
|
self.empty_grid_frame_ids.insert(shape.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn start_render_loop(
|
|
&mut self,
|
|
base_object: Option<&Uuid>,
|
|
tree: ShapesPoolRef,
|
|
timestamp: i32,
|
|
sync_render: bool,
|
|
) -> Result<FrameType> {
|
|
self.clear(tree);
|
|
view_mode::precompute_viewer_visible_set(self, tree);
|
|
|
|
let _start = performance::begin_timed_log!("start_render_loop");
|
|
let scale = self.get_scale();
|
|
|
|
self.tile_viewbox.update(&self.viewbox);
|
|
self.focus_mode.reset();
|
|
|
|
performance::begin_measure!("render");
|
|
performance::begin_measure!("start_render_loop");
|
|
|
|
// Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom)
|
|
// to clamp atlas updates. This prevents zoom-out tiles from forcing atlas
|
|
// growth far beyond real content.
|
|
let doc_bounds = self.compute_document_bounds(base_object, tree);
|
|
self.surfaces.atlas.set_doc_bounds(doc_bounds);
|
|
|
|
self.cache_cleared_this_render = false;
|
|
let preserve_target = self.preserve_target_during_render;
|
|
self.preserve_target_during_render = false;
|
|
|
|
if self.options.is_interactive_transform() {
|
|
// Keep `Target` as the previous frame and overwrite only the tiles
|
|
// that changed. This avoids clearing + redrawing an atlas backdrop
|
|
// every rAF during drag (a common source of GPU work/stalls).
|
|
self.surfaces
|
|
.reset_interactive_transform(self.background_color);
|
|
if !self.interactive_target_seeded {
|
|
// Seed from the last presented frame; this is stable even when
|
|
// fast_mode skips cache updates and regardless of atlas coverage.
|
|
self.interactive_target_seeded = true;
|
|
}
|
|
} else if preserve_target || self.zoom_changed() {
|
|
// Shape updates or zoom-end: keep the last presented frame on screen
|
|
// while tiles are re-rendered asynchronously. During zoom the
|
|
// preview from render_from_cache stays visible until the full-
|
|
// quality pass completes.
|
|
self.surfaces
|
|
.reset_interactive_transform(self.background_color);
|
|
self.surfaces.seed_backbuffer_from_target();
|
|
self.interactive_target_seeded = false;
|
|
} else {
|
|
self.reset_canvas();
|
|
self.interactive_target_seeded = false;
|
|
// Paint rulers/frame now so they survive the progressive frames
|
|
// instead of blanking until the first full `present_frame`.
|
|
// Skip on sync renders (thumbnails/exports)
|
|
if !sync_render {
|
|
ui::render(self, tree);
|
|
self.flush_and_submit();
|
|
}
|
|
}
|
|
|
|
// Viewer fixed-scroll passes reuse the same WASM context; `reset` does not
|
|
// clear Backbuffer, so pass 2 would otherwise keep pass-1 pixels in regions
|
|
// that render no shapes for the current mask. Target is cleared in present_frame.
|
|
if self.viewer_masked_pass() {
|
|
view_mode::reset_viewer_masked_surfaces(self);
|
|
}
|
|
|
|
let surface_ids = SurfaceId::Strokes as u32
|
|
| SurfaceId::Fills as u32
|
|
| SurfaceId::InnerShadows as u32
|
|
| SurfaceId::TextDropShadows as u32;
|
|
|
|
self.surfaces.apply_mut(surface_ids, |s| {
|
|
s.canvas().scale((scale, scale));
|
|
});
|
|
|
|
self.surfaces.resize_cache_from_viewbox(
|
|
&self.viewbox,
|
|
&self.cached_viewbox,
|
|
self.options.dpr_viewport_interest_area_threshold,
|
|
)?;
|
|
|
|
// FIXME - review debug
|
|
// debug::render_debug_tiles_for_viewbox(self);
|
|
|
|
let _tile_start = performance::begin_timed_log!("tile_cache_update");
|
|
|
|
performance::begin_measure!("tile_cache");
|
|
let only_visible = self.options.is_interactive_transform();
|
|
self.pending_tiles
|
|
.update(&self.tile_viewbox, &self.surfaces, only_visible);
|
|
performance::end_measure!("tile_cache");
|
|
|
|
performance::end_timed_log!("tile_cache_update", _tile_start);
|
|
|
|
self.draw_shape_surface_stack_into(None, SurfaceId::Current);
|
|
|
|
#[allow(unused)]
|
|
let mut frame_type = FrameType::None;
|
|
if sync_render {
|
|
frame_type = self.render_shape_tree_sync(base_object, tree, timestamp)?;
|
|
} 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();
|
|
frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?;
|
|
|
|
// This is an option to debug frames.
|
|
if self.options.capture_frames > 0 {
|
|
self.options.capture_frames -= 1;
|
|
}
|
|
|
|
// Update cached_viewbox after visible tiles render
|
|
// synchronously so that render_from_cache uses the correct
|
|
// zoom ratio even if interest-area tiles are still rendering
|
|
// asynchronously. Without this, panning right after a zoom
|
|
// would keep scaling the Cache surface by the old zoom ratio
|
|
// (pixelated/wrong-scale tiles) because the async render
|
|
// never completes — each pan frame cancels it.
|
|
if self.cache_cleared_this_render {
|
|
self.cached_viewbox = self.viewbox;
|
|
}
|
|
}
|
|
|
|
performance::end_measure!("start_render_loop");
|
|
performance::end_timed_log!("start_render_loop", _start);
|
|
Ok(frame_type)
|
|
}
|
|
|
|
fn compute_document_bounds(
|
|
&mut self,
|
|
base_object: Option<&Uuid>,
|
|
tree: ShapesPoolRef,
|
|
) -> Option<skia::Rect> {
|
|
let ids: Vec<Uuid> = if let Some(id) = base_object {
|
|
vec![*id]
|
|
} else {
|
|
let root = tree.get(&Uuid::nil())?;
|
|
root.children_ids(false)
|
|
};
|
|
|
|
let mut acc: Option<skia::Rect> = None;
|
|
for id in ids.iter() {
|
|
let Some(shape) = tree.get(id) else {
|
|
continue;
|
|
};
|
|
let r = shape.extrect(tree, 1.0);
|
|
if r.is_empty() {
|
|
continue;
|
|
}
|
|
acc = Some(if let Some(mut a) = acc {
|
|
a.join(r);
|
|
a
|
|
} else {
|
|
r
|
|
});
|
|
}
|
|
acc
|
|
}
|
|
|
|
pub fn continue_render_loop(
|
|
&mut self,
|
|
base_object: Option<&Uuid>,
|
|
tree: ShapesPoolRef,
|
|
timestamp: i32,
|
|
allow_stop: bool,
|
|
) -> Result<FrameType> {
|
|
performance::begin_measure!("continue_render_loop");
|
|
let frame_type =
|
|
self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?;
|
|
|
|
// `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not
|
|
// presented (only flushed), so defer composition to the final frame and
|
|
// avoid re-snapshotting up to 4096² on every rAF during async tile work.
|
|
if !self.options.is_interactive_transform() && matches!(frame_type, FrameType::Full) {
|
|
self.surfaces.draw_tile_atlas_to_backbuffer(
|
|
&self.viewbox,
|
|
&self.tile_viewbox,
|
|
self.background_color,
|
|
);
|
|
}
|
|
|
|
match frame_type {
|
|
FrameType::None => {
|
|
panic!("FrameType::None");
|
|
}
|
|
FrameType::Partial => {
|
|
// Partial frame: just flush GPU work. The display shows the last
|
|
// fully submitted frame; no need to copy or draw UI overlays here.
|
|
self.flush();
|
|
}
|
|
FrameType::Full => {
|
|
// A full-quality frame is now complete. Rebuild the per-shape crop
|
|
// cache from the clean Backbuffer (no UI overlay yet) so that
|
|
// interactive drag backgrounds don't include the grid overlay.
|
|
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
|
self.rebuild_backbuffer_crop_cache(tree);
|
|
}
|
|
// present_frame: copy clean Backbuffer → Target, draw UI/debug
|
|
// overlays on Target only, then flush. Backbuffer stays overlay-free.
|
|
self.present_frame(tree);
|
|
wapi::notify_tiles_render_complete!();
|
|
performance::end_measure!("render");
|
|
}
|
|
}
|
|
performance::end_measure!("continue_render_loop");
|
|
Ok(frame_type)
|
|
}
|
|
|
|
pub fn render_shape_tree_sync(
|
|
&mut self,
|
|
base_object: Option<&Uuid>,
|
|
tree: ShapesPoolRef,
|
|
timestamp: i32,
|
|
) -> Result<FrameType> {
|
|
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
|
|
|
|
// Same composition as `continue_render_loop` for full frames: snapshot only the
|
|
// drawable tile rect into the atlas (no blur-margin overlap), then blit once.
|
|
if !self.viewer_masked_pass() {
|
|
self.surfaces.draw_tile_atlas_to_backbuffer(
|
|
&self.viewbox,
|
|
&self.tile_viewbox,
|
|
self.background_color,
|
|
);
|
|
}
|
|
|
|
let saved_preview_mode = self.preview_mode;
|
|
self.preview_mode = true;
|
|
self.present_frame(tree);
|
|
self.preview_mode = saved_preview_mode;
|
|
Ok(FrameType::Full)
|
|
}
|
|
|
|
pub fn render_shape_pixels(
|
|
&mut self,
|
|
id: &Uuid,
|
|
tree: ShapesPoolRef,
|
|
scale: f32,
|
|
timestamp: i32,
|
|
) -> Result<(Vec<u8>, i32, i32)> {
|
|
export::render_shape_pixels(self, id, tree, scale, timestamp)
|
|
}
|
|
|
|
#[inline]
|
|
pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool {
|
|
if iteration % self.options.node_batch_threshold != 0 {
|
|
return false;
|
|
}
|
|
if performance::get_time() - timestamp <= self.options.max_blocking_time_ms {
|
|
return false;
|
|
}
|
|
|
|
// During interactive shape transforms we must complete every
|
|
// visible tile in a single rAF so the user never sees tiles
|
|
// popping in sequentially. Only yield once all visible work is
|
|
// done and we are processing the interest-area pre-render.
|
|
if self.options.is_interactive_transform() {
|
|
if let Some(tile) = self.current_tile {
|
|
if self.tile_viewbox.is_visible(&tile) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
#[inline]
|
|
fn clip_target_surface_to_stack(
|
|
&mut self,
|
|
clips: &ClipStack,
|
|
target_surface: SurfaceId,
|
|
scale: f32,
|
|
antialias: bool,
|
|
) {
|
|
let translation = self
|
|
.surfaces
|
|
.get_render_context_translation(self.render_area, scale);
|
|
|
|
for (bounds, corners, transform, inverse_transform) in clips.iter() {
|
|
let mut total_matrix = Matrix::new_identity();
|
|
if target_surface == SurfaceId::Export {
|
|
let Some((export_rect, export_scale)) = self.export_context else {
|
|
continue;
|
|
};
|
|
total_matrix.pre_scale((export_scale, export_scale), None);
|
|
total_matrix.pre_translate((-export_rect.x(), -export_rect.y()));
|
|
} else {
|
|
total_matrix.pre_scale((scale, scale), None);
|
|
total_matrix.pre_translate((translation.0, translation.1));
|
|
}
|
|
total_matrix.pre_concat(transform);
|
|
|
|
let canvas = self.surfaces.canvas(target_surface);
|
|
canvas.concat(&total_matrix);
|
|
if let Some(corners) = corners {
|
|
let rrect = RRect::new_rect_radii(*bounds, corners);
|
|
canvas.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
|
|
} else {
|
|
canvas.clip_rect(*bounds, skia::ClipOp::Intersect, antialias);
|
|
}
|
|
self.surfaces
|
|
.canvas(target_surface)
|
|
.concat(inverse_transform);
|
|
}
|
|
}
|
|
|
|
pub fn render_shape_enter(
|
|
&mut self,
|
|
element: &Shape,
|
|
mask: bool,
|
|
clip_bounds: Option<&ClipStack>,
|
|
target_surface: SurfaceId,
|
|
) {
|
|
enter_exit::render_shape_enter(self, element, mask, clip_bounds, target_surface)
|
|
}
|
|
|
|
#[inline]
|
|
pub fn render_shape_exit(
|
|
&mut self,
|
|
element: &Shape,
|
|
visited_mask: bool,
|
|
clip_bounds: Option<ClipStack>,
|
|
target_surface: SurfaceId,
|
|
) -> Result<()> {
|
|
enter_exit::render_shape_exit(self, element, visited_mask, clip_bounds, target_surface)
|
|
}
|
|
|
|
pub fn get_current_tile_bounds(&mut self) -> Result<Rect> {
|
|
let tile = self
|
|
.current_tile
|
|
.ok_or(Error::CriticalError("Current tile not found".to_string()))?;
|
|
let offset = self.viewbox.get_offset();
|
|
Ok(tile.get_rect_with_offset(&offset))
|
|
}
|
|
|
|
pub fn get_rect_bounds(&mut self, rect: skia::Rect) -> Rect {
|
|
let scale = self.get_scale();
|
|
let offset_x = self.viewbox.area.left * scale;
|
|
let offset_y = self.viewbox.area.top * scale;
|
|
Rect::from_xywh(
|
|
(rect.left * scale) - offset_x,
|
|
(rect.top * scale) - offset_y,
|
|
rect.width() * scale,
|
|
rect.height() * scale,
|
|
)
|
|
}
|
|
|
|
pub fn get_shape_selrect_bounds(&mut self, shape: &Shape) -> Rect {
|
|
let rect = shape.selrect();
|
|
self.get_rect_bounds(rect)
|
|
}
|
|
|
|
pub fn get_shape_extrect_bounds(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Rect {
|
|
let scale = self.get_scale();
|
|
let rect = shape.extrect(tree, scale);
|
|
self.get_rect_bounds(rect)
|
|
}
|
|
|
|
pub fn get_aligned_tile_bounds(&mut self, tile: tiles::Tile) -> Rect {
|
|
let scale = self.get_scale();
|
|
let start_tile_x =
|
|
(self.viewbox.area.left * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE;
|
|
let start_tile_y =
|
|
(self.viewbox.area.top * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE;
|
|
Rect::from_xywh(
|
|
(tile.x() as f32 * tiles::TILE_SIZE) - start_tile_x,
|
|
(tile.y() as f32 * tiles::TILE_SIZE) - start_tile_y,
|
|
tiles::TILE_SIZE,
|
|
tiles::TILE_SIZE,
|
|
)
|
|
}
|
|
|
|
// Returns the bounds of the current tile relative to the viewbox,
|
|
// aligned to the nearest tile grid origin.
|
|
//
|
|
// Unlike `get_current_tile_bounds`, which calculates bounds using the exact
|
|
// scaled offset of the viewbox, this method snaps the origin to the nearest
|
|
// lower multiple of `TILE_SIZE`. This ensures the tile bounds are aligned
|
|
// with the global tile grid, which is useful for rendering tiles in a
|
|
/// consistent and predictable layout.
|
|
pub fn get_current_aligned_tile_bounds(&mut self) -> Result<Rect> {
|
|
Ok(self.get_aligned_tile_bounds(
|
|
self.current_tile
|
|
.ok_or(Error::CriticalError("Current tile not found".to_string()))?,
|
|
))
|
|
}
|
|
|
|
/// Renders element drop shadows to DropShadows surface and composites to Current.
|
|
/// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur).
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render_element_drop_shadows_and_composite(
|
|
&mut self,
|
|
element: &Shape,
|
|
tree: ShapesPoolRef,
|
|
extrect: &mut Option<Rect>,
|
|
clip_bounds: Option<ClipStack>,
|
|
scale: f32,
|
|
node_render_state: &NodeRenderState,
|
|
target_surface: SurfaceId,
|
|
) -> Result<()> {
|
|
drop_shadow::render_element_drop_shadows_and_composite(
|
|
self,
|
|
element,
|
|
tree,
|
|
extrect,
|
|
clip_bounds,
|
|
scale,
|
|
node_render_state,
|
|
target_surface,
|
|
)
|
|
}
|
|
|
|
pub fn render_shape_tree_partial_uncached(
|
|
&mut self,
|
|
tree: ShapesPoolRef,
|
|
timestamp: i32,
|
|
allow_stop: bool,
|
|
export: bool,
|
|
) -> Result<(bool, bool)> {
|
|
let mut iteration = 0;
|
|
let mut is_empty = true;
|
|
|
|
let mut target_surface = SurfaceId::Current;
|
|
if export {
|
|
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.
|
|
//
|
|
// `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;
|
|
for id in modifier_ids.iter() {
|
|
// Current (post-modifier) bounds
|
|
if let Some(s) = tree.get(id) {
|
|
let r = s.extrect(tree, 1.0);
|
|
acc = Some(match acc {
|
|
None => r,
|
|
Some(mut prev) => {
|
|
prev.join(r);
|
|
prev
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 = raw.extrect(tree, 1.0);
|
|
acc = Some(match acc {
|
|
None => r0,
|
|
Some(mut prev) => {
|
|
prev.join(r0);
|
|
prev
|
|
}
|
|
});
|
|
}
|
|
}
|
|
acc
|
|
} else {
|
|
None
|
|
};
|
|
|
|
while let Some(node_render_state) = self.pending_nodes.pop() {
|
|
let node_id = node_render_state.id;
|
|
let visited_children = node_render_state.visited_children;
|
|
let visited_mask = node_render_state.visited_mask;
|
|
let mask = node_render_state.mask;
|
|
let clip_bounds = node_render_state.clip_bounds.clone();
|
|
|
|
is_empty = false;
|
|
|
|
let Some(element) = tree.get(&node_id) else {
|
|
// The shape isn't available yet (likely still streaming in from WASM).
|
|
// Skip it for this pass; a subsequent render will pick it up once present.
|
|
continue;
|
|
};
|
|
let scale = self.get_scale_fast();
|
|
let mut extrect: Option<Rect> = None;
|
|
|
|
// If the shape is not in the tile set, then we add them.
|
|
if self.tiles.get_tiles_of(node_id).is_none() {
|
|
self.add_shape_tiles(element, tree);
|
|
}
|
|
|
|
if visited_children {
|
|
if !node_render_state.flattened {
|
|
self.render_shape_exit(element, visited_mask, clip_bounds, target_surface)?;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if !node_render_state.is_root() {
|
|
let transformed_element: Cow<Shape> = Cow::Borrowed(element);
|
|
|
|
// Aggressive early exit: check hidden first (fastest check)
|
|
if transformed_element.hidden {
|
|
continue;
|
|
}
|
|
|
|
if !view_mode::shape_visible_for_viewer_layer(
|
|
&self.viewer_render_root,
|
|
&self.viewer_visible_set,
|
|
&node_id,
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// Ancestors needed to reach whitelisted descendants: traverse only.
|
|
if self.include_filter.is_some()
|
|
&& view_mode::shape_visible_for_viewer_layer(
|
|
&self.viewer_render_root,
|
|
&self.viewer_visible_set,
|
|
&node_id,
|
|
)
|
|
&& !view_mode::shape_should_paint_for_viewer_layer(
|
|
&self.include_filter,
|
|
&node_id,
|
|
)
|
|
{
|
|
if element.is_recursive() {
|
|
let children_ids: Vec<_> =
|
|
element.children_ids_iter(false).copied().collect();
|
|
let children_ids = sort_z_index(tree, element, children_ids);
|
|
for child_id in children_ids.iter() {
|
|
self.pending_nodes.push(NodeRenderState {
|
|
id: *child_id,
|
|
visited_children: false,
|
|
clip_bounds: clip_bounds.clone(),
|
|
visited_mask: false,
|
|
mask: false,
|
|
flattened: false,
|
|
});
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// For frames and groups, we must use extrect because they can have nested content
|
|
// that extends beyond their selrect. Using selrect for early exit would incorrectly
|
|
// skip frames/groups that have nested content in the current tile.
|
|
let is_container = matches!(
|
|
transformed_element.shape_type,
|
|
crate::shapes::Type::Frame(_) | crate::shapes::Type::Group(_)
|
|
);
|
|
|
|
let has_effects = transformed_element.has_effects_that_extend_bounds();
|
|
|
|
let is_visible = export
|
|
|| mask
|
|
|| 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)
|
|
&& !transformed_element.visually_insignificant(scale, tree)
|
|
} else {
|
|
let selrect = transformed_element.selrect();
|
|
selrect.intersects(self.render_area_with_margins)
|
|
&& !transformed_element.visually_insignificant(scale, tree)
|
|
};
|
|
|
|
if self.options.is_debug_visible() {
|
|
let shape_extrect_bounds = self.get_shape_extrect_bounds(element, tree);
|
|
debug::render_debug_shape(self, None, Some(shape_extrect_bounds));
|
|
}
|
|
|
|
if !is_visible {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Interactive drag cache: if this node is cacheable during interactive transform,
|
|
// draw it directly from Backbuffer crop on the current tile surface and skip
|
|
// traversing/rendering the subtree.
|
|
if self.options.is_interactive_transform() {
|
|
let use_cached = self.should_use_cached_top_level_during_interactive(
|
|
node_id,
|
|
tree,
|
|
modifier_ids,
|
|
moved_bounds,
|
|
);
|
|
|
|
if use_cached {
|
|
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) {
|
|
let crop_image = &crop.image;
|
|
let crop_src_selrect = crop.src_selrect;
|
|
|
|
let cur_selrect = tree.get(&node_id).map(|s| s.selrect());
|
|
let (dx, dy) = match cur_selrect {
|
|
Some(cur) => (
|
|
cur.left - crop_src_selrect.left,
|
|
cur.top - crop_src_selrect.top,
|
|
),
|
|
None => (0.0, 0.0),
|
|
};
|
|
let scale = self.get_scale_fast();
|
|
let translation = self
|
|
.surfaces
|
|
.get_render_context_translation(self.render_area, scale);
|
|
|
|
let canvas = self.surfaces.canvas(target_surface);
|
|
canvas.save();
|
|
canvas.reset_matrix();
|
|
// If the crop includes shadows/blur (extrect pixels outside the fill/stroke
|
|
// silhouette), do NOT apply the silhouette clip or we'd cut those pixels.
|
|
let should_clip_crop = element.shadows.is_empty() && element.blur.is_none();
|
|
if should_clip_crop {
|
|
if let Some(clip_path) = element.drag_crop_clip_path() {
|
|
let mut doc_to_tile = Matrix::new_identity();
|
|
// Map document-space coordinates into tile pixels.
|
|
// Rendering surfaces apply: scale(scale) then translate(translation) in doc units.
|
|
// Equivalent point mapping: (doc + translation) * scale.
|
|
doc_to_tile.post_translate((translation.0, translation.1));
|
|
doc_to_tile.post_scale((scale, scale), None);
|
|
let clip_path = clip_path.make_transform(&doc_to_tile);
|
|
canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true);
|
|
}
|
|
}
|
|
let doc_left =
|
|
crop.capture_vb_left + (crop.capture_src_left as f32 / scale) + dx;
|
|
let doc_top =
|
|
crop.capture_vb_top + (crop.capture_src_top as f32 / scale) + dy;
|
|
|
|
let x = (doc_left + translation.0) * scale;
|
|
let y = (doc_top + translation.1) * scale;
|
|
let bw = crop_image.width() as f32;
|
|
let bh = crop_image.height() as f32;
|
|
let dst = skia::Rect::from_xywh(x, y, bw, bh);
|
|
canvas.draw_image_rect(crop_image, None, dst, &skia::Paint::default());
|
|
|
|
canvas.restore();
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id);
|
|
|
|
// Skip render_shape_enter/exit for flattened containers
|
|
// If a container was flattened, it doesn't affect children visually, so we skip
|
|
// the expensive enter/exit operations and process children directly
|
|
if !can_flatten {
|
|
// Enter focus early so shadow_before_layer can run (it needs focus_mode.is_active())
|
|
self.focus_mode.enter(&element.id);
|
|
|
|
// For frames with layer blur, render shadow BEFORE the layer so it doesn't get
|
|
// the layer blur (which would make it more diffused than without clipping)
|
|
let shadow_before_layer = !node_render_state.is_root()
|
|
&& self.focus_mode.is_active()
|
|
&& !self.options.is_fast_mode()
|
|
&& !matches!(element.shape_type, Type::Text(_))
|
|
&& Self::frame_clip_layer_blur(element).is_some()
|
|
&& element.drop_shadows_visible().next().is_some();
|
|
|
|
if shadow_before_layer {
|
|
self.render_element_drop_shadows_and_composite(
|
|
element,
|
|
tree,
|
|
&mut extrect,
|
|
clip_bounds.clone(),
|
|
scale,
|
|
&node_render_state,
|
|
target_surface,
|
|
)?;
|
|
}
|
|
|
|
// Render background blur BEFORE save_layer so it modifies
|
|
// the backdrop independently of the shape's opacity.
|
|
if !node_render_state.is_root() && self.focus_mode.is_active() {
|
|
self.render_background_blur(element, target_surface);
|
|
}
|
|
|
|
self.render_shape_enter(element, mask, clip_bounds.as_ref(), target_surface);
|
|
}
|
|
|
|
if !node_render_state.is_root() && self.focus_mode.is_active() {
|
|
// Skip expensive drop shadow rendering in fast mode (during pan/zoom).
|
|
let skip_shadows = self.options.is_fast_mode();
|
|
|
|
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
|
|
let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some();
|
|
|
|
// For text shapes, render drop shadow using text rendering logic
|
|
if !skip_shadows
|
|
&& !shadows_already_rendered
|
|
&& !matches!(element.shape_type, Type::Text(_))
|
|
{
|
|
self.render_element_drop_shadows_and_composite(
|
|
element,
|
|
tree,
|
|
&mut extrect,
|
|
clip_bounds.clone(),
|
|
scale,
|
|
&node_render_state,
|
|
target_surface,
|
|
)?;
|
|
} else {
|
|
// This is necessary or the later flush_and_submit will be very slow
|
|
self.surfaces
|
|
.draw_into(SurfaceId::DropShadows, target_surface, None);
|
|
}
|
|
|
|
// For frames without clip_content, inner strokes must render after children in
|
|
// render_shape_exit so children don't paint over them. Strip them here.
|
|
let element_for_inline: Cow<Shape> = if matches!(element.shape_type, Type::Frame(_))
|
|
&& !element.clip_content
|
|
&& element.has_inner_stroke()
|
|
{
|
|
let is_open = element.is_open();
|
|
let mut modified = element.clone();
|
|
modified
|
|
.strokes
|
|
.retain(|s| s.render_kind(is_open) != StrokeKind::Inner);
|
|
Cow::Owned(modified)
|
|
} else {
|
|
Cow::Borrowed(element)
|
|
};
|
|
|
|
self.render_shape(
|
|
&element_for_inline,
|
|
clip_bounds.clone(),
|
|
SurfaceId::Fills,
|
|
SurfaceId::Strokes,
|
|
SurfaceId::InnerShadows,
|
|
SurfaceId::TextDropShadows,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
target_surface,
|
|
)?;
|
|
|
|
self.surfaces
|
|
.canvas(SurfaceId::DropShadows)
|
|
.clear(skia::Color::TRANSPARENT);
|
|
} else if visited_children {
|
|
self.draw_shape_surface_stack_into(Some(element), target_surface);
|
|
}
|
|
|
|
// Skip nested state updates for flattened containers
|
|
// Flattened containers don't affect children, so we don't need to track their state
|
|
if !can_flatten {
|
|
match element.shape_type {
|
|
Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => {
|
|
self.nested_blurs.push(None);
|
|
self.cached_layer_blur = None;
|
|
}
|
|
Type::Group(_) if element.masked_group_layer_blur().is_some() => {
|
|
self.nested_blurs.push(None);
|
|
self.cached_layer_blur = None;
|
|
}
|
|
Type::Frame(_) | Type::Group(_) => {
|
|
self.nested_blurs.push(element.blur);
|
|
self.cached_layer_blur = None;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Set the node as visited_children before processing children
|
|
self.pending_nodes.push(NodeRenderState {
|
|
id: node_id,
|
|
visited_children: true,
|
|
clip_bounds: clip_bounds.clone(),
|
|
visited_mask: false,
|
|
mask,
|
|
flattened: can_flatten,
|
|
});
|
|
|
|
if element.is_recursive() {
|
|
// Shrink the child clip by ~1 device px when the frame has an inner stroke, same
|
|
// epsilon as `fills::render` inset, so clipped overflow does not sit under the
|
|
// stroke band drawn later in `render_shape_exit`.
|
|
let clip_inset_for_children = (matches!(element.shape_type, Type::Frame(_))
|
|
&& element.clip()
|
|
&& element.has_inner_stroke())
|
|
.then_some(1.0 / scale);
|
|
let children_clip_bounds = node_render_state.get_children_clip_bounds(
|
|
element,
|
|
None,
|
|
clip_inset_for_children,
|
|
);
|
|
|
|
let mut children_ids: Vec<_> = Vec::new();
|
|
if can_flatten {
|
|
get_simplified_children(tree, element, &mut children_ids);
|
|
} else {
|
|
children_ids = element.children_ids_iter(false).copied().collect();
|
|
}
|
|
|
|
let children_ids = sort_z_index(tree, element, children_ids);
|
|
|
|
for child_id in children_ids.iter() {
|
|
self.pending_nodes.push(NodeRenderState {
|
|
id: *child_id,
|
|
visited_children: false,
|
|
clip_bounds: children_clip_bounds.clone(),
|
|
visited_mask: false,
|
|
mask: false,
|
|
flattened: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// We try to avoid doing too many calls to get_time
|
|
if allow_stop && self.should_stop_rendering(iteration, timestamp) {
|
|
return Ok((is_empty, true));
|
|
}
|
|
iteration += 1;
|
|
}
|
|
|
|
Ok((is_empty, false))
|
|
}
|
|
|
|
pub fn render_shape_tree_partial(
|
|
&mut self,
|
|
base_object: Option<&Uuid>,
|
|
tree: ShapesPoolRef,
|
|
timestamp: i32,
|
|
allow_stop: bool,
|
|
) -> Result<FrameType> {
|
|
let mut should_stop = false;
|
|
self.viewer_render_root = base_object.copied();
|
|
let root_ids = {
|
|
if let Some(shape_id) = base_object {
|
|
vec![*shape_id]
|
|
} else {
|
|
let Some(root) = tree.get(&Uuid::nil()) else {
|
|
return Err(Error::CriticalError("Root shape not found".to_string()));
|
|
};
|
|
root.children_ids(false)
|
|
}
|
|
};
|
|
|
|
while !should_stop {
|
|
if let Some(current_tile) = self.current_tile {
|
|
// NOTE: For now we don't need to cover the case where the tile
|
|
// is not cached because everything will be handled from draw_atlas.
|
|
// Viewer masked passes (include_filter) must not reuse cached tiles from
|
|
// a previous pass; otherwise pass-1 pixels can leak into pass 2.
|
|
if self.viewer_masked_pass() || !self.surfaces.has_cached_tile_surface(current_tile)
|
|
{
|
|
performance::begin_measure!("render_shape_tree::uncached");
|
|
let (is_empty, early_return) = self
|
|
.render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?;
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
if self.options.capture_frames > 0 {
|
|
debug::console_debug_surface(self, SurfaceId::Backbuffer);
|
|
}
|
|
|
|
if early_return {
|
|
self.viewer_render_root = None;
|
|
return Ok(FrameType::Partial);
|
|
}
|
|
performance::end_measure!("render_shape_tree::uncached");
|
|
|
|
let tile_rect = self.get_current_tile_bounds()?;
|
|
// Composite if the walker did work in this PAF (`!is_empty`) OR
|
|
// the tile has unfinished work from a previous PAF
|
|
// (`current_tile_had_shapes` was set when we populated pending_nodes
|
|
// for this tile).
|
|
if !is_empty || self.current_tile_had_shapes {
|
|
if self.options.is_interactive_transform() {
|
|
// During drag, avoid snapshot-based caching. Draw Current directly
|
|
// into Target (and Cache) to reduce stalls.
|
|
self.surfaces.draw_current_tile_into_backbuffer(
|
|
&tile_rect,
|
|
self.background_color,
|
|
surfaces::DrawOnCache::Yes,
|
|
);
|
|
} else {
|
|
self.apply_render_to_final_canvas()?;
|
|
}
|
|
|
|
if self.options.is_debug_visible() {
|
|
debug::render_workspace_current_tile(
|
|
self,
|
|
"".to_string(),
|
|
current_tile,
|
|
tile_rect,
|
|
);
|
|
}
|
|
}
|
|
} else if self.tiles.is_empty_at(current_tile) {
|
|
self.surfaces.remove_cached_tile_surface(current_tile);
|
|
}
|
|
}
|
|
|
|
self.surfaces
|
|
.canvas(SurfaceId::Current)
|
|
.clear(self.background_color);
|
|
|
|
// If we finish processing every node rendering is complete
|
|
// let's check if there are more pending nodes
|
|
if let Some(next_tile) = self.pending_tiles.pop() {
|
|
self.update_render_context(next_tile);
|
|
// Reset for the new tile. We'll flip it to true if the
|
|
// tile has shapes, so a later "is_empty=true" reflects
|
|
// a resumed-from-yield case rather than a genuinely
|
|
// empty tile.
|
|
self.current_tile_had_shapes = false;
|
|
|
|
let viewer_masked_pass = self.viewer_masked_pass();
|
|
|
|
let Some(ids) = self.tiles.get_shapes_at(next_tile) else {
|
|
// If the tile is empty we do not need to render it.
|
|
continue;
|
|
};
|
|
|
|
// Never skip based on cached surfaces during viewer masked passes.
|
|
if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) {
|
|
// If the tile is cached, then we do not need to
|
|
// render it.
|
|
continue;
|
|
}
|
|
|
|
// Check if any shape on this tile has a background blur.
|
|
// If so, we need ALL root shapes rendered (not just those
|
|
// assigned to this tile) because the blur snapshots Current
|
|
// which must contain the shapes behind it.
|
|
let tile_has_bg_blur = ids.iter().any(|id| {
|
|
tree.get(id)
|
|
.is_some_and(|s| s.visible_background_blur().is_some())
|
|
});
|
|
|
|
// We only need first level shapes, in the same order as the parent node.
|
|
//
|
|
// During interactive transforms we may invalidate only the modified shapes
|
|
// (to avoid massive ancestor eviction). However, we still composite full
|
|
// tiles (we clear the tile rect before drawing Current), so we must render
|
|
// all root shapes that can contribute to this tile; otherwise, unchanged
|
|
// siblings inside the same tile would disappear.
|
|
let mut valid_ids = Vec::with_capacity(ids.len());
|
|
if self.options.is_interactive_transform() || tile_has_bg_blur {
|
|
valid_ids.extend(root_ids.iter().copied());
|
|
} else {
|
|
for root_id in root_ids.iter() {
|
|
if ids.contains(root_id) {
|
|
valid_ids.push(*root_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !valid_ids.is_empty() {
|
|
self.current_tile_had_shapes = true;
|
|
}
|
|
|
|
self.pending_nodes
|
|
.extend(valid_ids.into_iter().map(|id| NodeRenderState {
|
|
id,
|
|
visited_children: false,
|
|
clip_bounds: None,
|
|
visited_mask: false,
|
|
mask: false,
|
|
flattened: false,
|
|
}));
|
|
} else {
|
|
// If there are no more pending tiles, stop.
|
|
should_stop = true;
|
|
}
|
|
}
|
|
|
|
self.viewer_render_root = None;
|
|
|
|
// Mark cache as valid for render_from_cache.
|
|
// Only update for full-quality renders (non-fast mode).
|
|
// An async render can complete while fast mode is active
|
|
// (e.g. interest-area tiles finish during a pan gesture).
|
|
// Those tiles lack effects (shadows, blur). Updating
|
|
// cached_viewbox here would make zoom_changed() return false,
|
|
// so set_view_end would skip tile invalidation and the next
|
|
// full render would reuse the low-quality tiles.
|
|
if !self.options.is_fast_mode() {
|
|
self.cached_viewbox = self.viewbox;
|
|
}
|
|
|
|
Ok(FrameType::Full)
|
|
}
|
|
|
|
/*
|
|
* Given a shape returns the TileRect with the range of tiles that the shape is in.
|
|
* This is always limited to the interest area to optimize performance and prevent
|
|
* processing unnecessary tiles outside the viewport. The interest area already
|
|
* includes a margin (VIEWPORT_INTEREST_AREA_THRESHOLD) calculated via
|
|
* get_tiles_for_viewbox_with_interest, ensuring smooth pan/zoom interactions.
|
|
*
|
|
* When the viewport changes (pan/zoom), the interest area is updated and shapes
|
|
* are dynamically added to the tile index via the fallback mechanism in
|
|
* render_shape_tree_partial_uncached, ensuring all shapes render correctly.
|
|
*/
|
|
pub fn get_tiles_for_shape(&mut self, shape: &Shape, tree: ShapesPoolRef) -> TileRect {
|
|
let scale = self.get_scale();
|
|
let extrect = shape.extrect(tree, scale);
|
|
let tile_size = tiles::get_tile_size(scale);
|
|
let shape_tiles = tiles::get_tiles_for_rect(extrect, tile_size);
|
|
let interest_rect = &self.tile_viewbox.interest_rect;
|
|
// Calculate the intersection of shape_tiles with interest_rect
|
|
// This returns only the tiles that are both in the shape and in the interest area
|
|
let intersection_x1 = shape_tiles.x1().max(interest_rect.x1());
|
|
let intersection_y1 = shape_tiles.y1().max(interest_rect.y1());
|
|
let intersection_x2 = shape_tiles.x2().min(interest_rect.x2());
|
|
let intersection_y2 = shape_tiles.y2().min(interest_rect.y2());
|
|
|
|
// Return the intersection if valid (there is overlap), otherwise return empty rect
|
|
if intersection_x1 <= intersection_x2 && intersection_y1 <= intersection_y2 {
|
|
// Valid intersection: return the tiles that are in both shape_tiles and interest_rect
|
|
TileRect(
|
|
intersection_x1,
|
|
intersection_y1,
|
|
intersection_x2,
|
|
intersection_y2,
|
|
)
|
|
} else {
|
|
// No intersection: shape is completely outside interest area
|
|
// The shape will be added dynamically via add_shape_tiles when it enters
|
|
// the interest area during pan/zoom operations
|
|
TileRect(0, 0, -1, -1)
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Given a shape, check the indexes and update it's location in the tile set
|
|
* returns the tiles that have changed in the process.
|
|
*/
|
|
pub fn update_shape_tiles(
|
|
&mut self,
|
|
shape: &Shape,
|
|
tree: ShapesPoolRef,
|
|
) -> HashSet<tiles::Tile> {
|
|
let tile_rect = self.get_tiles_for_shape(shape, tree);
|
|
|
|
// Collect old tiles to avoid borrow conflict with remove_shape_at
|
|
let old_tiles: Vec<_> = self
|
|
.tiles
|
|
.get_tiles_of(shape.id)
|
|
.map_or(Vec::new(), |t| t.iter().copied().collect());
|
|
|
|
let mut result = HashSet::<tiles::Tile>::with_capacity(old_tiles.len());
|
|
|
|
// When the shape has an active modifier (i.e. is being moved/resized),
|
|
// clear its OLD doc-space extent from the atlas using the raw
|
|
// (pre-modifier) shape. The per-tile clearing done later via
|
|
// `clear_tile_in_atlas` only covers tiles tracked in `atlas.tile_doc_rects`
|
|
// at the current zoom level. However, the atlas may also contain stale
|
|
// pixels from previous zoom levels (tiles are larger / smaller in doc
|
|
// space at different zoom scales) that were never re-tracked after a zoom
|
|
// change. Clearing the full raw extrect here removes all such residual
|
|
// content without growing the atlas.
|
|
//
|
|
// We intentionally skip this when there is NO modifier so that plain
|
|
// zoom / pan tile-index rebuilds do NOT invalidate valid atlas content.
|
|
if tree.get_modifier(&shape.id).is_some() {
|
|
if let Some(raw_shape) = tree.get_raw(&shape.id) {
|
|
let old_extrect = raw_shape.extrect(tree, 1.0);
|
|
self.surfaces
|
|
.atlas
|
|
.clear_doc_rect_in_atlas_clipped(old_extrect);
|
|
}
|
|
}
|
|
|
|
// First, remove the shape from all tiles where it was previously located
|
|
for tile in old_tiles {
|
|
self.tiles.remove_shape_at(tile, shape.id);
|
|
result.insert(tile);
|
|
}
|
|
|
|
// Then, add the shape to the new tiles
|
|
for tile in tile_rect.iter(true) {
|
|
self.tiles.add_shape_at(tile, shape.id);
|
|
result.insert(tile);
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/*
|
|
* Incremental version of update_shape_tiles for pan/zoom operations.
|
|
* Updates the tile index and returns ONLY tiles that need cache invalidation.
|
|
*
|
|
* During pan operations, shapes don't move in world coordinates. The interest
|
|
* area (viewport) moves, which changes which tiles we track in the index, but
|
|
* tiles that were already cached don't need re-rendering just because the
|
|
* viewport moved.
|
|
*
|
|
* This function:
|
|
* 1. Updates the tile index (adds/removes shapes from tiles based on interest area)
|
|
* 2. Returns empty vec for cache invalidation (pan doesn't change tile content)
|
|
*
|
|
* Tile cache invalidation only happens when shapes actually move or change,
|
|
* which is handled by rebuild_touched_tiles, not during pan/zoom.
|
|
*/
|
|
pub fn update_shape_tiles_incremental(
|
|
&mut self,
|
|
shape: &Shape,
|
|
tree: ShapesPoolRef,
|
|
) -> Vec<tiles::Tile> {
|
|
let tile_rect = self.get_tiles_for_shape(shape, tree);
|
|
let old_tiles: HashSet<tiles::Tile> = self
|
|
.tiles
|
|
.get_tiles_of(shape.id)
|
|
.map_or(HashSet::new(), |tiles| tiles.iter().copied().collect());
|
|
|
|
let new_tiles: HashSet<tiles::Tile> = tile_rect.iter(true).collect();
|
|
|
|
// Tiles where shape is being removed from index (left interest area)
|
|
let removed: Vec<_> = old_tiles.difference(&new_tiles).copied().collect();
|
|
// Tiles where shape is being added to index (entered interest area)
|
|
let added: Vec<_> = new_tiles.difference(&old_tiles).copied().collect();
|
|
|
|
// Update the index: remove from old tiles
|
|
for tile in &removed {
|
|
self.tiles.remove_shape_at(*tile, shape.id);
|
|
}
|
|
|
|
// Update the index: add to new tiles
|
|
for tile in &added {
|
|
self.tiles.add_shape_at(*tile, shape.id);
|
|
}
|
|
|
|
// Don't invalidate cache for pan/zoom - the tile content hasn't changed,
|
|
// only the interest area moved. Tiles that were cached are still valid.
|
|
// New tiles that entered the interest area will be rendered fresh since
|
|
// they weren't in the cache anyway.
|
|
Vec::new()
|
|
}
|
|
|
|
/*
|
|
* Add the tiles for the shape to the index.
|
|
* returns the tiles that have been updated
|
|
*/
|
|
pub fn add_shape_tiles(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Vec<tiles::Tile> {
|
|
performance::begin_measure!("add_shape_tiles");
|
|
let tiles: Vec<tiles::Tile> = self.get_tiles_for_shape(shape, tree).iter(true).collect();
|
|
for tile in tiles.iter() {
|
|
self.tiles.add_shape_at(*tile, shape.id);
|
|
}
|
|
performance::end_measure!("add_shape_tiles");
|
|
tiles
|
|
}
|
|
|
|
pub fn remove_cached_tile(&mut self, tile: tiles::Tile) {
|
|
self.surfaces.remove_cached_tile_surface(tile);
|
|
}
|
|
|
|
/// Rebuild the tile index (shape→tile mapping) for all top-level shapes.
|
|
/// This does NOT invalidate the tile texture cache — cached tile images
|
|
/// survive so that fast-mode renders during pan still show shadows/blur.
|
|
pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) {
|
|
let zoom_changed = self.zoom_changed();
|
|
performance::begin_measure!("rebuild_tile_index");
|
|
let mut nodes = Vec::<Uuid>::with_capacity(64);
|
|
nodes.push(Uuid::nil());
|
|
while let Some(shape_id) = nodes.pop() {
|
|
if let Some(shape) = tree.get(&shape_id) {
|
|
if shape_id != Uuid::nil() {
|
|
if zoom_changed {
|
|
let _ = self.update_shape_tiles(shape, tree);
|
|
} else {
|
|
let _ = self.update_shape_tiles_incremental(shape, tree);
|
|
}
|
|
} else {
|
|
// We only need to rebuild tiles from the first level.
|
|
for child_id in shape.children_ids_iter(false) {
|
|
nodes.push(*child_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
performance::end_measure!("rebuild_tile_index");
|
|
}
|
|
|
|
pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) {
|
|
performance::begin_measure!("rebuild_tiles_shallow");
|
|
|
|
self.rebuild_tile_index(tree);
|
|
|
|
// Zoom changes world tile size: a partial cache update would mix scales in the
|
|
// mosaic and glitch. Same zoom as last finished render (typical pan): drop only
|
|
// tile textures and keep the cache canvas for render_from_cache.
|
|
if self.zoom_changed() {
|
|
self.surfaces.remove_cached_tiles(self.background_color);
|
|
} else {
|
|
self.surfaces.invalidate_tile_cache();
|
|
}
|
|
|
|
performance::end_measure!("rebuild_tiles_shallow");
|
|
}
|
|
|
|
pub fn rebuild_tiles_from(&mut self, tree: ShapesPoolRef, base_id: Option<&Uuid>) {
|
|
performance::begin_measure!("rebuild_tiles");
|
|
|
|
self.tiles.invalidate();
|
|
|
|
let mut all_tiles = HashSet::<tiles::Tile>::new();
|
|
let mut nodes = {
|
|
if let Some(base_id) = base_id {
|
|
vec![*base_id]
|
|
} else {
|
|
vec![Uuid::nil()]
|
|
}
|
|
};
|
|
|
|
while let Some(shape_id) = nodes.pop() {
|
|
if let Some(shape) = tree.get(&shape_id) {
|
|
if shape_id != Uuid::nil() {
|
|
// We have invalidated the tiles so we only need to add the shape
|
|
all_tiles.extend(self.add_shape_tiles(shape, tree));
|
|
}
|
|
|
|
for child_id in shape.children_ids_iter(false) {
|
|
nodes.push(*child_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Invalidate changed tiles - old content stays visible until new tiles render
|
|
self.surfaces.remove_cached_tiles(self.background_color);
|
|
for tile in all_tiles {
|
|
self.remove_cached_tile(tile);
|
|
}
|
|
performance::end_measure!("rebuild_tiles");
|
|
}
|
|
|
|
/*
|
|
* Rebuild the tiles for the shapes that have been modified from the
|
|
* last time this was executed.
|
|
*/
|
|
pub fn rebuild_touched_tiles(&mut self, tree: ShapesPoolRef) {
|
|
performance::begin_measure!("rebuild_touched_tiles");
|
|
|
|
let mut all_tiles = HashSet::<tiles::Tile>::new();
|
|
|
|
let ids = std::mem::take(&mut self.touched_ids);
|
|
self.preserve_target_during_render = !ids.is_empty();
|
|
|
|
for shape_id in ids.iter() {
|
|
if let Some(shape) = tree.get(shape_id) {
|
|
if shape_id != &Uuid::nil() {
|
|
all_tiles.extend(self.update_shape_tiles(shape, tree));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update the changed tiles
|
|
for tile in all_tiles {
|
|
self.remove_cached_tile(tile);
|
|
}
|
|
|
|
performance::end_measure!("rebuild_touched_tiles");
|
|
}
|
|
|
|
/// Invalidates extended rectangles and updates tiles for a set of shapes
|
|
///
|
|
/// This function takes a set of shape IDs and for each one:
|
|
/// 1. Invalidates the extrect cache
|
|
/// 2. Updates the tiles to ensure proper rendering
|
|
///
|
|
/// This is useful when you have a pre-computed set of shape IDs that need to be refreshed,
|
|
/// regardless of their relationship to other shapes (e.g., ancestors, descendants, or any other collection).
|
|
pub fn update_tiles_shapes(
|
|
&mut self,
|
|
shape_ids: &[Uuid],
|
|
tree: ShapesPoolMutRef<'_>,
|
|
) -> Result<()> {
|
|
performance::begin_measure!("invalidate_and_update_tiles");
|
|
let mut all_tiles = HashSet::<tiles::Tile>::new();
|
|
for shape_id in shape_ids {
|
|
if let Some(shape) = tree.get(shape_id) {
|
|
all_tiles.extend(self.update_shape_tiles(shape, tree));
|
|
}
|
|
}
|
|
for tile in all_tiles {
|
|
self.remove_cached_tile(tile);
|
|
}
|
|
performance::end_measure!("invalidate_and_update_tiles");
|
|
Ok(())
|
|
}
|
|
|
|
/// Rebuilds tiles for shapes with modifiers and processes their ancestors
|
|
///
|
|
/// This function applies transformation modifiers to shapes and updates their tiles.
|
|
/// Additionally, it processes all ancestors of modified shapes to ensure their
|
|
/// extended rectangles are properly recalculated and their tiles are updated.
|
|
/// This is crucial for frames and groups that contain transformed children.
|
|
pub fn rebuild_modifier_tiles(
|
|
&mut self,
|
|
tree: ShapesPoolMutRef<'_>,
|
|
ids: &[Uuid],
|
|
) -> Result<()> {
|
|
// During interactive transform, skip ancestor invalidation: walking up to the
|
|
// parent frame evicts every tile the frame covers, including dense tiles with
|
|
// many siblings. Ancestor extrect caches are already invalidated by
|
|
// `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by
|
|
// the committing code path (rebuild_touched_tiles).
|
|
if self.options.is_interactive_transform() {
|
|
self.update_tiles_shapes(ids, tree)?;
|
|
} else {
|
|
let ancestors = all_with_ancestors(ids, tree, false);
|
|
self.update_tiles_shapes(&ancestors, tree)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_scale(&self) -> f32 {
|
|
// During export, use the export scale instead of the workspace zoom.
|
|
if let Some((_, export_scale)) = self.export_context {
|
|
return export_scale;
|
|
}
|
|
self.viewbox.get_scale()
|
|
}
|
|
|
|
/// Hot-path variant that skips the export_context check.
|
|
/// Use in render_shape / walk loops where export is never active.
|
|
#[inline]
|
|
pub fn get_scale_fast(&self) -> f32 {
|
|
self.viewbox.get_scale()
|
|
}
|
|
|
|
pub fn zoom_changed(&self) -> bool {
|
|
(self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON
|
|
}
|
|
|
|
pub fn mark_touched(&mut self, uuid: Uuid) {
|
|
self.touched_ids.insert(uuid);
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn clean_touched(&mut self) {
|
|
self.touched_ids.clear();
|
|
}
|
|
|
|
pub fn set_view(&mut self, zoom: f32, x: f32, y: f32) {
|
|
self.viewbox.set_all(zoom, x, y);
|
|
}
|
|
|
|
pub fn print_stats(&self) {
|
|
self.stats.print();
|
|
}
|
|
|
|
pub fn prepare_context_loss_cleanup(&mut self) {
|
|
// Drop cached GPU-backed snapshots before dropping the render state.
|
|
self.backbuffer_crop_cache.clear();
|
|
self.surfaces.invalidate_tile_cache();
|
|
// Mark context as abandoned so resource destructors avoid issuing
|
|
// GL commands when the browser has already lost/restored the context.
|
|
get_gpu_state().context.abandon();
|
|
}
|
|
|
|
pub fn free_gpu_resources(&mut self) {
|
|
get_gpu_state().context.free_gpu_resources();
|
|
}
|
|
}
|