From ddf9b4637bef7d57482a09794f7bf4e05a4fe24c Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Wed, 10 Jun 2026 15:46:16 +0200 Subject: [PATCH] :recycle: Refactor render structures --- render-wasm/src/main.rs | 2 +- render-wasm/src/math/bools.rs | 6 +- render-wasm/src/render.rs | 1804 ++--------------- render-wasm/src/render/background_blur.rs | 91 + render-wasm/src/render/cache.rs | 148 ++ render-wasm/src/render/debug.rs | 4 + render-wasm/src/render/drag_crop.rs | 330 +++ render-wasm/src/render/drop_shadow.rs | 357 ++++ render-wasm/src/render/enter_exit.rs | 198 ++ render-wasm/src/render/export.rs | 98 + render-wasm/src/render/fills.rs | 14 +- render-wasm/src/render/filters.rs | 6 +- render-wasm/src/render/focus_mode.rs | 53 + render-wasm/src/render/fonts.rs | 10 +- render-wasm/src/render/images.rs | 71 +- render-wasm/src/render/layer_blur.rs | 58 + render-wasm/src/render/strokes.rs | 7 +- render-wasm/src/render/surfaces.rs | 56 +- render-wasm/src/render/text.rs | 34 +- render-wasm/src/render/ui.rs | 27 +- render-wasm/src/render/view_mode.rs | 73 + render-wasm/src/render/walk.rs | 232 +++ .../src/shapes/modifiers/grid_layout.rs | 2 - render-wasm/src/utils.rs | 2 +- render-wasm/src/wasm/fonts.rs | 5 + 25 files changed, 1917 insertions(+), 1771 deletions(-) create mode 100644 render-wasm/src/render/background_blur.rs create mode 100644 render-wasm/src/render/cache.rs create mode 100644 render-wasm/src/render/drag_crop.rs create mode 100644 render-wasm/src/render/drop_shadow.rs create mode 100644 render-wasm/src/render/enter_exit.rs create mode 100644 render-wasm/src/render/export.rs create mode 100644 render-wasm/src/render/focus_mode.rs create mode 100644 render-wasm/src/render/layer_blur.rs create mode 100644 render-wasm/src/render/view_mode.rs create mode 100644 render-wasm/src/render/walk.rs diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 2f7503d181..e02466849a 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -923,7 +923,7 @@ pub extern "C" fn get_shape_extrect(a: u32, b: u32, c: u32, d: u32) -> Result<*m let Some(shape) = state.shapes.get(&id) else { return Err(Error::CriticalError("Shape not found".to_string())); }; - let extrect = get_render_state().get_cached_extrect(shape, &state.shapes, 1.0); + let extrect = shape.extrect(&state.shapes, 1.0); let mut buf = Vec::with_capacity(16); buf.extend_from_slice(&extrect.x().to_le_bytes()); buf.extend_from_slice(&extrect.y().to_le_bytes()); diff --git a/render-wasm/src/math/bools.rs b/render-wasm/src/math/bools.rs index 0f7e01612d..1bfa68182c 100644 --- a/render-wasm/src/math/bools.rs +++ b/render-wasm/src/math/bools.rs @@ -333,11 +333,7 @@ fn beziers_to_segments(beziers: &[(BezierSource, Bezier)]) -> Vec { let mut last_end = (first_bezier.end.x as f32, first_bezier.end.y as f32); let mut cur_end = first_bezier.end; - loop { - let Some((next_src, next_bezier)) = find_next_in_pool(&mut pool, cur_end, cur_src) - else { - break; - }; + while let Some((next_src, next_bezier)) = find_next_in_pool(&mut pool, cur_end, cur_src) { push_bezier(&mut result, &next_bezier); last_end = (next_bezier.end.x as f32, next_bezier.end.y as f32); cur_end = next_bezier.end; diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index df284816f6..5dda8f7754 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1,10 +1,18 @@ +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; @@ -16,6 +24,8 @@ 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; @@ -24,11 +34,15 @@ 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::math; use crate::shapes::{ - all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, - Stroke, StrokeKind, TextContent, Type, + 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}; @@ -40,8 +54,6 @@ use crate::{get_gpu_state, performance}; pub use fonts::*; pub use images::*; -type ClipStack = Vec<(Rect, Option, Matrix)>; - #[repr(u8)] pub enum FrameType { None = 0, @@ -57,293 +69,6 @@ pub enum RenderFlag { Full = 2, } -#[derive(Debug)] -pub struct NodeRenderState { - pub id: Uuid, - // We use this bool to keep that we've traversed all the children inside this node. - visited_children: bool, - // This is used to clip the content of frames. - clip_bounds: Option, - // This is a flag to indicate that we've already drawn the mask of a masked group. - visited_mask: bool, - // This bool indicates that we're drawing the mask shape. - mask: bool, - // True when this container was flattened (enter/exit skipped). - flattened: bool, -} - -/// Get simplified children of a container, flattening nested flattened containers -fn get_simplified_children<'a>(tree: ShapesPoolRef<'a>, shape: &'a Shape) -> Vec { - let mut result = Vec::new(); - - for child_id in shape.children_ids_iter(false) { - if let Some(child) = tree.get(child_id) { - if child.can_flatten() { - // Child is flattened: recursively get its simplified children - result.extend(get_simplified_children(tree, child)); - } else { - // Child is not flattened: add it directly - result.push(*child_id); - } - } - } - - result -} - -impl NodeRenderState { - pub fn is_root(&self) -> bool { - self.id.is_nil() - } - - /// Calculates the clip bounds for child elements of a given shape. - /// - /// This function determines the clipping region that should be applied to child elements - /// when rendering. It takes into account the element's selection rectangle, transform. - /// - /// # Parameters - /// - /// * `element` - The shape element for which to calculate clip bounds - /// * `offset` - Optional offset (x, y) to adjust the bounds position. When provided, - /// the bounds are translated by the negative of this offset, effectively moving - /// the clipping region to compensate for coordinate system transformations. - /// This is useful for nested coordinate systems or when elements are grouped - /// and need relative positioning adjustments. - fn append_clip( - clip_stack: Option, - clip: (Rect, Option, Matrix), - ) -> Option { - match clip_stack { - Some(mut stack) => { - stack.push(clip); - Some(stack) - } - None => Some(vec![clip]), - } - } - - pub fn get_children_clip_bounds( - &self, - element: &Shape, - offset: Option<(f32, f32)>, - clip_inset: Option, - ) -> Option { - if self.id.is_nil() || !element.clip() { - return self.clip_bounds.clone(); - } - - let mut bounds = element.selrect(); - if let Some(offset) = offset { - let x = bounds.x() - offset.0; - let y = bounds.y() - offset.1; - let width = bounds.width(); - let height = bounds.height(); - bounds.set_xywh(x, y, width, height); - } - let mut transform = element.transform; - transform.post_translate(bounds.center()); - transform.pre_translate(-bounds.center()); - - let corners = match &element.shape_type { - Type::Rect(data) => data.corners, - Type::Frame(data) => data.corners, - _ => None, - }; - - if let Some(clip_inset) = clip_inset.filter(|&e| e > 0.0) { - bounds.inset((clip_inset, clip_inset)); - } - - Self::append_clip(self.clip_bounds.clone(), (bounds, corners, transform)) - } - - /// Calculates the clip bounds for shadow rendering of a given shape. - /// - /// This function determines the clipping region that should be applied when rendering a - /// shadow for a shape element. For frames, it uses the shadow bounds to clip nested - /// shadows. For groups, it returns the existing clip bounds since groups should not - /// constrain nested shadows based on their selection rectangle bounds. - /// - /// # Parameters - /// - /// * `element` - The shape element for which to calculate shadow clip bounds - /// * `shadow` - The shadow configuration containing blur, offset, and other properties - pub fn get_nested_shadow_clip_bounds( - &self, - element: &Shape, - shadow: &Shadow, - ) -> Option { - if self.id.is_nil() { - return self.clip_bounds.clone(); - } - - // Assert that the shape is either a Frame or Group - assert!( - matches!(element.shape_type, Type::Frame(_) | Type::Group(_)), - "Shape must be a Frame or Group for nested shadow clip bounds calculation" - ); - - match &element.shape_type { - Type::Frame(_) => { - let mut bounds = element.get_selrect_shadow_bounds(shadow); - let blur_inset = (shadow.blur * 2.).max(0.0); - if blur_inset > 0.0 { - let max_inset_x = (bounds.width() * 0.5).max(0.0); - let max_inset_y = (bounds.height() * 0.5).max(0.0); - // Clamp the inset so we never shrink more than half of the width/height; - // otherwise the rect could end up inverted on small frames. - let inset_x = blur_inset.min(max_inset_x); - let inset_y = blur_inset.min(max_inset_y); - if inset_x > 0.0 || inset_y > 0.0 { - bounds.inset((inset_x, inset_y)); - } - } - - let mut transform = element.transform; - transform.post_translate(element.center()); - transform.pre_translate(-element.center()); - - let corners = match &element.shape_type { - Type::Frame(data) => data.corners, - _ => None, - }; - - Self::append_clip(self.clip_bounds.clone(), (bounds, corners, transform)) - } - _ => self.clip_bounds.clone(), - } - } -} - -/// Represents the "focus mode" state used during rendering. -/// -/// Focus mode allows selectively highlighting or isolating specific shapes (UUIDs) -/// during the render pass. It maintains a list of shapes to focus and tracks -/// whether the current rendering context is inside a focused element. -/// -/// # Focus Propagation -/// If a shape is in focus, all its nested content -/// is also considered to be in focus for the duration of the render traversal. Focus -/// state propagates *downward* through the tree while rendering. -/// -/// # Usage -/// - `set_shapes(...)` to activate focus mode for specific elements and their anidated content. -/// - `clear()` to disable focus mode. -/// - `reset()` should be called at the beginning of the render loop. -/// - `enter(...)` / `exit(...)` should be called when entering and leaving shape -/// render contexts. -/// - `is_active()` returns whether the current shape is being rendered in focus. -#[derive(Clone)] -pub struct FocusMode { - shapes: Vec, - depth: u32, -} - -impl FocusMode { - pub fn new() -> Self { - FocusMode { - shapes: Vec::new(), - depth: 0, - } - } - - pub fn clear(&mut self) { - self.shapes.clear(); - self.depth = 0; - } - - pub fn set_shapes(&mut self, shapes: Vec) { - self.shapes = shapes; - } - - /// Returns `true` if the given shape ID should be focused. - /// If the `shapes` list is empty, focus applies to all shapes. - pub fn should_focus(&self, id: &Uuid) -> bool { - self.shapes.is_empty() || self.shapes.contains(id) - } - - pub fn enter(&mut self, id: &Uuid) { - if self.should_focus(id) { - self.depth += 1; - } - } - - pub fn exit(&mut self, id: &Uuid) { - if self.should_focus(id) && self.depth > 0 { - self.depth -= 1; - } - } - - pub fn is_active(&self) -> bool { - self.depth > 0 - } - - pub fn reset(&mut self) { - self.depth = 0; - } -} - -/* - * Sort by z_index descending (higher z renders on top). - * The sort is stable so if the values are equal the index for the children - * has preference. - * When changing this method check the benchmark - */ -fn sort_z_index(tree: ShapesPoolRef, element: &Shape, children_ids: Vec) -> Vec { - if element.has_layout() { - let mut ids = children_ids; - - if element.is_flex() && !element.is_flex_reverse() { - ids.reverse(); - } - ids.sort_by(|id1, id2| { - let z1 = tree.get(id1).map(|s| s.z_index()).unwrap_or(0); - let z2 = tree.get(id2).map(|s| s.z_index()).unwrap_or(0); - z2.cmp(&z1) - }); - ids - } else { - children_ids - } -} - -struct RenderStats { - pub counts: HashMap, -} - -#[allow(dead_code)] -impl RenderStats { - pub fn new() -> Self { - Self { - counts: HashMap::new(), - } - } - - fn count(&mut self, id: Uuid) -> i32 { - let counter = self.counts.entry(id).or_insert(0); - *counter += 1; - *counter - } - - fn clear(&mut self) { - self.counts.clear(); - } - - #[allow(dead_code)] - fn get(&self, id: &Uuid) -> Option<&i32> { - self.counts.get(id) - } - - fn print(&self) { - let mut sum: i32 = 0; - for (&id, &count) in self.counts.iter() { - println!("{}: {}", id, count); - sum += count; - } - println!("{}: {}", self.counts.len(), sum); - } -} - pub(crate) struct RenderState { pub options: RenderOptions, stats: RenderStats, @@ -371,14 +96,17 @@ pub(crate) struct RenderState { // Frames contained in groups must reset this nested_fills stack pushing a new empty vector. pub nested_fills: Vec>, pub nested_blurs: Vec>, // FIXME: why is this an option? + pub cached_layer_blur: Option>, pub nested_shadows: Vec>, pub show_grid: Option, + pub empty_grid_frame_ids: HashSet, pub rulers: RulerState, pub focus_mode: FocusMode, /// Viewer-only whitelist for fixed-scroll layer passes. pub include_filter: Option>, /// Frame id passed as `base_object` for viewer renders; always traversed. pub viewer_render_root: Option, + pub viewer_visible_set: Option>, pub touched_ids: HashSet, /// 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 @@ -411,61 +139,6 @@ pub(crate) struct RenderState { pub backbuffer_crop_cache: HashMap, } -pub struct InteractiveDragCrop { - pub src_doc_bounds: Rect, - pub src_selrect: Rect, - /// Viewbox origin (doc-space) at capture time. - pub capture_vb_left: f32, - pub capture_vb_top: f32, - /// Backbuffer pixel origin used for `snapshot_rect` (so we can do 1:1 blits). - pub capture_src_left: i32, - pub capture_src_top: i32, - pub image: skia::Image, -} - -/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)` with each side -/// at most `max_side_px` (**without scaling**): centered on the projection of -/// `viewport_doc ∩ src_doc_bounds`, or on the full crop if that intersection is empty. -/// `max_side_px` should match [`GpuState::max_texture_size`] (same budget as the atlas). -#[allow(clippy::too_many_arguments)] -fn drag_crop_snapshot_window_px( - max_side_px: i32, - out_w: i32, - out_h: i32, - viewport_doc: Rect, - vb_left: f32, - vb_top: f32, - scale: f32, - src_left_px: i32, - src_top_px: i32, - src_doc_bounds: Rect, -) -> (i32, i32, i32, i32) { - let cap = max_side_px.max(1); - if out_w <= cap && out_h <= cap { - return (0, 0, out_w, out_h); - } - let win_w = out_w.min(cap); - let win_h = out_h.min(cap); - - let mut vis = viewport_doc; - let has_vis = vis.intersect(src_doc_bounds); - let (cx, cy) = if !has_vis || vis.is_empty() { - (out_w as f32 * 0.5, out_h as f32 * 0.5) - } else { - let lx0 = (vis.left - vb_left) * scale - src_left_px as f32; - let ly0 = (vis.top - vb_top) * scale - src_top_px as f32; - let lx1 = (vis.right - vb_left) * scale - src_left_px as f32; - let ly1 = (vis.bottom - vb_top) * scale - src_top_px as f32; - ((lx0 + lx1) * 0.5, (ly0 + ly1) * 0.5) - }; - - let mut ox = (cx - win_w as f32 * 0.5).round() as i32; - let mut oy = (cy - win_h as f32 * 0.5).round() as i32; - ox = ox.clamp(0, out_w - win_w); - oy = oy.clamp(0, out_h - win_h); - (ox, oy, win_w, win_h) -} - impl RenderState { /// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an /// interactive transform (drag/resize/rotate). @@ -483,56 +156,13 @@ impl RenderState { moved_ids: &[Uuid], moved_bounds: Option, ) -> bool { - if !self.backbuffer_crop_cache.contains_key(&node_id) { - return false; - } - let Some(raw) = tree.get_raw(&node_id) else { - return false; - }; - if raw.parent_id != Some(Uuid::nil()) { - return false; - } - - // If this top-level shape itself is being moved, always allow using its cached pixels. - // BUT only for pure translations. For non-translation transforms (scale/rotate/skew), - // cached pixels won't match the live result (and may require resampling), so render live. - if moved_ids.contains(&node_id) { - let Some(m) = tree.get_modifier(&node_id) else { - return false; - }; - // Only allow using the cached pixels for pure translations. - // For non-translation transforms (scale/rotate/skew), cached pixels won't match. - // If the transform is the identity means a reflow, we need to redraw as well. - if math::identitish(m) || !math::is_move_only_matrix(m) { - return false; - } - - if !self.backbuffer_crop_cache.contains_key(&node_id) { - return false; - } - - // Additionally require this node to be safe to serve from a rectangular backbuffer - // crop while moving; otherwise it must be rendered live (e.g. text, overflow frames). - return tree - .get(&node_id) - .is_some_and(|s| s.is_safe_for_drag_crop_cache(tree)); - } - - // 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). - if let Some(moved) = moved_bounds { - let intersects = self - .backbuffer_crop_cache - .get(&node_id) - .is_some_and(|crop| moved.intersects(crop.src_doc_bounds)); - - if intersects { - return false; - } - } - true + 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 { @@ -576,12 +206,15 @@ impl RenderState { 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, @@ -602,157 +235,19 @@ impl RenderState { /// 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(&self, shape_blur: Option) -> Option { - let mut total = 0.; - - for nested_blur in self.nested_blurs.iter().flatten() { - total += Self::blur_variance(Some(*nested_blur)); - } - - total += Self::blur_variance(shape_blur); - - Self::blur_from_variance(total) - } - - /// Returns the variance (radius²) for a visible layer blur, or zero if the - /// blur is hidden/absent. Working in variance space lets us add multiple - /// blur radii correctly. - fn blur_variance(blur: Option) -> f32 { - match blur { - Some(blur) if !blur.hidden && blur.blur_type == BlurType::LayerBlur => { - blur.value.powi(2) - } - _ => 0., - } - } - - /// Builds a blur from an accumulated variance value. If no variance was - /// contributed, we return `None`; otherwise the equivalent single radius is - /// `sqrt(total)`. - fn blur_from_variance(total: f32) -> Option { - (total > 0.).then(|| Blur::new(BlurType::LayerBlur, false, total.sqrt())) - } - - /// Convenience helper to merge two optional layer blurs using the same - /// variance math as `combined_layer_blur`. - fn combine_blur_values(base: Option, extra: Option) -> Option { - let total = Self::blur_variance(base) + Self::blur_variance(extra); - Self::blur_from_variance(total) + fn combined_layer_blur(&mut self, shape_blur: Option) -> Option { + layer_blur::combined_layer_blur(&self.nested_blurs, &mut self.cached_layer_blur, shape_blur) } fn frame_clip_layer_blur(shape: &Shape) -> Option { - shape.frame_clip_layer_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) { - if self.options.is_fast_mode() { - return; - } - if matches!(shape.shape_type, Type::Text(_)) || matches!(shape.shape_type, Type::SVGRaw(_)) - { - return; - } - let blur = match shape.visible_background_blur() { - Some(blur) => blur, - None => return, - }; - - let scale = self.get_scale(); - let scaled_sigma = radius_to_sigma(blur.value * scale); - // Cap sigma so the blur kernel (≈3σ) stays within the tile margin. - // This prevents visible seams at tile boundaries when zoomed in. - // During export there's no tiling, so skip the cap. - let sigma = if self.export_context.is_some() { - scaled_sigma - } else { - let margin = self.surfaces.margins().width as f32; - let max_sigma = margin / 3.0; - scaled_sigma.min(max_sigma) - }; - - let blur_filter = - match skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None) { - Some(filter) => filter, - None => return, - }; - - let translation = self - .surfaces - .get_render_context_translation(self.render_area, scale); - - let center = shape.center(); - let mut matrix = shape.transform; - matrix.post_translate(center); - matrix.pre_translate(-center); - - let canvas = self.surfaces.canvas(target_surface); - canvas.save(); - - // Current/Export have no render context transform (identity canvas). - // Apply scale + translate + shape transform so the clip maps - // from shape-local coords to device pixels correctly. - canvas.scale((scale, scale)); - canvas.translate(translation); - canvas.concat(&matrix); - - // Clip to shape's path based on shape type - match &shape.shape_type { - Type::Rect(data) if data.corners.is_some() => { - let rrect = RRect::new_rect_radii(shape.selrect, data.corners.as_ref().unwrap()); - canvas.clip_rrect(rrect, skia::ClipOp::Intersect, true); - } - Type::Frame(data) if data.corners.is_some() => { - let rrect = RRect::new_rect_radii(shape.selrect, data.corners.as_ref().unwrap()); - canvas.clip_rrect(rrect, skia::ClipOp::Intersect, true); - } - Type::Rect(_) | Type::Frame(_) => { - canvas.clip_rect(shape.selrect, skia::ClipOp::Intersect, true); - } - Type::Circle => { - let mut pb = skia::PathBuilder::new(); - pb.add_oval(shape.selrect, None, None); - canvas.clip_path(&pb.detach(), skia::ClipOp::Intersect, true); - } - _ => { - if let Some(path) = shape.get_skia_path() { - canvas.clip_path(&path, skia::ClipOp::Intersect, true); - } else { - canvas.clip_rect(shape.selrect, skia::ClipOp::Intersect, true); - } - } - } - - // Reset matrix so the blur is applied in device space (sigma is already - // scaled by the zoom). Clips survive reset_matrix (stored in device coords). - canvas.reset_matrix(); - - // Apply the blur as a backdrop filter on a save_layer. A backdrop filter - // samples the *current device* contents (respecting the active clip), - // which includes whatever has been drawn so far — including any in-flight - // ancestor save_layer, such as a parent frame with opacity < 100% or a - // non-default blend mode. This way the background blur reflects the actual - // pixels behind the shape regardless of the layer stack. Src blend makes - // the layer replace the clipped region with the blurred backdrop instead - // of compositing over it (which would double-render the backdrop). - let mut paint = skia::Paint::default(); - paint.set_blend_mode(skia::BlendMode::Src); - let layer_rec = skia::canvas::SaveLayerRec::default() - .backdrop(&blur_filter) - .backdrop_tile_mode(skia::TileMode::Clamp) - .paint(&paint); - canvas.save_layer(&layer_rec); - - // Two restores are required, balancing two separate pushes: - // 1. this restore pops the save_layer above; it is the step that composites - // the blurred-backdrop layer back onto the canvas (with the Src paint), - // so it is what actually produces the blurred output. - // 2. the final restore pops the canvas.save() above, removing the shape clip - // and the scale/translate/transform so they don't leak into later drawing. - canvas.restore(); - canvas.restore(); + background_blur::render_background_blur(self, shape, target_surface) } /// Runs `f` with `ignore_nested_blurs` temporarily forced to `true`. @@ -973,64 +468,7 @@ impl RenderState { } pub fn apply_render_to_final_canvas(&mut self) -> Result<()> { - // During interactive transforms we render tiles directly into Target; updating the cache - // (snapshot -> atlas blit -> tiles.add) can force GPU stalls. Defer cache rebuild until - // the interaction ends. - if self.options.is_interactive_transform() { - let tile_rect = self.get_current_aligned_tile_bounds()?; - self.surfaces.draw_current_tile_into_backbuffer( - &tile_rect, - self.background_color, - surfaces::DrawOnCache::No, - ); - return Ok(()); - } - - // Viewer masked passes render a partial scene. Reusing the tile texture cache would - // SrcOver-blend onto textures from the previous pass and leak pixels into the blob. - // `render_sync_shape` (viewer/thumbnails) uses the same direct backbuffer path. - if self.viewer_masked_pass() || self.viewer_render_root.is_some() { - // Use viewbox-aligned bounds (not grid-snapped) to match interactive-transform - // compositing and avoid a visible offset vs the DOM canvas. - let tile_rect = self.get_current_tile_bounds()?; - self.surfaces.draw_current_tile_into_backbuffer( - &tile_rect, - self.background_color, - surfaces::DrawOnCache::No, - ); - return Ok(()); - } - - 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. - 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 - .as_ref() - .ok_or(Error::CriticalError("Current tile not found".to_string()))?; - - self.surfaces.draw_current_tile_into_tile_atlas( - &self.tile_viewbox, - ¤t_tile, - &tile_rect, - fast_mode, - self.render_area, - ); - - let rect = self.get_current_tile_bounds()?; - self.surfaces - .draw_cached_tile_into_backbuffer(current_tile, &rect); - - Ok(()) + cache::apply_render_to_final_canvas(self) } /// This function draws the "surface stack" into the specified "target" surface. @@ -1070,6 +508,11 @@ impl RenderState { .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) { @@ -1084,6 +527,9 @@ impl RenderState { 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| { @@ -1111,64 +557,28 @@ impl RenderState { } fn viewer_masked_pass(&self) -> bool { - self.include_filter.is_some() - } - - /// True when the shape or any descendant is whitelisted. - pub fn shape_visible_in_include_filter(&self, shape_id: &Uuid, tree: ShapesPoolRef) -> bool { - let Some(ref include) = self.include_filter else { - return true; - }; - if include.contains(shape_id) { - return true; - } - let Some(shape) = tree.get(shape_id) else { - return false; - }; - shape - .children_ids_iter(false) - .any(|child_id| self.shape_visible_in_include_filter(child_id, tree)) - } - - /// When an include whitelist is active, only those ids are painted. - fn shape_should_paint_for_viewer_layer(&self, shape_id: &Uuid) -> bool { - match &self.include_filter { - Some(include) => include.contains(shape_id), - None => true, - } - } - - /// Viewer layer mask: traverse whitelisted subtrees; paint only listed ids. - pub fn shape_visible_for_viewer_layer(&self, shape_id: &Uuid, tree: ShapesPoolRef) -> bool { - if self.viewer_render_root.as_ref() == Some(shape_id) { - return true; - } - self.shape_visible_in_include_filter(shape_id, tree) + view_mode::viewer_masked_pass(&self.include_filter) } fn get_inherited_drop_shadows(&self) -> Option> { - let drop_shadows: Vec<&Shadow> = self + let drop_shadows: Vec = 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() { - return None; + None + } else { + Some(drop_shadows) } - - Some( - drop_shadows - .into_iter() - .map(|shadow| { - let mut paint = skia_safe::Paint::default(); - let filter = shadow.get_drop_shadow_filter(); - paint.set_image_filter(filter); - paint - }) - .collect(), - ) } #[allow(clippy::too_many_arguments)] @@ -1209,7 +619,7 @@ impl RenderState { // 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(), self.options.antialias_threshold); + && shape.should_use_antialias(self.get_scale_fast(), self.options.antialias_threshold); let skip_effects = fast_mode; let has_nested_fills = self @@ -1242,7 +652,7 @@ impl RenderState { && target_surface != SurfaceId::Export; if can_render_directly { - let scale = self.get_scale(); + let scale = self.get_scale_fast(); let translation = self .surfaces .get_render_context_translation(self.render_area, scale); @@ -1285,8 +695,8 @@ impl RenderState { // set clipping if let Some(clips) = clip_bounds.as_ref() { - let scale = self.get_scale(); - for (mut bounds, corners, transform) in clips.iter() { + 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); }); @@ -1335,27 +745,28 @@ impl RenderState { // We don't want to change the value in the global state let mut shape: Cow = 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 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.to_mut().set_blur(None); + shape.set_blur(None); } } else if !frame_has_blur { if let Some(blur) = self.combined_layer_blur(shape.blur) { - shape.to_mut().set_blur(Some(blur)); + shape.set_blur(Some(blur)); } } else if shape_has_blur { - shape.to_mut().set_blur(None); + shape.set_blur(None); } if skip_effects { - shape.to_mut().set_blur(None); + shape.set_blur(None); } // For non-text, non-SVG shapes in the normal rendering path, apply blur @@ -1368,7 +779,7 @@ impl RenderState { && !matches!(shape.shape_type, Type::SVGRaw(_)) { if let Some(blur) = shape.blur.filter(|b| !b.hidden) { - shape.to_mut().set_blur(None); + shape.set_blur(None); Some(blur.sigma()) } else { None @@ -1405,7 +816,7 @@ impl RenderState { match dom_result { Ok(dom) => { dom.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id)); - shape.to_mut().set_svg(dom); + shape.set_svg(dom); } Err(e) => { eprintln!("Error parsing SVG. Error: {}", e); @@ -1433,7 +844,8 @@ impl RenderState { 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()); + 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); @@ -1456,7 +868,7 @@ impl RenderState { text::render( Some(self), None, - &shape, + shape, &mut paragraph_builders, Some(fills_surface_id), None, @@ -1477,7 +889,7 @@ impl RenderState { text::render_inner_stroke( Some(self), None, - &shape, + shape, &mut mask_builders, stroke_paragraphs, &mut fill_builders, @@ -1490,7 +902,7 @@ impl RenderState { text::render_with_bounds_outset( Some(self), None, - &shape, + shape, stroke_paragraphs, Some(strokes_surface_id), None, @@ -1534,7 +946,7 @@ impl RenderState { text::render( Some(self), None, - &shape, + shape, &mut paragraphs_with_shadows, text_drop_shadows_surface_id.into(), Some(&shadow), @@ -1546,7 +958,7 @@ impl RenderState { } else { shadows::render_text_shadows( self, - &shape, + shape, &mut paragraphs_with_shadows, &mut stroke_paragraphs_with_shadows_list, text_drop_shadows_surface_id.into(), @@ -1563,7 +975,7 @@ impl RenderState { text::render( Some(self), None, - &shape, + shape, &mut paragraphs_with_shadows, text_drop_shadows_surface_id.into(), Some(shadow), @@ -1578,7 +990,7 @@ impl RenderState { text::render( Some(self), None, - &shape, + shape, &mut paragraph_builders, Some(fills_surface_id), None, @@ -1590,7 +1002,7 @@ impl RenderState { // 3. Stroke drop shadows shadows::render_text_shadows( self, - &shape, + shape, &mut paragraphs_with_shadows, &mut stroke_paragraphs_with_shadows_list, text_drop_shadows_surface_id.into(), @@ -1614,7 +1026,7 @@ impl RenderState { text::render_inner_stroke( Some(self), None, - &shape, + shape, &mut mask_builders, stroke_paragraphs, &mut fill_builders, @@ -1627,7 +1039,7 @@ impl RenderState { text::render_with_bounds_outset( Some(self), None, - &shape, + shape, stroke_paragraphs, Some(strokes_surface_id), None, @@ -1642,7 +1054,7 @@ impl RenderState { // 5. Stroke inner shadows shadows::render_text_shadows( self, - &shape, + shape, &mut paragraphs_with_shadows, &mut stroke_paragraphs_with_shadows_list, Some(innershadows_surface_id), @@ -1658,7 +1070,7 @@ impl RenderState { text::render( Some(self), None, - &shape, + shape, &mut paragraphs_with_shadows, Some(innershadows_surface_id), Some(shadow), @@ -1771,12 +1183,12 @@ impl RenderState { }; if self.options.is_debug_visible() { - let shape_selrect_bounds = self.get_shape_selrect_bounds(&shape); + 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); + self.draw_shape_surface_stack_into(Some(shape), target_surface); } // Only restore if we saved (optimization for simple shapes) @@ -1805,332 +1217,11 @@ impl RenderState { } fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { - self.backbuffer_crop_cache.clear(); - - // Collect candidate shapes that are "recortable" and visible in the current viewport. - - // This is intentionally conservative; we only cache shapes that do not overlap with - // ANY other candidate to guarantee the pixels under their bounds belong exclusively - // to that shape in Backbuffer. - let viewport = self.viewbox.area; - let scale = self.get_scale(); - let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect) - - let root_ids: Vec = match tree.get(&Uuid::nil()) { - Some(root) => root.children_ids(false), - None => Vec::new(), - }; - - for shape_id in root_ids { - let Some(shape) = tree.get(&shape_id) else { - continue; - }; - if shape.hidden { - continue; - } - - let doc_bounds = self.get_cached_extrect(shape, tree, 1.0); - if !doc_bounds.intersects(viewport) { - continue; - } - - // Also require selrect to be visible; used for drag delta placement. - let selrect = shape.selrect(); - if !selrect.intersects(viewport) { - continue; - } - - candidates.push((shape.id, doc_bounds, selrect)); - } - - // Filter out any candidate that overlaps with any other candidate. - // Sort by left edge so the inner loop can break early once no further - // x-overlap is possible, reducing comparisons from O(N²) to O(N log N) - // in typical layouts where shapes are spread out. - candidates.sort_unstable_by(|a, b| { - a.1.left - .partial_cmp(&b.1.left) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let n = candidates.len(); - let mut is_overlapping = vec![false; n]; - for i in 0..n { - for j in (i + 1)..n { - if candidates[j].1.left >= candidates[i].1.right { - break; // sorted: no further x-overlap possible for i - } - if is_overlapping[i] && is_overlapping[j] { - continue; // both already excluded, skip check - } - if candidates[i].1.intersects(candidates[j].1) { - is_overlapping[i] = true; - is_overlapping[j] = true; - } - } - } - let non_overlapping: Vec<(Uuid, Rect, Rect)> = candidates - .iter() - .zip(is_overlapping.iter()) - .filter_map(|((id, bounds, selrect), ov)| { - if !ov { - Some((*id, *bounds, *selrect)) - } else { - None - } - }) - .collect(); - - let vb_left = self.viewbox.area.left; - let vb_top = self.viewbox.area.top; - let (bb_w, bb_h) = self.surfaces.surface_size(SurfaceId::Backbuffer); - let max_snap_px = get_gpu_state().max_texture_size(); - - // Snapshot the atlas once for the whole pass so that all shapes sharing - // the tile/atlas fallback path reuse the same GPU image rather than each - // triggering a separate `image_snapshot` flush. - let atlas_snap = self.surfaces.atlas.snapshot_for_drag_crop(); - - // Scratch surface reused across all shapes that need the tile/atlas - // fallback — avoids one WebGL texture allocation per shape. - // Created lazily on first use and grown if a later shape needs more space. - let mut scratch_surface: Option = None; - - for (id, doc_bounds, selrect) in non_overlapping { - let left = ((doc_bounds.left - vb_left) * scale).floor() as i32; - let top = ((doc_bounds.top - vb_top) * scale).floor() as i32; - let right = ((doc_bounds.right - vb_left) * scale).ceil() as i32; - let bottom = ((doc_bounds.bottom - vb_top) * scale).ceil() as i32; - if right <= left || bottom <= top { - continue; - } - let src_irect = skia::IRect::new(left, top, right, bottom); - - let src_doc_bounds = Rect::new( - src_irect.left as f32 / scale + vb_left, - src_irect.top as f32 / scale + vb_top, - src_irect.right as f32 / scale + vb_left, - src_irect.bottom as f32 / scale + vb_top, - ); - - let full_w = src_irect.width(); - let full_h = src_irect.height(); - let (win_ox, win_oy, win_w, win_h) = drag_crop_snapshot_window_px( - max_snap_px, - full_w, - full_h, - viewport, - vb_left, - vb_top, - scale, - src_irect.left, - src_irect.top, - src_doc_bounds, - ); - let window_irect = skia::IRect::new( - src_irect.left + win_ox, - src_irect.top + win_oy, - src_irect.left + win_ox + win_w, - src_irect.top + win_oy + win_h, - ); - - let src_doc_window = Rect::new( - window_irect.left as f32 / scale + vb_left, - window_irect.top as f32 / scale + vb_top, - window_irect.right as f32 / scale + vb_left, - window_irect.bottom as f32 / scale + vb_top, - ); - - let in_backbuffer = window_irect.left >= 0 - && window_irect.top >= 0 - && window_irect.right <= bb_w - && window_irect.bottom <= bb_h; - - let backbuffer_snap = if in_backbuffer { - self.surfaces - .snapshot_rect(SurfaceId::Backbuffer, window_irect) - } else { - None - }; - - let image = if let Some(img) = backbuffer_snap { - img - } else { - // Ensure the scratch surface is large enough for this window. - // Grow (reallocate) only when necessary so that the common case - // of similarly-sized shapes pays zero extra allocation cost. - let needs_alloc = scratch_surface - .as_ref() - .is_none_or(|s| s.width() < win_w || s.height() < win_h); - if needs_alloc { - scratch_surface = get_gpu_state() - .create_surface_with_isize( - "drag_crop_scratch".to_string(), - skia::ISize::new(win_w, win_h), - ) - .ok(); - } - let Some(scratch) = scratch_surface.as_mut() else { - continue; - }; - let Some(img) = self.surfaces.try_snapshot_doc_rect_from_tiles_and_atlas( - scratch, - atlas_snap.as_ref(), - src_doc_window, - window_irect, - win_w, - win_h, - vb_left, - vb_top, - scale, - ) else { - continue; - }; - img - }; - - self.backbuffer_crop_cache.insert( - id, - InteractiveDragCrop { - src_doc_bounds: src_doc_window, - src_selrect: selrect, - capture_vb_left: vb_left, - capture_vb_top: vb_top, - capture_src_left: window_irect.left, - capture_src_top: window_irect.top, - image, - }, - ); - } + drag_crop::rebuild_backbuffer_crop_cache(self, tree) } pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { - let _start = performance::begin_timed_log!("render_from_cache"); - performance::begin_measure!("render_from_cache"); - let bg_color = self.background_color; - - // During fast mode (pan/zoom), if a previous full-quality render still has pending tiles, - // always prefer the persistent atlas. The atlas is incrementally updated as tiles finish, - // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. - if self.options.is_fast_mode() && !self.surfaces.atlas.is_empty() { - self.surfaces - .draw_atlas_to_backbuffer(self.viewbox, bg_color); - - // For pure pan (same zoom), overlay any cached per-tile textures on top of the atlas. - // This reduces the "rubbery"/distorted look of atlas-only previews while keeping - // fast-mode responsive. For zoom, the tile grid changes so cached tiles would be - // mispositioned — skip them. - if !self.zoom_changed() { - let visible_rect = tiles::get_tiles_for_viewbox(&self.viewbox); - let offset = self.viewbox.get_offset(); - for tx in visible_rect.x1()..=visible_rect.x2() { - for ty in visible_rect.y1()..=visible_rect.y2() { - let tile = tiles::Tile::from(tx, ty); - if self.surfaces.has_cached_tile_surface(tile) { - let rect = tile.get_rect_with_offset(&offset); - self.surfaces.draw_cached_tile_into_backbuffer(tile, &rect); - } - } - } - } - - self.present_frame(shapes); - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); - return; - } - - // Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache) - if self.cached_viewbox.area.width() > 0.0 { - // Scale and translate the target according to the cached data - let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom; - - let interest = self.options.dpr_viewport_interest_area_threshold; - let TileRect(start_tile_x, start_tile_y, _, _) = - tiles::get_tiles_for_viewbox_with_interest(&self.cached_viewbox, interest); - let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr; - let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr; - let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; - let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y; - - // For zoom-out, prefer cache only if it fully covers the viewport. - // Otherwise, atlas will provide a more correct full-viewport preview. - let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom; - if zooming_out { - let cache_dim = self.surfaces.cache_dimensions(); - let cache_w = cache_dim.width as f32; - let cache_h = cache_dim.height as f32; - - // Viewport in target pixels. - let vw = self.viewbox.dpr_width().max(1.0); - let vh = self.viewbox.dpr_height().max(1.0); - - // Inverse-map viewport corners into cache coordinates. - // target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords). - // => cache = (target / navigate_zoom) - translate - let inv = if navigate_zoom.abs() > f32::EPSILON { - 1.0 / navigate_zoom - } else { - 0.0 - }; - - // let cx0 = (0.0 * inv) - translate_x; - // let cy0 = (0.0 * inv) - translate_y; - // NOTA: 0.0 * inv => siempre 0 - let cx0 = -translate_x; - let cy0 = -translate_y; - let cx1 = (vw * inv) - translate_x; - let cy1 = (vh * inv) - translate_y; - - let min_x = cx0.min(cx1); - let min_y = cy0.min(cy1); - let max_x = cx0.max(cx1); - let max_y = cy0.max(cy1); - - let cache_covers = - min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h; - if !cache_covers { - // Early return only if atlas exists; otherwise keep cache path. - if !self.surfaces.atlas.is_empty() { - self.surfaces - .draw_atlas_to_backbuffer(self.viewbox, bg_color); - - self.present_frame(shapes); - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); - return; - } - } - } - - // Draw directly from cache surface, avoiding snapshot overhead - self.surfaces.draw_cache_to_backbuffer(); - - // During pure pan (same zoom), draw tiles from the HashMap - // on top of the scaled Cache surface. Cached tile textures - // include full-quality effects (shadows, blur) from the last - // render, so blitting them avoids re-rendering and keeps pan - // smooth. During zoom the tile grid changes so HashMap tiles - // would be at wrong positions — skip them and let the full - // render after set_view_end handle it. - if !self.zoom_changed() { - let visible_rect = tiles::get_tiles_for_viewbox(&self.viewbox); - let offset = self.viewbox.get_offset(); - for tx in visible_rect.x1()..=visible_rect.x2() { - for ty in visible_rect.y1()..=visible_rect.y2() { - let tile = tiles::Tile::from(tx, ty); - if self.surfaces.has_cached_tile_surface(tile) { - let rect = tile.get_rect_with_offset(&offset); - self.surfaces.draw_cached_tile_into_backbuffer(tile, &rect); - } - } - } - } - - self.present_frame(shapes); - } - - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); + cache::render_from_cache(self, shapes) } /// Render a preview of the shapes during loading. @@ -2168,19 +1259,31 @@ impl RenderState { self.surfaces.gc(); self.pending_nodes.clear(); - if self.pending_nodes.capacity() < tree.len() { - self.pending_nodes - .reserve(tree.len() - self.pending_nodes.capacity()); - } + 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( @@ -2191,6 +1294,7 @@ impl RenderState { sync_render: bool, ) -> Result { 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(); @@ -2236,6 +1340,13 @@ impl RenderState { self.interactive_target_seeded = false; } + // 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 @@ -2316,7 +1427,7 @@ impl RenderState { let Some(shape) = tree.get(id) else { continue; }; - let r = self.get_cached_extrect(shape, tree, 1.0); + let r = shape.extrect(tree, 1.0); if r.is_empty() { continue; } @@ -2397,103 +1508,7 @@ impl RenderState { scale: f32, timestamp: i32, ) -> Result<(Vec, i32, i32)> { - let target_surface = SurfaceId::Export; - - // `render_shape_pixels` is used by the workspace to render thumbnails using the - // same WASM renderer instance. It must not leak any state into the main - // viewport renderer (tile cache, atlas, focus mode, render context, etc.). - // - // In particular, `update_render_context` clears and reconfigures multiple - // render surfaces, and `render_area` drives atlas blits. If we don't restore - // them, the workspace can temporarily show missing tiles until the next - // interaction (e.g. zoom) forces a full context rebuild. - 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_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); - let saved_nested_blurs = std::mem::take(&mut self.nested_blurs); - let saved_nested_shadows = std::mem::take(&mut self.nested_shadows); - let saved_ignore_nested_blurs = self.ignore_nested_blurs; - let saved_preview_mode = self.preview_mode; - - // Reset focus mode so all shapes in the export tree are rendered. - // Without this, leftover focus_mode state from the workspace could - // cause shapes (and their background blur) to be skipped. - self.focus_mode.clear(); - - self.surfaces - .canvas(target_surface) - .clear(skia::Color::TRANSPARENT); - - if tree.len() != 0 { - let Some(shape) = tree.get(id) else { - // FIXME - return Ok((Vec::new(), 0, 0)); - }; - let mut extrect = shape.extrect(tree, scale); - self.export_context = Some((extrect, scale)); - let margins = self.surfaces.margins; - extrect.offset((margins.width as f32 / scale, margins.height as f32 / scale)); - - self.surfaces.resize_export_surface(scale, extrect); - self.render_area = extrect; - self.render_area_with_margins = extrect; - self.surfaces.update_render_context(extrect, scale); - - self.pending_nodes.push(NodeRenderState { - id: *id, - visited_children: false, - clip_bounds: None, - visited_mask: false, - mask: false, - flattened: false, - }); - self.render_shape_tree_partial_uncached(tree, timestamp, false, true)?; - } - - // Clear export context so get_scale() returns to workspace zoom. - self.export_context = None; - - self.surfaces.flush_and_submit(target_surface); - - let image = self.surfaces.snapshot(target_surface); - let data = image - .encode( - Some(&mut get_gpu_state().context), - skia::EncodedImageFormat::PNG, - 100, - ) - .expect("PNG encode failed"); - let skia::ISize { width, height } = image.dimensions(); - - // Restore the workspace render state. - 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.current_tile = saved_current_tile; - self.pending_nodes = saved_pending_nodes; - self.nested_fills = saved_nested_fills; - self.nested_blurs = saved_nested_blurs; - self.nested_shadows = saved_nested_shadows; - self.ignore_nested_blurs = saved_ignore_nested_blurs; - self.preview_mode = saved_preview_mode; - - // Restore render-surface transforms for the workspace context. - // If we have a current tile, restore its tile render context; otherwise - // fall back to restoring the previous render_area (may be empty). - let workspace_scale = self.get_scale(); - if let Some(tile) = self.current_tile { - self.update_render_context(tile); - } else if !self.render_area.is_empty() { - self.surfaces - .update_render_context(self.render_area, workspace_scale); - } - - Ok((data.as_bytes().to_vec(), width, height)) + export::render_shape_pixels(self, id, tree, scale, timestamp) } #[inline] @@ -2532,7 +1547,7 @@ impl RenderState { .surfaces .get_render_context_translation(self.render_area, scale); - for (bounds, corners, transform) in clips.iter() { + 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 { @@ -2556,7 +1571,7 @@ impl RenderState { } self.surfaces .canvas(target_surface) - .concat(&total_matrix.invert().unwrap_or_default()); + .concat(inverse_transform); } } @@ -2567,89 +1582,7 @@ impl RenderState { clip_bounds: Option<&ClipStack>, target_surface: SurfaceId, ) { - // Masked groups needs two rendering passes, the first one rendering - // the content and the second one rendering the mask so we need to do - // an extra save_layer to keep all the masked group separate from - // other already drawn elements. - if let Type::Group(group) = element.shape_type { - let fills = &element.fills; - let shadows = &element.shadows; - self.nested_fills.push(fills.to_vec()); - self.nested_shadows.push(shadows.to_vec()); - - if group.masked { - // A masked group's blur is applied as a single layer blur over - // the whole masked result. - let mask_group_blur = element.masked_group_layer_blur().is_some(); - if mask_group_blur { - self.surfaces.canvas(target_surface).save(); - if let Some(clips) = clip_bounds { - let scale = self.get_scale(); - let antialias = !self.options.is_fast_mode() - && element - .should_use_antialias(scale, self.options.antialias_threshold); - self.clip_target_surface_to_stack(clips, target_surface, scale, antialias); - } - } - - let mut paint = skia::Paint::default(); - if !self.options.is_fast_mode() { - if let Some(blur) = element.masked_group_layer_blur() { - let scale = self.get_scale(); - let sigma = radius_to_sigma(blur.value * scale); - if let Some(filter) = - skia::image_filters::blur((sigma, sigma), None, None, None) - { - paint.set_image_filter(filter); - } - } - } - - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - self.surfaces.canvas(target_surface).save_layer(&layer_rec); - } - } - - if let Type::Frame(_) = element.shape_type { - self.nested_fills.push(Vec::new()); - } - - // When we're rendering the mask shape we need to set a special blend mode - // called 'destination-in' that keeps the drawn content within the mask. - // @see https://skia.org/docs/user/api/skblendmode_overview/ - if mask { - let mut mask_paint = skia::Paint::default(); - mask_paint.set_blend_mode(skia::BlendMode::DstIn); - let mask_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); - self.surfaces.canvas(target_surface).save_layer(&mask_rec); - } - - // Only create save_layer if actually needed - // For simple shapes with default opacity and blend mode, skip expensive save_layer - // Groups with masks need a layer to properly handle the mask rendering - let needs_layer = element.needs_layer(); - - if needs_layer { - let mut paint = skia::Paint::default(); - paint.set_blend_mode(element.blend_mode().into()); - paint.set_alpha_f(element.opacity()); - - // Skip frame-level blur in fast mode (pan/zoom). - if !self.options.is_fast_mode() { - if let Some(frame_blur) = Self::frame_clip_layer_blur(element) { - let scale = self.get_scale(); - let sigma = radius_to_sigma(frame_blur.value * scale); - if let Some(filter) = - skia::image_filters::blur((sigma, sigma), None, None, None) - { - paint.set_image_filter(filter); - } - } - } - - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - self.surfaces.canvas(target_surface).save_layer(&layer_rec); - } + enter_exit::render_shape_enter(self, element, mask, clip_bounds, target_surface) } #[inline] @@ -2660,111 +1593,7 @@ impl RenderState { clip_bounds: Option, target_surface: SurfaceId, ) -> Result<()> { - if visited_mask { - // Because masked groups needs two rendering passes (first drawing - // the content and then drawing the mask), we need to do an - // extra restore. - if let Type::Group(group) = element.shape_type { - if group.masked { - self.surfaces.canvas(target_surface).restore(); - } - } - } else { - // !visited_mask - if let Type::Group(group) = element.shape_type { - // When we're dealing with masked groups we need to - // do a separate extra step to draw the mask (the last - // element of a masked group) and blend (using - // the blend mode 'destination-in') the content - // of the group and the mask. - if group.masked { - self.pending_nodes.push(NodeRenderState { - id: element.id, - visited_children: true, - clip_bounds: None, - visited_mask: true, - mask: false, - flattened: false, - }); - if let Some(&mask_id) = element.mask_id() { - self.pending_nodes.push(NodeRenderState { - id: mask_id, - visited_children: false, - clip_bounds: None, - visited_mask: false, - mask: true, - flattened: false, - }); - } - } - } - } - - match element.shape_type { - Type::Frame(_) | Type::Group(_) => { - self.nested_fills.pop(); - self.nested_blurs.pop(); - self.nested_shadows.pop(); - } - _ => {} - } - - // Strokes are drawn over children for clipped frames (all strokes), and for non-clipped - // frames with inner strokes (inner strokes only — non-inner were rendered before children). - // Skip when focus mode excludes this subtree (focus_mode.exit runs after this, so - // is_active() still reflects this element's focus state here). - let needs_exit_strokes = self.focus_mode.is_active() - && (element.clip() - || (matches!(element.shape_type, Type::Frame(_)) && element.has_inner_stroke())); - - if needs_exit_strokes { - let mut element_strokes: Cow = Cow::Borrowed(element); - element_strokes.to_mut().clear_fills(); - element_strokes.to_mut().clear_shadows(); - element_strokes.to_mut().clip_content = false; - - // For non-clipped frames, non-inner strokes were already rendered inline. - if !element.clip() { - let is_open = element.is_open(); - element_strokes - .to_mut() - .strokes - .retain(|s| s.render_kind(is_open) == StrokeKind::Inner); - } - - // Frame blur is applied at the save_layer level - avoid double blur on the stroke paint - if Self::frame_clip_layer_blur(element).is_some() { - element_strokes.to_mut().set_blur(None); - } - self.render_shape( - &element_strokes, - clip_bounds, - SurfaceId::Fills, - SurfaceId::Strokes, - SurfaceId::InnerShadows, - SurfaceId::TextDropShadows, - true, - None, - None, - None, - target_surface, - )?; - } - - // Only restore if we created a layer (optimization for simple shapes) - // Groups with masks need restore to properly handle the mask rendering - let needs_layer = element.needs_layer(); - - if needs_layer { - self.surfaces.canvas(target_surface).restore(); - } - - if visited_mask && element.masked_group_layer_blur().is_some() { - self.surfaces.canvas(target_surface).restore(); - } - - self.focus_mode.exit(&element.id); - Ok(()) + enter_exit::render_shape_exit(self, element, visited_mask, clip_bounds, target_surface) } pub fn get_current_tile_bounds(&mut self) -> Result { @@ -2794,7 +1623,7 @@ impl RenderState { pub fn get_shape_extrect_bounds(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Rect { let scale = self.get_scale(); - let rect = self.get_cached_extrect(shape, tree, scale); + let rect = shape.extrect(tree, scale); self.get_rect_bounds(rect) } @@ -2827,232 +1656,6 @@ impl RenderState { )) } - /// Renders a drop shadow effect for the given shape. - /// - /// Creates a black shadow by converting the original shadow color to black, - /// scaling the blur radius, and rendering the shape with the shadow offset applied. - #[allow(clippy::too_many_arguments)] - fn render_drop_black_shadow( - &mut self, - shape: &Shape, - shape_bounds: &Rect, - shadow: &Shadow, - clip_bounds: Option, - scale: f32, - extra_layer_blur: Option, - target_surface: SurfaceId, - ) -> Result<()> { - let mut transformed_shadow: Cow = Cow::Borrowed(shadow); - transformed_shadow.to_mut().offset = (0.0, 0.0); - transformed_shadow.to_mut().color = skia::Color::BLACK; - - let mut plain_shape = Cow::Borrowed(shape); - let combined_blur = - Self::combine_blur_values(self.combined_layer_blur(shape.blur), extra_layer_blur); - let blur_filter = combined_blur.and_then(|blur| { - let sigma = blur.sigma(); - skia::image_filters::blur((sigma, sigma), None, None, None) - }); - - let use_low_zoom_path = scale <= 1.0 && combined_blur.is_none(); - let mut transform_matrix = shape.transform; - let center = shape.center(); - // Re-center the matrix so rotations/scales happen around the shape center, - // matching how the shape itself is rendered. - transform_matrix.post_translate(center); - transform_matrix.pre_translate(-center); - - // Transform the local shadow offset into world coordinates so that rotations/scales - // applied to the shape are respected when positioning the shadow. - let mapped = transform_matrix.map_vector((shadow.offset.0, shadow.offset.1)); - let world_offset = (mapped.x, mapped.y); - - // The opacity of fills and strokes shouldn't affect the shadow, - // so we paint everything black with the same opacity. - let plain_shape_mut = plain_shape.to_mut(); - plain_shape_mut.clear_fills(); - if shape.has_fills() { - plain_shape_mut.add_fill(Fill::Solid(SolidColor(skia::Color::BLACK))); - } - - // Reuse existing strokes and only override their fill color. - for stroke in plain_shape_mut.strokes.iter_mut() { - stroke.fill = Fill::Solid(SolidColor(skia::Color::BLACK)); - } - - plain_shape_mut.clear_shadows(); - plain_shape_mut.blur = None; - plain_shape_mut.background_blur = None; - - // Shadow rendering uses a single render_shape call with no render_shape_exit, - // so strokes must be drawn here. Disable clip_content to avoid skip_strokes - // (which defers strokes to render_shape_exit for clipped frames). - plain_shape_mut.clip_content = false; - - let Some(drop_filter) = transformed_shadow.get_drop_shadow_filter() else { - return Ok(()); - }; - - let mut bounds = drop_filter.compute_fast_bounds(shape_bounds); - // 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 - { - return Ok(()); - } - - // blur=0 at high zoom: draw directly on DropShadows with geometric spread (no filter). - if scale > 1.0 && shadow.blur <= 0.0 { - let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows); - drop_canvas.save(); - //drop_canvas.scale((scale, scale)); - //drop_canvas.translate(translation); - - self.with_nested_blurs_suppressed(|state| { - state.render_shape( - &plain_shape, - clip_bounds, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - false, - Some(shadow.offset), - None, - Some(shadow.spread), - target_surface, - ) - })?; - - self.surfaces.canvas(SurfaceId::DropShadows).restore(); - return Ok(()); - } - - // Create filter with blur only (no offset, no spread - handled geometrically) - let blur_only_filter = if transformed_shadow.blur > 0.0 { - let sigma = radius_to_sigma(transformed_shadow.blur); - Some(skia::image_filters::blur((sigma, sigma), None, None, None)) - } else { - None - }; - - let mut shadow_paint = skia::Paint::default(); - if let Some(blur_filter) = blur_only_filter { - shadow_paint.set_image_filter(blur_filter); - } - shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); - - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint); - - // Low zoom path: use blur filter but apply offset and spread geometrically - if use_low_zoom_path { - let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows); - drop_canvas.save_layer(&layer_rec); - //drop_canvas.scale((scale, scale)); - //drop_canvas.translate(translation); - - self.with_nested_blurs_suppressed(|state| { - state.render_shape( - &plain_shape, - clip_bounds, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - false, - Some(shadow.offset), // Offset is geometric - None, - Some(shadow.spread), - target_surface, - ) - })?; - - self.surfaces.canvas(SurfaceId::DropShadows).restore(); - return Ok(()); - } - - // Adaptive downscale for large blur values (lossless GPU optimization). - // Bounds above were computed from the original sigma so filter surface coverage is correct. - // Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8x): beyond that the - // filter surface becomes too small and quality degrades noticeably. - let blur_downscale_threshold: f32 = self.options.blur_downscale_threshold; - let min_blur_downscale: f32 = 1.0 / blur_downscale_threshold; - let blur_downscale = if shadow.blur > blur_downscale_threshold { - (blur_downscale_threshold / shadow.blur).max(min_blur_downscale) - } else { - 1.0 - }; - - // High zoom with blur: use render_into_filter_surface to ensure blur has enough space - // Apply spread geometrically to avoid dilate filter rounding issues - let filter_result = filters::render_into_filter_surface( - self, - bounds, - blur_downscale, - |state, temp_surface| { - let canvas = state.surfaces.canvas(temp_surface); - canvas.save_layer(&layer_rec); - - state.with_nested_blurs_suppressed(|state| { - // Apply offset and spread geometrically - state.render_shape( - &plain_shape, - clip_bounds, - temp_surface, - temp_surface, - temp_surface, - temp_surface, - false, - Some(shadow.offset), // Offset is geometric - None, - Some(shadow.spread), - target_surface, - ) - })?; - - state.surfaces.canvas(temp_surface).restore(); - Ok(()) - }, - )?; - - if let Some((mut surface, filter_scale)) = filter_result { - let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows); - drop_canvas.save(); - //drop_canvas.scale((scale, scale)); - //drop_canvas.translate(translation); - let mut drop_paint = skia::Paint::default(); - drop_paint.set_image_filter(blur_filter.clone()); - - // If we scaled down in the filter surface, we need to scale back up - if filter_scale < 1.0 { - drop_canvas.save(); - drop_canvas.scale((1.0 / filter_scale, 1.0 / filter_scale)); - drop_canvas.translate((bounds.left * filter_scale, bounds.top * filter_scale)); - surface.draw( - drop_canvas, - (0.0, 0.0), - self.sampling_options, - Some(&drop_paint), - ); - drop_canvas.restore(); - } else { - drop_canvas.save(); - drop_canvas.translate((bounds.left, bounds.top)); - surface.draw( - drop_canvas, - (0.0, 0.0), - self.sampling_options, - Some(&drop_paint), - ); - drop_canvas.restore(); - } - drop_canvas.restore(); - } - - Ok(()) - } - /// 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)] @@ -3066,121 +1669,16 @@ impl RenderState { node_render_state: &NodeRenderState, target_surface: SurfaceId, ) -> Result<()> { - let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale)); - let inherited_layer_blur = match element.shape_type { - Type::Frame(_) | Type::Group(_) => element.blur, - _ => None, - }; - - for shadow in element.drop_shadows_visible() { - let paint = skia::Paint::default(); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - self.surfaces - .canvas(SurfaceId::DropShadows) - .save_layer(&layer_rec); - - self.render_drop_black_shadow( - element, - element_extrect, - shadow, - clip_bounds.clone(), - scale, - None, - target_surface, - )?; - - if !matches!(element.shape_type, Type::Bool(_)) { - let shadow_children = if element.is_recursive() { - get_simplified_children(tree, element) - } else { - Vec::new() - }; - - for shadow_shape_id in shadow_children.iter() { - let Some(shadow_shape) = tree.get(shadow_shape_id) else { - continue; - }; - if shadow_shape.hidden { - continue; - } - - let nested_clip_bounds = - node_render_state.get_nested_shadow_clip_bounds(element, shadow); - - if !matches!(shadow_shape.shape_type, Type::Text(_)) { - self.render_drop_black_shadow( - shadow_shape, - &shadow_shape.extrect(tree, scale), - shadow, - nested_clip_bounds, - scale, - inherited_layer_blur, - target_surface, - )?; - } else { - let paint = skia::Paint::default(); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - self.surfaces - .canvas(SurfaceId::DropShadows) - .save_layer(&layer_rec); - - let mut transformed_shadow: Cow = Cow::Borrowed(shadow); - transformed_shadow.to_mut().color = skia::Color::BLACK; - transformed_shadow.to_mut().blur = transformed_shadow.blur; - transformed_shadow.to_mut().spread = transformed_shadow.spread; - - let mut new_shadow_paint = skia::Paint::default(); - new_shadow_paint - .set_image_filter(transformed_shadow.get_drop_shadow_filter()); - new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); - - self.with_nested_blurs_suppressed(|state| { - state.render_shape( - shadow_shape, - nested_clip_bounds, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - true, - None, - Some(vec![new_shadow_paint.clone()]), - None, - target_surface, - ) - })?; - self.surfaces.canvas(SurfaceId::DropShadows).restore(); - } - } - } - - let mut paint = skia::Paint::default(); - paint.set_color(shadow.color); - paint.set_blend_mode(skia::BlendMode::SrcIn); - self.surfaces - .canvas(SurfaceId::DropShadows) - .draw_paint(&paint); - - self.surfaces.canvas(SurfaceId::DropShadows).restore(); - } - - if let Some(clips) = clip_bounds.as_ref() { - let antialias = !self.options.is_fast_mode() - && element.should_use_antialias(scale, self.options.antialias_threshold); - self.surfaces.canvas(target_surface).save(); - self.clip_target_surface_to_stack(clips, target_surface, scale, antialias); - self.surfaces - .draw_into(SurfaceId::DropShadows, target_surface, None); - self.surfaces.canvas(target_surface).restore(); - } else { - self.surfaces - .draw_into(SurfaceId::DropShadows, target_surface, None); - } - - self.surfaces - .canvas(SurfaceId::DropShadows) - .clear(skia::Color::TRANSPARENT); - Ok(()) + 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( @@ -3211,7 +1709,7 @@ impl RenderState { 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); + let r = s.extrect(tree, 1.0); acc = Some(match acc { None => r, Some(mut prev) => { @@ -3225,7 +1723,7 @@ impl RenderState { // 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); + let r0 = raw.extrect(tree, 1.0); acc = Some(match acc { None => r0, Some(mut prev) => { @@ -3254,7 +1752,7 @@ impl RenderState { // Skip it for this pass; a subsequent render will pick it up once present. continue; }; - let scale = self.get_scale(); + let scale = self.get_scale_fast(); let mut extrect: Option = None; // If the shape is not in the tile set, then we add them. @@ -3277,14 +1775,25 @@ impl RenderState { continue; } - if !self.shape_visible_for_viewer_layer(&node_id, tree) { + 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() - && self.shape_visible_for_viewer_layer(&node_id, tree) - && !self.shape_should_paint_for_viewer_layer(&node_id) + && 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<_> = @@ -3361,7 +1870,7 @@ impl RenderState { ), None => (0.0, 0.0), }; - let scale = self.get_scale(); + let scale = self.get_scale_fast(); let translation = self .surfaces .get_render_context_translation(self.render_area, scale); @@ -3511,12 +2020,15 @@ impl RenderState { 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; } _ => {} } @@ -3546,13 +2058,12 @@ impl RenderState { clip_inset_for_children, ); - let children_ids: Vec<_> = if can_flatten { - // Container was flattened: get simplified children (which skip this level) - get_simplified_children(tree, element) + let mut children_ids: Vec<_> = Vec::new(); + if can_flatten { + get_simplified_children(tree, element, &mut children_ids); } else { - // Container not flattened: use original children - element.children_ids_iter(false).copied().collect() - }; + children_ids = element.children_ids_iter(false).copied().collect(); + } let children_ids = sort_z_index(tree, element, children_ids); @@ -3757,7 +2268,7 @@ impl RenderState { */ pub fn get_tiles_for_shape(&mut self, shape: &Shape, tree: ShapesPoolRef) -> TileRect { let scale = self.get_scale(); - let extrect = self.get_cached_extrect(shape, tree, 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; @@ -4076,6 +2587,13 @@ impl RenderState { 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 } @@ -4089,10 +2607,6 @@ impl RenderState { self.touched_ids.clear(); } - pub fn get_cached_extrect(&mut self, shape: &Shape, tree: ShapesPoolRef, scale: f32) -> Rect { - shape.extrect(tree, scale) - } - pub fn set_view(&mut self, zoom: f32, x: f32, y: f32) { self.viewbox.set_all(zoom, x, y); } diff --git a/render-wasm/src/render/background_blur.rs b/render-wasm/src/render/background_blur.rs new file mode 100644 index 0000000000..f811c3efa0 --- /dev/null +++ b/render-wasm/src/render/background_blur.rs @@ -0,0 +1,91 @@ +use super::{RenderState, SurfaceId}; +use crate::shapes::{radius_to_sigma, Shape, Type}; +use skia_safe::{self as skia, RRect}; + +pub fn render_background_blur( + render_state: &mut RenderState, + shape: &Shape, + target_surface: SurfaceId, +) { + if render_state.options.is_fast_mode() { + return; + } + if matches!(shape.shape_type, Type::Text(_)) || matches!(shape.shape_type, Type::SVGRaw(_)) { + return; + } + let blur = match shape + .blur + .filter(|b| !b.hidden && b.blur_type == crate::shapes::BlurType::BackgroundBlur) + { + Some(blur) => blur, + None => return, + }; + + let scale = render_state.get_scale(); + let scaled_sigma = radius_to_sigma(blur.value * scale); + let sigma = if render_state.export_context.is_some() { + scaled_sigma + } else { + let margin = render_state.surfaces.margins().width as f32; + let max_sigma = margin / 3.0; + scaled_sigma.min(max_sigma) + }; + + let blur_filter = + match skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None) { + Some(filter) => filter, + None => return, + }; + + let target_surface_snapshot = render_state.surfaces.snapshot(target_surface); + let translation = render_state + .surfaces + .get_render_context_translation(render_state.render_area, scale); + + let center = shape.center(); + let mut matrix = shape.transform; + matrix.post_translate(center); + matrix.pre_translate(-center); + + let canvas = render_state.surfaces.canvas(target_surface); + canvas.save(); + + canvas.scale((scale, scale)); + canvas.translate(translation); + canvas.concat(&matrix); + + match &shape.shape_type { + Type::Rect(data) if data.corners.is_some() => { + let rrect = RRect::new_rect_radii(shape.selrect, data.corners.as_ref().unwrap()); + canvas.clip_rrect(rrect, skia::ClipOp::Intersect, true); + } + Type::Frame(data) if data.corners.is_some() => { + let rrect = RRect::new_rect_radii(shape.selrect, data.corners.as_ref().unwrap()); + canvas.clip_rrect(rrect, skia::ClipOp::Intersect, true); + } + Type::Rect(_) | Type::Frame(_) => { + canvas.clip_rect(shape.selrect, skia::ClipOp::Intersect, true); + } + Type::Circle => { + let mut pb = skia::PathBuilder::new(); + pb.add_oval(shape.selrect, None, None); + canvas.clip_path(&pb.detach(), skia::ClipOp::Intersect, true); + } + _ => { + if let Some(path) = shape.get_skia_path() { + canvas.clip_path(&path, skia::ClipOp::Intersect, true); + } else { + canvas.clip_rect(shape.selrect, skia::ClipOp::Intersect, true); + } + } + } + + canvas.reset_matrix(); + + let mut paint = skia::Paint::default(); + paint.set_image_filter(blur_filter); + paint.set_blend_mode(skia::BlendMode::Src); + canvas.draw_image(&target_surface_snapshot, (0, 0), Some(&paint)); + + canvas.restore(); +} diff --git a/render-wasm/src/render/cache.rs b/render-wasm/src/render/cache.rs new file mode 100644 index 0000000000..335346ef1b --- /dev/null +++ b/render-wasm/src/render/cache.rs @@ -0,0 +1,148 @@ +use crate::error::{Error, Result}; +use crate::state::ShapesPoolRef; +use crate::tiles; + +pub fn apply_render_to_final_canvas(render_state: &mut crate::render::RenderState) -> Result<()> { + if render_state.options.is_interactive_transform() { + let tile_rect = render_state.get_current_aligned_tile_bounds()?; + render_state.surfaces.draw_current_tile_into_backbuffer( + &tile_rect, + render_state.background_color, + super::surfaces::DrawOnCache::No, + ); + return Ok(()); + } + + if render_state.viewer_masked_pass() { + let tile_rect = render_state.get_current_tile_bounds()?; + render_state.surfaces.draw_current_tile_into_backbuffer( + &tile_rect, + render_state.background_color, + super::surfaces::DrawOnCache::No, + ); + return Ok(()); + } + + let fast_mode = render_state.options.is_fast_mode(); + if !fast_mode && !render_state.cache_cleared_this_render { + render_state + .surfaces + .clear_cache(render_state.background_color); + render_state.cache_cleared_this_render = true; + } + let tile_rect = render_state.get_current_aligned_tile_bounds()?; + + let current_tile = *render_state + .current_tile + .as_ref() + .ok_or(Error::CriticalError("Current tile not found".to_string()))?; + + render_state.surfaces.draw_current_tile_into_tile_atlas( + &render_state.tile_viewbox, + ¤t_tile, + &tile_rect, + fast_mode, + render_state.render_area, + ); + + let rect = render_state.get_current_tile_bounds()?; + render_state + .surfaces + .draw_cached_tile_into_backbuffer(current_tile, &rect); + + Ok(()) +} + +pub fn render_from_cache(render_state: &mut crate::render::RenderState, shapes: ShapesPoolRef) { + let _start = crate::performance::begin_timed_log!("render_from_cache"); + crate::performance::begin_measure!("render_from_cache"); + let bg_color = render_state.background_color; + + if render_state.options.is_fast_mode() && !render_state.surfaces.atlas.is_empty() { + render_state + .surfaces + .draw_atlas_to_backbuffer(render_state.viewbox, bg_color); + + render_state.present_frame(shapes); + crate::performance::end_measure!("render_from_cache"); + crate::performance::end_timed_log!("render_from_cache", _start); + return; + } + + if render_state.cached_viewbox.area.width() > 0.0 { + let navigate_zoom = render_state.viewbox.zoom / render_state.cached_viewbox.zoom; + + let interest = render_state.options.dpr_viewport_interest_area_threshold; + let tiles::TileRect(start_tile_x, start_tile_y, _, _) = + tiles::get_tiles_for_viewbox_with_interest(&render_state.cached_viewbox, interest); + let offset_x = render_state.viewbox.area.left + * render_state.cached_viewbox.zoom + * render_state.options.dpr; + let offset_y = render_state.viewbox.area.top + * render_state.cached_viewbox.zoom + * render_state.options.dpr; + let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; + let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y; + + let zooming_out = render_state.viewbox.zoom < render_state.cached_viewbox.zoom; + if zooming_out { + let cache_dim = render_state.surfaces.cache_dimensions(); + let cache_w = cache_dim.width as f32; + let cache_h = cache_dim.height as f32; + + let vw = render_state.viewbox.dpr_width().max(1.0); + let vh = render_state.viewbox.dpr_height().max(1.0); + + let inv = if navigate_zoom.abs() > f32::EPSILON { + 1.0 / navigate_zoom + } else { + 0.0 + }; + + let cx0 = -translate_x; + let cy0 = -translate_y; + let cx1 = (vw * inv) - translate_x; + let cy1 = (vh * inv) - translate_y; + + let min_x = cx0.min(cx1); + let min_y = cy0.min(cy1); + let max_x = cx0.max(cx1); + let max_y = cy0.max(cy1); + + let cache_covers = min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h; + if !cache_covers && !render_state.surfaces.atlas.is_empty() { + render_state + .surfaces + .draw_atlas_to_backbuffer(render_state.viewbox, bg_color); + + render_state.present_frame(shapes); + crate::performance::end_measure!("render_from_cache"); + crate::performance::end_timed_log!("render_from_cache", _start); + return; + } + } + + render_state.surfaces.draw_cache_to_backbuffer(); + + if !render_state.zoom_changed() { + let visible_rect = tiles::get_tiles_for_viewbox(&render_state.viewbox); + let offset = render_state.viewbox.get_offset(); + for tx in visible_rect.x1()..=visible_rect.x2() { + for ty in visible_rect.y1()..=visible_rect.y2() { + let tile = tiles::Tile::from(tx, ty); + if render_state.surfaces.has_cached_tile_surface(tile) { + let rect = tile.get_rect_with_offset(&offset); + render_state + .surfaces + .draw_cached_tile_into_backbuffer(tile, &rect); + } + } + } + } + + render_state.present_frame(shapes); + } + + crate::performance::end_measure!("render_from_cache"); + crate::performance::end_timed_log!("render_from_cache", _start); +} diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 0574e3ab28..893778b4ec 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -163,6 +163,10 @@ pub fn render_debug_shape( shape_selrect: Option, shape_extrect: Option, ) { + if shape_selrect.is_none() && shape_extrect.is_none() { + return; + } + let canvas = render_state.surfaces.canvas(SurfaceId::Debug); let mut paint = skia::Paint::default(); diff --git a/render-wasm/src/render/drag_crop.rs b/render-wasm/src/render/drag_crop.rs new file mode 100644 index 0000000000..e91de28455 --- /dev/null +++ b/render-wasm/src/render/drag_crop.rs @@ -0,0 +1,330 @@ +use skia_safe::{self as skia, Rect}; + +use super::RenderState; +use super::SurfaceId; +use crate::get_gpu_state; +use crate::math; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +pub struct InteractiveDragCrop { + pub src_doc_bounds: Rect, + pub src_selrect: Rect, + /// Viewbox origin (doc-space) at capture time. + pub capture_vb_left: f32, + pub capture_vb_top: f32, + /// Backbuffer pixel origin used for `snapshot_rect` (so we can do 1:1 blits). + pub capture_src_left: i32, + pub capture_src_top: i32, + pub image: skia::Image, +} + +/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)` with each side +/// at most `max_side_px` (**without scaling**): centered on the projection of +/// `viewport_doc ∩ src_doc_bounds`, or on the full crop if that intersection is empty. +/// `max_side_px` should match [`GpuState::max_texture_size`] (same budget as the atlas). +#[allow(clippy::too_many_arguments)] +pub fn drag_crop_snapshot_window_px( + max_side_px: i32, + out_w: i32, + out_h: i32, + viewport_doc: Rect, + vb_left: f32, + vb_top: f32, + scale: f32, + src_left_px: i32, + src_top_px: i32, + src_doc_bounds: Rect, +) -> (i32, i32, i32, i32) { + let cap = max_side_px.max(1); + if out_w <= cap && out_h <= cap { + return (0, 0, out_w, out_h); + } + let win_w = out_w.min(cap); + let win_h = out_h.min(cap); + + let mut vis = viewport_doc; + let has_vis = vis.intersect(src_doc_bounds); + let (cx, cy) = if !has_vis || vis.is_empty() { + (out_w as f32 * 0.5, out_h as f32 * 0.5) + } else { + let lx0 = (vis.left - vb_left) * scale - src_left_px as f32; + let ly0 = (vis.top - vb_top) * scale - src_top_px as f32; + let lx1 = (vis.right - vb_left) * scale - src_left_px as f32; + let ly1 = (vis.bottom - vb_top) * scale - src_top_px as f32; + ((lx0 + lx1) * 0.5, (ly0 + ly1) * 0.5) + }; + + let mut ox = (cx - win_w as f32 * 0.5).round() as i32; + let mut oy = (cy - win_h as f32 * 0.5).round() as i32; + ox = ox.clamp(0, out_w - win_w); + oy = oy.clamp(0, out_h - win_h); + (ox, oy, win_w, win_h) +} + +/// 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. +pub fn should_use_cached_top_level_during_interactive( + render_state: &mut RenderState, + node_id: Uuid, + tree: ShapesPoolRef, + moved_ids: &[Uuid], + moved_bounds: Option, +) -> bool { + if !render_state.backbuffer_crop_cache.contains_key(&node_id) { + return false; + } + let Some(raw) = tree.get_raw(&node_id) else { + return false; + }; + if raw.parent_id != Some(Uuid::nil()) { + return false; + } + + // If this top-level shape itself is being moved, always allow using its cached pixels. + // BUT only for pure translations. For non-translation transforms (scale/rotate/skew), + // cached pixels won't match the live result (and may require resampling), so render live. + if moved_ids.contains(&node_id) { + let Some(m) = tree.get_modifier(&node_id) else { + return false; + }; + // Only allow using the cached pixels for pure translations. + // For non-translation transforms (scale/rotate/skew), cached pixels won't match. + // If the transform is the identity means a reflow, we need to redraw as well. + if math::identitish(m) || !math::is_move_only_matrix(m) { + return false; + } + + // Additionally require this node to be safe to serve from a rectangular backbuffer + // crop while moving; otherwise it must be rendered live (e.g. text, overflow frames). + return tree + .get(&node_id) + .is_some_and(|s| s.is_safe_for_drag_crop_cache(tree)); + } + + // 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). + if let Some(moved) = moved_bounds { + let intersects = render_state + .backbuffer_crop_cache + .get(&node_id) + .is_some_and(|crop| moved.intersects(crop.src_doc_bounds)); + + if intersects { + return false; + } + } + true +} + +pub fn rebuild_backbuffer_crop_cache(render_state: &mut RenderState, tree: ShapesPoolRef) { + render_state.backbuffer_crop_cache.clear(); + + // Collect candidate shapes that are "recortable" and visible in the current viewport. + + // This is intentionally conservative; we only cache shapes that do not overlap with + // ANY other candidate to guarantee the pixels under their bounds belong exclusively + // to that shape in Backbuffer. + let viewport = render_state.viewbox.area; + let scale = render_state.get_scale(); + let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect) + + let root_ids: Vec = match tree.get(&Uuid::nil()) { + Some(root) => root.children_ids(false), + None => Vec::new(), + }; + + for shape_id in root_ids { + let Some(shape) = tree.get(&shape_id) else { + continue; + }; + if shape.hidden { + continue; + } + + let doc_bounds = shape.extrect(tree, 1.0); + if !doc_bounds.intersects(viewport) { + continue; + } + + // Also require selrect to be visible; used for drag delta placement. + let selrect = shape.selrect(); + if !selrect.intersects(viewport) { + continue; + } + + candidates.push((shape.id, doc_bounds, selrect)); + } + + // Filter out any candidate that overlaps with any other candidate. + // Sort by left edge so the inner loop can break early once no further + // x-overlap is possible, reducing comparisons from O(N²) to O(N log N) + // in typical layouts where shapes are spread out. + candidates.sort_unstable_by(|a, b| { + a.1.left + .partial_cmp(&b.1.left) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let n = candidates.len(); + let mut is_overlapping = vec![false; n]; + for i in 0..n { + for j in (i + 1)..n { + if candidates[j].1.left >= candidates[i].1.right { + break; // sorted: no further x-overlap possible for i + } + if is_overlapping[i] && is_overlapping[j] { + continue; // both already excluded, skip check + } + if candidates[i].1.intersects(candidates[j].1) { + is_overlapping[i] = true; + is_overlapping[j] = true; + } + } + } + let non_overlapping: Vec<(Uuid, Rect, Rect)> = candidates + .iter() + .zip(is_overlapping.iter()) + .filter_map(|((id, bounds, selrect), ov)| { + if !ov { + Some((*id, *bounds, *selrect)) + } else { + None + } + }) + .collect(); + + let vb_left = render_state.viewbox.area.left; + let vb_top = render_state.viewbox.area.top; + let (bb_w, bb_h) = render_state.surfaces.surface_size(SurfaceId::Backbuffer); + let max_snap_px = get_gpu_state().max_texture_size(); + + // Snapshot the atlas once for the whole pass so that all shapes sharing + // the tile/atlas fallback path reuse the same GPU image rather than each + // triggering a separate `image_snapshot` flush. + let atlas_snap = render_state.surfaces.atlas.snapshot_for_drag_crop(); + + // Scratch surface reused across all shapes that need the tile/atlas + // fallback — avoids one WebGL texture allocation per shape. + // Created lazily on first use and grown if a later shape needs more space. + let mut scratch_surface: Option = None; + + for (id, doc_bounds, selrect) in non_overlapping { + let left = ((doc_bounds.left - vb_left) * scale).floor() as i32; + let top = ((doc_bounds.top - vb_top) * scale).floor() as i32; + let right = ((doc_bounds.right - vb_left) * scale).ceil() as i32; + let bottom = ((doc_bounds.bottom - vb_top) * scale).ceil() as i32; + if right <= left || bottom <= top { + continue; + } + let src_irect = skia::IRect::new(left, top, right, bottom); + + let src_doc_bounds = Rect::new( + src_irect.left as f32 / scale + vb_left, + src_irect.top as f32 / scale + vb_top, + src_irect.right as f32 / scale + vb_left, + src_irect.bottom as f32 / scale + vb_top, + ); + + let full_w = src_irect.width(); + let full_h = src_irect.height(); + let (win_ox, win_oy, win_w, win_h) = drag_crop_snapshot_window_px( + max_snap_px, + full_w, + full_h, + viewport, + vb_left, + vb_top, + scale, + src_irect.left, + src_irect.top, + src_doc_bounds, + ); + let window_irect = skia::IRect::new( + src_irect.left + win_ox, + src_irect.top + win_oy, + src_irect.left + win_ox + win_w, + src_irect.top + win_oy + win_h, + ); + + let src_doc_window = Rect::new( + window_irect.left as f32 / scale + vb_left, + window_irect.top as f32 / scale + vb_top, + window_irect.right as f32 / scale + vb_left, + window_irect.bottom as f32 / scale + vb_top, + ); + + let in_backbuffer = window_irect.left >= 0 + && window_irect.top >= 0 + && window_irect.right <= bb_w + && window_irect.bottom <= bb_h; + + let backbuffer_snap = if in_backbuffer { + render_state + .surfaces + .snapshot_rect(SurfaceId::Backbuffer, window_irect) + } else { + None + }; + + let image = if let Some(img) = backbuffer_snap { + img + } else { + // Ensure the scratch surface is large enough for this window. + // Grow (reallocate) only when necessary so that the common case + // of similarly-sized shapes pays zero extra allocation cost. + let needs_alloc = scratch_surface + .as_ref() + .is_none_or(|s| s.width() < win_w || s.height() < win_h); + if needs_alloc { + scratch_surface = get_gpu_state() + .create_surface_with_isize( + "drag_crop_scratch".to_string(), + skia::ISize::new(win_w, win_h), + ) + .ok(); + } + let Some(scratch) = scratch_surface.as_mut() else { + continue; + }; + let Some(img) = render_state + .surfaces + .try_snapshot_doc_rect_from_tiles_and_atlas( + scratch, + atlas_snap.as_ref(), + src_doc_window, + window_irect, + win_w, + win_h, + vb_left, + vb_top, + scale, + ) + else { + continue; + }; + img + }; + + render_state.backbuffer_crop_cache.insert( + id, + InteractiveDragCrop { + src_doc_bounds: src_doc_window, + src_selrect: selrect, + capture_vb_left: vb_left, + capture_vb_top: vb_top, + capture_src_left: window_irect.left, + capture_src_top: window_irect.top, + image, + }, + ); + } +} diff --git a/render-wasm/src/render/drop_shadow.rs b/render-wasm/src/render/drop_shadow.rs new file mode 100644 index 0000000000..fba180d15e --- /dev/null +++ b/render-wasm/src/render/drop_shadow.rs @@ -0,0 +1,357 @@ +use std::borrow::Cow; + +use skia_safe as skia; + +use crate::error::Result; +use crate::shapes::{radius_to_sigma, Blur, Fill, Shadow, Shape, SolidColor, Type}; +use crate::state::ShapesPoolRef; + +use super::{ + filters, get_simplified_children, layer_blur, ClipStack, NodeRenderState, RenderState, + SurfaceId, +}; + +#[allow(clippy::too_many_arguments)] +pub fn render_drop_black_shadow( + render_state: &mut RenderState, + shape: &Shape, + shape_bounds: &skia::Rect, + shadow: &Shadow, + clip_bounds: Option, + scale: f32, + extra_layer_blur: Option, + target_surface: SurfaceId, +) -> Result<()> { + let mut transformed_shadow: Cow = Cow::Borrowed(shadow); + transformed_shadow.to_mut().offset = (0.0, 0.0); + transformed_shadow.to_mut().color = skia::Color::BLACK; + + let mut plain_shape = Cow::Borrowed(shape); + let combined_blur = layer_blur::combine_blur_values( + layer_blur::combined_layer_blur( + &render_state.nested_blurs, + &mut render_state.cached_layer_blur, + shape.blur, + ), + extra_layer_blur, + ); + let blur_filter = combined_blur.and_then(|blur| { + let sigma = blur.sigma(); + skia::image_filters::blur((sigma, sigma), None, None, None) + }); + + let use_low_zoom_path = scale <= 1.0 && combined_blur.is_none(); + let mut transform_matrix = shape.transform; + let center = shape.center(); + transform_matrix.post_translate(center); + transform_matrix.pre_translate(-center); + + let mapped = transform_matrix.map_vector((shadow.offset.0, shadow.offset.1)); + let world_offset = (mapped.x, mapped.y); + + let plain_shape_mut = plain_shape.to_mut(); + plain_shape_mut.clear_fills(); + if shape.has_fills() { + plain_shape_mut.add_fill(Fill::Solid(SolidColor(skia::Color::BLACK))); + } + + for stroke in plain_shape_mut.strokes.iter_mut() { + stroke.fill = Fill::Solid(SolidColor(skia::Color::BLACK)); + } + + plain_shape_mut.clear_shadows(); + plain_shape_mut.blur = None; + + plain_shape_mut.clip_content = false; + + let Some(drop_filter) = transformed_shadow.get_drop_shadow_filter() else { + return Ok(()); + }; + + let mut bounds = drop_filter.compute_fast_bounds(shape_bounds); + bounds.offset(world_offset); + if !bounds.intersects(render_state.render_area_with_margins) + && target_surface != SurfaceId::Export + { + return Ok(()); + } + + if scale > 1.0 && shadow.blur <= 0.0 { + let drop_canvas = render_state.surfaces.canvas(SurfaceId::DropShadows); + drop_canvas.save(); + + render_state.with_nested_blurs_suppressed(|state| { + state.render_shape( + &plain_shape, + clip_bounds, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + false, + Some(shadow.offset), + None, + Some(shadow.spread), + target_surface, + ) + })?; + + render_state + .surfaces + .canvas(SurfaceId::DropShadows) + .restore(); + return Ok(()); + } + + let blur_only_filter = if transformed_shadow.blur > 0.0 { + let sigma = radius_to_sigma(transformed_shadow.blur); + Some(skia::image_filters::blur((sigma, sigma), None, None, None)) + } else { + None + }; + + let mut shadow_paint = skia::Paint::default(); + if let Some(blur_filter) = blur_only_filter { + shadow_paint.set_image_filter(blur_filter); + } + shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint); + + if use_low_zoom_path { + let drop_canvas = render_state.surfaces.canvas(SurfaceId::DropShadows); + drop_canvas.save_layer(&layer_rec); + + render_state.with_nested_blurs_suppressed(|state| { + state.render_shape( + &plain_shape, + clip_bounds, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + false, + Some(shadow.offset), + None, + Some(shadow.spread), + target_surface, + ) + })?; + + render_state + .surfaces + .canvas(SurfaceId::DropShadows) + .restore(); + return Ok(()); + } + + let blur_downscale_threshold: f32 = render_state.options.blur_downscale_threshold; + let min_blur_downscale: f32 = 1.0 / blur_downscale_threshold; + let blur_downscale = if shadow.blur > blur_downscale_threshold { + (blur_downscale_threshold / shadow.blur).max(min_blur_downscale) + } else { + 1.0 + }; + + let filter_result = filters::render_into_filter_surface( + render_state, + bounds, + blur_downscale, + |state, temp_surface| { + let canvas = state.surfaces.canvas(temp_surface); + canvas.save_layer(&layer_rec); + + state.with_nested_blurs_suppressed(|state| { + state.render_shape( + &plain_shape, + clip_bounds, + temp_surface, + temp_surface, + temp_surface, + temp_surface, + false, + Some(shadow.offset), + None, + Some(shadow.spread), + target_surface, + ) + })?; + + state.surfaces.canvas(temp_surface).restore(); + Ok(()) + }, + )?; + + if let Some((mut surface, filter_scale)) = filter_result { + let drop_canvas = render_state.surfaces.canvas(SurfaceId::DropShadows); + drop_canvas.save(); + let mut drop_paint = skia::Paint::default(); + drop_paint.set_image_filter(blur_filter.clone()); + + if filter_scale < 1.0 { + drop_canvas.save(); + drop_canvas.scale((1.0 / filter_scale, 1.0 / filter_scale)); + drop_canvas.translate((bounds.left * filter_scale, bounds.top * filter_scale)); + surface.draw( + drop_canvas, + (0.0, 0.0), + render_state.sampling_options, + Some(&drop_paint), + ); + drop_canvas.restore(); + } else { + drop_canvas.save(); + drop_canvas.translate((bounds.left, bounds.top)); + surface.draw( + drop_canvas, + (0.0, 0.0), + render_state.sampling_options, + Some(&drop_paint), + ); + drop_canvas.restore(); + } + drop_canvas.restore(); + } + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub fn render_element_drop_shadows_and_composite( + render_state: &mut RenderState, + element: &Shape, + tree: ShapesPoolRef, + extrect: &mut Option, + clip_bounds: Option, + scale: f32, + node_render_state: &NodeRenderState, + target_surface: SurfaceId, +) -> Result<()> { + let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale)); + let inherited_layer_blur = match element.shape_type { + Type::Frame(_) | Type::Group(_) => element.blur, + _ => None, + }; + + for shadow in element.drop_shadows_visible() { + let paint = skia::Paint::default(); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + render_state + .surfaces + .canvas(SurfaceId::DropShadows) + .save_layer(&layer_rec); + + render_drop_black_shadow( + render_state, + element, + element_extrect, + shadow, + clip_bounds.clone(), + scale, + None, + target_surface, + )?; + + if !matches!(element.shape_type, Type::Bool(_)) { + let mut shadow_children = Vec::new(); + if element.is_recursive() { + get_simplified_children(tree, element, &mut shadow_children); + } + + for shadow_shape_id in shadow_children.iter() { + let Some(shadow_shape) = tree.get(shadow_shape_id) else { + continue; + }; + if shadow_shape.hidden { + continue; + } + + let nested_clip_bounds = + node_render_state.get_nested_shadow_clip_bounds(element, shadow); + + if !matches!(shadow_shape.shape_type, Type::Text(_)) { + render_drop_black_shadow( + render_state, + shadow_shape, + &shadow_shape.extrect(tree, scale), + shadow, + nested_clip_bounds, + scale, + inherited_layer_blur, + target_surface, + )?; + } else { + let paint = skia::Paint::default(); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + render_state + .surfaces + .canvas(SurfaceId::DropShadows) + .save_layer(&layer_rec); + + let mut transformed_shadow: Cow = Cow::Borrowed(shadow); + transformed_shadow.to_mut().color = skia::Color::BLACK; + transformed_shadow.to_mut().blur = transformed_shadow.blur; + transformed_shadow.to_mut().spread = transformed_shadow.spread; + + let mut new_shadow_paint = skia::Paint::default(); + new_shadow_paint.set_image_filter(transformed_shadow.get_drop_shadow_filter()); + new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); + + render_state.with_nested_blurs_suppressed(|state| { + state.render_shape( + shadow_shape, + nested_clip_bounds, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + true, + None, + Some(vec![new_shadow_paint.clone()]), + None, + target_surface, + ) + })?; + render_state + .surfaces + .canvas(SurfaceId::DropShadows) + .restore(); + } + } + } + + let mut paint = skia::Paint::default(); + paint.set_color(shadow.color); + paint.set_blend_mode(skia::BlendMode::SrcIn); + render_state + .surfaces + .canvas(SurfaceId::DropShadows) + .draw_paint(&paint); + + render_state + .surfaces + .canvas(SurfaceId::DropShadows) + .restore(); + } + + if let Some(clips) = clip_bounds.as_ref() { + let antialias = !render_state.options.is_fast_mode() + && element.should_use_antialias(scale, render_state.options.antialias_threshold); + render_state.surfaces.canvas(target_surface).save(); + render_state.clip_target_surface_to_stack(clips, target_surface, scale, antialias); + render_state + .surfaces + .draw_into(SurfaceId::DropShadows, target_surface, None); + render_state.surfaces.canvas(target_surface).restore(); + } else { + render_state + .surfaces + .draw_into(SurfaceId::DropShadows, target_surface, None); + } + + render_state + .surfaces + .canvas(SurfaceId::DropShadows) + .clear(skia::Color::TRANSPARENT); + Ok(()) +} diff --git a/render-wasm/src/render/enter_exit.rs b/render-wasm/src/render/enter_exit.rs new file mode 100644 index 0000000000..f99bde5d40 --- /dev/null +++ b/render-wasm/src/render/enter_exit.rs @@ -0,0 +1,198 @@ +use std::borrow::Cow; + +use skia_safe as skia; + +use crate::error::Result; +use crate::shapes::{radius_to_sigma, Shape, StrokeKind, Type}; + +use super::layer_blur; +use super::{ClipStack, NodeRenderState, RenderState, SurfaceId}; + +pub fn render_shape_enter( + render_state: &mut RenderState, + element: &Shape, + mask: bool, + clip_bounds: Option<&ClipStack>, + target_surface: SurfaceId, +) { + if let Type::Group(group) = element.shape_type { + let fills = &element.fills; + let shadows = &element.shadows; + render_state.nested_fills.push(fills.to_vec()); + render_state.nested_shadows.push(shadows.to_vec()); + + if group.masked { + let mask_group_blur = element.masked_group_layer_blur().is_some(); + if mask_group_blur { + render_state.surfaces.canvas(target_surface).save(); + if let Some(clips) = clip_bounds { + let scale = render_state.get_scale(); + let antialias = !render_state.options.is_fast_mode() + && element + .should_use_antialias(scale, render_state.options.antialias_threshold); + render_state.clip_target_surface_to_stack( + clips, + target_surface, + scale, + antialias, + ); + } + } + + let mut paint = skia::Paint::default(); + if !render_state.options.is_fast_mode() { + if let Some(blur) = element.masked_group_layer_blur() { + let scale = render_state.get_scale(); + let sigma = radius_to_sigma(blur.value * scale); + if let Some(filter) = + skia::image_filters::blur((sigma, sigma), None, None, None) + { + paint.set_image_filter(filter); + } + } + } + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + render_state + .surfaces + .canvas(target_surface) + .save_layer(&layer_rec); + } + } + + if let Type::Frame(_) = element.shape_type { + render_state.nested_fills.push(Vec::new()); + } + + if mask { + let mut mask_paint = skia::Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + let mask_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); + render_state + .surfaces + .canvas(target_surface) + .save_layer(&mask_rec); + } + + let needs_layer = element.needs_layer(); + + if needs_layer { + let mut paint = skia::Paint::default(); + paint.set_blend_mode(element.blend_mode().into()); + paint.set_alpha_f(element.opacity()); + + if !render_state.options.is_fast_mode() { + if let Some(frame_blur) = layer_blur::frame_clip_layer_blur(element) { + let scale = render_state.get_scale(); + let sigma = radius_to_sigma(frame_blur.value * scale); + if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { + paint.set_image_filter(filter); + } + } + } + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + render_state + .surfaces + .canvas(target_surface) + .save_layer(&layer_rec); + } + + render_state.focus_mode.enter(&element.id); +} + +pub fn render_shape_exit( + render_state: &mut RenderState, + element: &Shape, + visited_mask: bool, + clip_bounds: Option, + target_surface: SurfaceId, +) -> Result<()> { + if visited_mask { + if let Type::Group(group) = element.shape_type { + if group.masked { + render_state.surfaces.canvas(target_surface).restore(); + } + } + } else if let Type::Group(group) = element.shape_type { + if group.masked { + render_state.pending_nodes.push(NodeRenderState { + id: element.id, + visited_children: true, + clip_bounds: None, + visited_mask: true, + mask: false, + flattened: false, + }); + if let Some(&mask_id) = element.mask_id() { + render_state.pending_nodes.push(NodeRenderState { + id: mask_id, + visited_children: false, + clip_bounds: None, + visited_mask: false, + mask: true, + flattened: false, + }); + } + } + } + + match element.shape_type { + Type::Frame(_) | Type::Group(_) => { + render_state.nested_fills.pop(); + render_state.nested_blurs.pop(); + render_state.cached_layer_blur = None; + render_state.nested_shadows.pop(); + } + _ => {} + } + + let needs_exit_strokes = render_state.focus_mode.is_active() + && (element.clip() + || (matches!(element.shape_type, Type::Frame(_)) && element.has_inner_stroke())); + + if needs_exit_strokes { + let mut element_strokes: Cow = Cow::Borrowed(element); + element_strokes.to_mut().clear_fills(); + element_strokes.to_mut().clear_shadows(); + element_strokes.to_mut().clip_content = false; + + if !element.clip() { + let is_open = element.is_open(); + element_strokes + .to_mut() + .strokes + .retain(|s| s.render_kind(is_open) == StrokeKind::Inner); + } + + if layer_blur::frame_clip_layer_blur(element).is_some() { + element_strokes.to_mut().set_blur(None); + } + render_state.render_shape( + &element_strokes, + clip_bounds, + SurfaceId::Fills, + SurfaceId::Strokes, + SurfaceId::InnerShadows, + SurfaceId::TextDropShadows, + true, + None, + None, + None, + target_surface, + )?; + } + + let needs_layer = element.needs_layer(); + + if needs_layer { + render_state.surfaces.canvas(target_surface).restore(); + } + + if visited_mask && element.masked_group_layer_blur().is_some() { + render_state.surfaces.canvas(target_surface).restore(); + } + + render_state.focus_mode.exit(&element.id); + Ok(()) +} diff --git a/render-wasm/src/render/export.rs b/render-wasm/src/render/export.rs new file mode 100644 index 0000000000..8f84e1e0ee --- /dev/null +++ b/render-wasm/src/render/export.rs @@ -0,0 +1,98 @@ +use crate::error::Result; +use crate::get_gpu_state; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; +use skia_safe as skia; + +use super::{NodeRenderState, RenderState, SurfaceId}; + +pub fn render_shape_pixels( + render_state: &mut RenderState, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + timestamp: i32, +) -> Result<(Vec, i32, i32)> { + let target_surface = SurfaceId::Export; + + let saved_focus_mode = render_state.focus_mode.clone(); + let saved_export_context = render_state.export_context; + let saved_render_area = render_state.render_area; + let saved_render_area_with_margins = render_state.render_area_with_margins; + let saved_current_tile = render_state.current_tile; + let saved_pending_nodes = std::mem::take(&mut render_state.pending_nodes); + let saved_nested_fills = std::mem::take(&mut render_state.nested_fills); + let saved_nested_blurs = std::mem::take(&mut render_state.nested_blurs); + let saved_nested_shadows = std::mem::take(&mut render_state.nested_shadows); + let saved_ignore_nested_blurs = render_state.ignore_nested_blurs; + let saved_preview_mode = render_state.preview_mode; + + render_state.focus_mode.clear(); + + render_state + .surfaces + .canvas(target_surface) + .clear(skia::Color::TRANSPARENT); + + if tree.len() != 0 { + let Some(shape) = tree.get(id) else { + return Ok((Vec::new(), 0, 0)); + }; + let mut extrect = shape.extrect(tree, scale); + render_state.export_context = Some((extrect, scale)); + let margins = render_state.surfaces.margins; + extrect.offset((margins.width as f32 / scale, margins.height as f32 / scale)); + + render_state.surfaces.resize_export_surface(scale, extrect); + render_state.render_area = extrect; + render_state.render_area_with_margins = extrect; + render_state.surfaces.update_render_context(extrect, scale); + + render_state.pending_nodes.push(NodeRenderState { + id: *id, + visited_children: false, + clip_bounds: None, + visited_mask: false, + mask: false, + flattened: false, + }); + render_state.render_shape_tree_partial_uncached(tree, timestamp, false, true)?; + } + + render_state.export_context = None; + + render_state.surfaces.flush_and_submit(target_surface); + + let image = render_state.surfaces.snapshot(target_surface); + let data = image + .encode( + Some(&mut get_gpu_state().context), + skia::EncodedImageFormat::PNG, + 100, + ) + .expect("PNG encode failed"); + let skia::ISize { width, height } = image.dimensions(); + + render_state.focus_mode = saved_focus_mode; + render_state.export_context = saved_export_context; + render_state.render_area = saved_render_area; + render_state.render_area_with_margins = saved_render_area_with_margins; + render_state.current_tile = saved_current_tile; + render_state.pending_nodes = saved_pending_nodes; + render_state.nested_fills = saved_nested_fills; + render_state.nested_blurs = saved_nested_blurs; + render_state.nested_shadows = saved_nested_shadows; + render_state.ignore_nested_blurs = saved_ignore_nested_blurs; + render_state.preview_mode = saved_preview_mode; + + let workspace_scale = render_state.get_scale(); + if let Some(tile) = render_state.current_tile { + render_state.update_render_context(tile); + } else if !render_state.render_area.is_empty() { + render_state + .surfaces + .update_render_context(render_state.render_area, workspace_scale); + } + + Ok((data.as_bytes().to_vec(), width, height)) +} diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index 45f8fc3048..fbe88e8025 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -27,13 +27,18 @@ fn draw_image_fill( let mut image_paint = skia::Paint::default(); image_paint.set_anti_alias(antialias); - if let Some(filter) = shape.image_filter(1.) { + let filter = shape.image_filter(1.); + if let Some(ref filter) = filter { image_paint.set_image_filter(filter.clone()); } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&image_paint); - // Save the current canvas state - canvas.save_layer(&layer_rec); + let has_image_filter = filter.is_some(); + if has_image_filter { + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&image_paint); + canvas.save_layer(&layer_rec); + } else { + canvas.save(); + } // Set the clipping rectangle to the container bounds match &shape.shape_type { @@ -87,7 +92,6 @@ fn draw_image_fill( paint, ); - // Restore the canvas to remove the clipping canvas.restore(); } diff --git a/render-wasm/src/render/filters.rs b/render-wasm/src/render/filters.rs index 34b33f403d..28f3edc19a 100644 --- a/render-wasm/src/render/filters.rs +++ b/render-wasm/src/render/filters.rs @@ -119,11 +119,13 @@ where { let canvas = render_state.surfaces.canvas(filter_id); - canvas.clear(skia::Color::TRANSPARENT); canvas.save(); - // Apply scale first, then translate canvas.scale((scale, scale)); canvas.translate((-bounds.left, -bounds.top)); + + let scaled_bounds = Rect::new(bounds.left, bounds.top, bounds.right, bounds.bottom); + canvas.clip_rect(scaled_bounds, skia::ClipOp::Intersect, false); + canvas.clear(skia::Color::TRANSPARENT); } draw_fn(render_state, filter_id)?; diff --git a/render-wasm/src/render/focus_mode.rs b/render-wasm/src/render/focus_mode.rs new file mode 100644 index 0000000000..c076594eed --- /dev/null +++ b/render-wasm/src/render/focus_mode.rs @@ -0,0 +1,53 @@ +use std::collections::HashSet; + +use crate::uuid::Uuid; + +#[derive(Clone)] +pub struct FocusMode { + shapes: HashSet, + active: bool, +} + +impl FocusMode { + pub fn new() -> Self { + FocusMode { + shapes: HashSet::new(), + active: false, + } + } + + pub fn clear(&mut self) { + self.shapes.clear(); + self.active = false; + } + + pub fn set_shapes(&mut self, shapes: Vec) { + self.shapes = shapes.into_iter().collect(); + } + + /// Returns `true` if the given shape ID should be focused. + /// If the `shapes` list is empty, focus applies to all shapes. + pub fn should_focus(&self, id: &Uuid) -> bool { + self.shapes.is_empty() || self.shapes.contains(id) + } + + pub fn enter(&mut self, id: &Uuid) { + if !self.active && self.should_focus(id) { + self.active = true; + } + } + + pub fn exit(&mut self, id: &Uuid) { + if self.active && self.should_focus(id) { + self.active = false; + } + } + + pub fn is_active(&self) -> bool { + self.active + } + + pub fn reset(&mut self) { + self.active = false; + } +} diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index 17bdb84290..f321b58a75 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -26,6 +26,7 @@ pub struct FontStore { debug_font: Font, ui_font: Font, fallback_fonts: HashSet, + registered_families: HashSet, } impl FontStore { @@ -55,6 +56,7 @@ impl FontStore { debug_font, ui_font, fallback_fonts: HashSet::new(), + registered_families: HashSet::new(), }) } @@ -105,7 +107,7 @@ impl FontStore { }; self.font_provider.register_typeface(typeface, font_name); - self.font_collection.clear_caches(); + self.registered_families.insert(font_name.to_string()); if is_fallback { self.fallback_fonts.insert(alias); @@ -121,13 +123,17 @@ impl FontStore { } else { alias.as_str() }; - self.font_provider.family_names().any(|x| x == font_name) + self.registered_families.contains(font_name) } pub fn get_fallback(&self) -> &HashSet { &self.fallback_fonts } + pub fn flush_caches(&mut self) { + self.font_collection.clear_caches(); + } + pub fn get_emoji_font(&self, _size: f32) -> Option { None } diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index b567829ab7..648e68bed5 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -6,6 +6,7 @@ use crate::error::Result; use crate::get_gpu_state; use skia_safe::gpu::{surfaces, Budgeted, DirectContext}; use skia_safe::{self as skia, Codec, ISize}; +use std::cell::RefCell; use std::collections::HashMap; pub type Image = skia::Image; @@ -60,8 +61,8 @@ enum StoredImage { } pub struct ImageStore { - images: HashMap<(Uuid, bool), StoredImage>, - context: Box, + images: RefCell>, + context: RefCell>, } /// Creates a Skia image from an existing WebGL texture. @@ -148,8 +149,8 @@ impl ImageStore { let gpu_state = get_gpu_state(); let context = &gpu_state.context; Self { - images: HashMap::with_capacity(2048), - context: Box::new(context.clone()), + images: RefCell::new(HashMap::new()), + context: RefCell::new(Box::new(context.clone())), } } @@ -161,16 +162,18 @@ impl ImageStore { ) -> crate::error::Result<()> { let key = (id, is_thumbnail); - if self.images.contains_key(&key) { + if self.images.borrow().contains_key(&key) { return Ok(()); } - let raw_data = image_data.to_vec(); - - if let Some(gpu_image) = decode_image(&mut self.context, &raw_data) { - self.images.insert(key, StoredImage::Gpu(gpu_image)); + if let Some(gpu_image) = decode_image(&mut self.context.borrow_mut(), image_data) { + self.images + .borrow_mut() + .insert(key, StoredImage::Gpu(gpu_image)); } else { - self.images.insert(key, StoredImage::Raw(raw_data)); + self.images + .borrow_mut() + .insert(key, StoredImage::Raw(image_data.to_vec())); } Ok(()) } @@ -188,51 +191,49 @@ impl ImageStore { ) -> Result<()> { let key = (id, is_thumbnail); - if self.images.contains_key(&key) { + if self.images.borrow().contains_key(&key) { return Ok(()); } // Create a Skia image from the existing GL texture - let image = create_image_from_gl_texture(&mut self.context, texture_id, width, height)?; - self.images.insert(key, StoredImage::Gpu(image)); + let image = create_image_from_gl_texture( + &mut self.context.borrow_mut(), + texture_id, + width, + height, + )?; + self.images + .borrow_mut() + .insert(key, StoredImage::Gpu(image)); Ok(()) } pub fn contains(&self, id: &Uuid, is_thumbnail: bool) -> bool { - self.images.contains_key(&(*id, is_thumbnail)) + self.images.borrow().contains_key(&(*id, is_thumbnail)) } - pub fn get(&mut self, id: &Uuid) -> Option<&Image> { - // Try to get full image first, fallback to thumbnail - let has_full = self.images.contains_key(&(*id, false)); - if has_full { - self.get_internal(id, false) - } else { - self.get_internal(id, true) - } + pub fn get(&mut self, id: &Uuid) -> Option { + self.get_internal(id, false) + .or_else(|| self.get_internal(id, true)) } pub fn get_cpu_image(&mut self, id: &Uuid) -> Option { - let gpu_image = self.get(id)?.clone(); - gpu_image.make_non_texture_image(self.context.as_mut()) + let gpu_image = self.get(id)?; + gpu_image.make_non_texture_image(self.context.borrow_mut().as_mut()) } - fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> { + fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option { let key = (*id, is_thumbnail); - // Use entry API to mutate the HashMap in-place if needed - if let Some(entry) = self.images.get_mut(&key) { + let mut images = self.images.borrow_mut(); + if let Some(entry) = images.get_mut(&key) { match entry { - StoredImage::Gpu(ref img) => Some(img), + StoredImage::Gpu(ref img) => Some(img.clone()), StoredImage::Raw(raw_data) => { - let gpu_image = decode_image(&mut self.context, raw_data)?; + let gpu_image = decode_image(&mut self.context.borrow_mut(), raw_data)?; + let clone = gpu_image.clone(); *entry = StoredImage::Gpu(gpu_image); - - if let StoredImage::Gpu(ref img) = entry { - Some(img) - } else { - None - } + Some(clone) } } } else { diff --git a/render-wasm/src/render/layer_blur.rs b/render-wasm/src/render/layer_blur.rs new file mode 100644 index 0000000000..e726549c3d --- /dev/null +++ b/render-wasm/src/render/layer_blur.rs @@ -0,0 +1,58 @@ +use crate::shapes::{Blur, BlurType, Shape}; + +/// 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. +pub fn combined_layer_blur( + nested_blurs: &[Option], + cached_layer_blur: &mut Option>, + shape_blur: Option, +) -> Option { + if let Some(ref cached) = cached_layer_blur { + return *cached; + } + let mut total = 0.; + + for nested_blur in nested_blurs.iter().flatten() { + total += blur_variance(Some(*nested_blur)); + } + + total += blur_variance(shape_blur); + + let result = blur_from_variance(total); + *cached_layer_blur = Some(result); + result +} + +/// Returns the variance (radius²) for a visible layer blur, or zero if the +/// blur is hidden/absent. Working in variance space lets us add multiple +/// blur radii correctly. +pub fn blur_variance(blur: Option) -> f32 { + match blur { + Some(blur) if !blur.hidden && blur.blur_type == BlurType::LayerBlur => blur.value.powi(2), + _ => 0., + } +} + +/// Builds a blur from an accumulated variance value. If no variance was +/// contributed, we return `None`; otherwise the equivalent single radius is +/// `sqrt(total)`. +pub fn blur_from_variance(total: f32) -> Option { + (total > 0.).then(|| Blur::new(BlurType::LayerBlur, false, total.sqrt())) +} + +/// Convenience helper to merge two optional layer blurs using the same +/// variance math as `combined_layer_blur`. +pub fn combine_blur_values(base: Option, extra: Option) -> Option { + let total = blur_variance(base) + blur_variance(extra); + blur_from_variance(total) +} + +pub fn frame_clip_layer_blur(shape: &Shape) -> Option { + shape.frame_clip_layer_blur() +} diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 8221cad4b6..0f65c61222 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -297,13 +297,14 @@ pub(super) fn handle_stroke_caps( blur: Option<&ImageFilter>, _antialias: bool, ) { - // Closed shapes don't have caps if !is_open { return; } - // When both ends share the same simple line cap, Skia already drew it - // natively via `PaintCap` on the stroke paint, so skip the manual overlay. + if stroke.cap_start.is_none() && stroke.cap_end.is_none() { + return; + } + if stroke.to_skia_linecap().is_some() { return; } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 3937976cdf..0f551948d6 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -167,8 +167,10 @@ impl DocAtlas { new_bottom = new_bottom.max(doc_rect.bottom.ceil()); } - // Add padding to reduce realloc frequency. - let pad = tiles::TILE_SIZE; + // Geometric over-allocation: pad by 25% of extent to reduce realloc frequency. + let doc_extent_w = new_right - new_left; + let doc_extent_h = new_bottom - new_top; + let pad = (doc_extent_w.min(doc_extent_h) * 0.25_f32).max(TILE_SIZE as f32); new_left -= pad; new_top -= pad; new_right += pad; @@ -770,7 +772,11 @@ impl Surfaces { if ids & SurfaceId::Export as u32 != 0 { f(self.get_mut(SurfaceId::Export)); } - performance::begin_measure!("apply_mut::flags"); + performance::end_measure!("apply_mut::flags"); + } + + pub fn apply_one(&mut self, surface_id: SurfaceId, mut f: impl FnMut(&mut skia::Surface)) { + f(self.get_mut(surface_id)); } pub fn get_render_context_translation( @@ -801,13 +807,6 @@ impl Surfaces { s.canvas().clear(skia::Color::TRANSPARENT); }); - // Mark all render surfaces as dirty so they get redrawn - self.mark_dirty(SurfaceId::Fills); - self.mark_dirty(SurfaceId::Strokes); - self.mark_dirty(SurfaceId::InnerShadows); - self.mark_dirty(SurfaceId::TextDropShadows); - self.mark_dirty(SurfaceId::DropShadows); - // Update transformations self.apply_mut(surface_ids, |s| { let canvas = s.canvas(); @@ -1191,13 +1190,14 @@ impl Surfaces { self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); // Draws current tile into tile atlas - let tile_ref = self.tiles.add(tile_viewbox, tile); - self.tile_atlas.canvas().draw_image_rect( - &tile_image, - None, - tile_ref.rect, - &skia::Paint::default(), - ); + if let Some(tile_ref) = self.tiles.add(tile_viewbox, tile) { + self.tile_atlas.canvas().draw_image_rect( + &tile_image, + None, + tile_ref.rect, + &skia::Paint::default(), + ); + } } } @@ -1324,9 +1324,7 @@ impl Surfaces { } pub fn get_tile_image_from_tile_atlas(&mut self, tile: Tile) -> Option { - let Some(tile_ref) = self.tiles.get(tile) else { - panic!("Tile not found {}:{}", tile.0, tile.1); - }; + let tile_ref = self.tiles.get(tile)?; let rect = IRect::from_ltrb( tile_ref.rect.left as i32, @@ -1456,10 +1454,7 @@ impl Surfaces { self.shape_strokes = surface; } - if let Some(surface) = self - .shape_strokes - .new_surface_with_dimensions((max_w, max_h)) - { + if let Some(surface) = self.shape_fills.new_surface_with_dimensions((max_w, max_h)) { self.shape_fills = surface; } } @@ -1564,8 +1559,9 @@ impl TileTextureCache { fn gc(&mut self) { // Make a real remove - for tile in self.removed.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { + let removed = std::mem::take(&mut self.removed); + for tile in removed { + if let Some(tile_ref) = self.grid.remove(&tile) { self.provider.deallocate(tile_ref); } } @@ -1652,7 +1648,7 @@ impl TileTextureCache { self.grid.contains_key(&tile) && !self.removed.contains(&tile) } - pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { + pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> Option { if self.grid.len() > TEXTURES_CACHE_CAPACITY { // First we try to remove the obsolete tiles. self.gc(); @@ -1665,9 +1661,7 @@ impl TileTextureCache { self.gc_non_visible(tile_viewbox); } - let Some(tile_ref) = self.provider.allocate() else { - panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); - }; + let tile_ref = self.provider.allocate()?; self.grid.insert(*tile, tile_ref.clone()); @@ -1676,7 +1670,7 @@ impl TileTextureCache { } self.is_updated = true; - tile_ref.clone() + Some(tile_ref) } pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 4f2d521179..33fccc61a9 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -28,24 +28,26 @@ pub fn stroke_paragraph_builder_group_from_text( let mut group_layer_opacity: Option = None; for paragraph in text_content.paragraphs() { - let mut stroke_paragraphs_map: std::collections::HashMap = - std::collections::HashMap::new(); + let mut stroke_paragraphs: Vec = Vec::new(); + let (stroke_paints, stroke_layer_opacity) = + get_text_stroke_paints(stroke, bounds, remove_stroke_alpha); + + if group_layer_opacity.is_none() { + group_layer_opacity = stroke_layer_opacity; + } for span in paragraph.children().iter() { - let (stroke_paints, stroke_layer_opacity) = - get_text_stroke_paints(stroke, bounds, remove_stroke_alpha); - - if group_layer_opacity.is_none() { - group_layer_opacity = stroke_layer_opacity; - } - let text: String = span.apply_text_transform(); - for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() { - let builder = stroke_paragraphs_map.entry(paint_idx).or_insert_with(|| { + if stroke_paragraphs.len() < stroke_paints.len() { + for _stroke_paint in stroke_paints.iter() { let paragraph_style = paragraph.paragraph_to_style(); - ParagraphBuilder::new(¶graph_style, fonts) - }); + stroke_paragraphs.push(ParagraphBuilder::new(¶graph_style, fonts)); + } + } + + for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() { + let builder = &mut stroke_paragraphs[paint_idx]; let stroke_paint = stroke_paint.clone(); let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent(); let stroke_style = span.to_stroke_style( @@ -59,10 +61,6 @@ pub fn stroke_paragraph_builder_group_from_text( } } - let stroke_paragraphs: Vec = (0..stroke_paragraphs_map.len()) - .filter_map(|i| stroke_paragraphs_map.remove(&i)) - .collect(); - paragraph_group.push(stroke_paragraphs); } @@ -555,7 +553,7 @@ fn draw_text( let layer_rec = SaveLayerRec::default().paint(&opacity_paint); canvas.save_layer(&layer_rec); } else { - canvas.save_layer(&SaveLayerRec::default()); + canvas.save(); } paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index 3b69955a82..651993170e 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -3,7 +3,6 @@ use skia_safe::{self as skia, Color4f}; use super::{RenderState, ShapesPoolRef, SurfaceId}; use crate::globals::get_ui_state; use crate::render::{grid_layout, rulers}; -use crate::shapes::{Layout, Type}; pub mod guides; pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { @@ -30,28 +29,13 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { } // Render overlays for empty grid frames - for shape in shapes.iter() { - if shape.id.is_nil() || !shape.children.is_empty() { + let empty_grid_ids: std::collections::HashSet = + std::mem::take(&mut render_state.empty_grid_frame_ids); + for id in &empty_grid_ids { + if show_grid_id == Some(*id) { continue; } - - if show_grid_id == Some(shape.id) { - continue; - } - - let Type::Frame(frame) = &shape.shape_type else { - continue; - }; - - if !matches!(frame.layout, Some(Layout::GridLayout(_, _))) { - continue; - } - - if shape.deleted() { - continue; - } - - if let Some(shape) = shapes.get(&shape.id) { + if let Some(shape) = shapes.get(id) { grid_layout::render_overlay( zoom, render_state.options.antialias_threshold, @@ -61,6 +45,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { ); } } + render_state.empty_grid_frame_ids = empty_grid_ids; let viewbox = render_state.viewbox; let ruler_state = render_state.rulers; diff --git a/render-wasm/src/render/view_mode.rs b/render-wasm/src/render/view_mode.rs new file mode 100644 index 0000000000..7f4af88314 --- /dev/null +++ b/render-wasm/src/render/view_mode.rs @@ -0,0 +1,73 @@ +use std::collections::HashSet; + +use crate::render::RenderState; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +pub fn viewer_masked_pass(include_filter: &Option>) -> bool { + include_filter.is_some() +} + +pub fn reset_viewer_masked_surfaces(render_state: &mut RenderState) { + render_state + .surfaces + .clear_backbuffer(render_state.background_color); + render_state.surfaces.clear_tile_atlas(); +} + +/// Precompute the set of all ancestor ids that are visible for the viewer +/// masked pass, avoiding recursive checks on the hot path. +pub fn precompute_viewer_visible_set(render_state: &mut RenderState, tree: ShapesPoolRef) { + render_state.viewer_visible_set = None; + let Some(ref include) = render_state.include_filter else { + return; + }; + let mut visible = include.clone(); + for id in include.iter() { + let mut current_id = id; + while let Some(raw) = tree.get_raw(current_id) { + match raw.parent_id { + Some(ref parent_id) => { + visible.insert(*parent_id); + current_id = parent_id; + } + None => break, + } + } + } + render_state.viewer_visible_set = Some(visible); +} + +/// True when the shape or any descendant is whitelisted. +pub fn shape_visible_in_include_filter( + viewer_visible_set: &Option>, + shape_id: &Uuid, +) -> bool { + match viewer_visible_set { + Some(visible) => visible.contains(shape_id), + None => true, + } +} + +/// When an include whitelist is active, only those ids are painted. +pub fn shape_should_paint_for_viewer_layer( + include_filter: &Option>, + shape_id: &Uuid, +) -> bool { + match include_filter { + Some(include) => include.contains(shape_id), + None => true, + } +} + +/// Viewer layer mask: traverse whitelisted subtrees; paint only listed ids. +pub fn shape_visible_for_viewer_layer( + viewer_render_root: &Option, + viewer_visible_set: &Option>, + shape_id: &Uuid, +) -> bool { + if viewer_render_root.as_ref() == Some(shape_id) { + return true; + } + shape_visible_in_include_filter(viewer_visible_set, shape_id) +} diff --git a/render-wasm/src/render/walk.rs b/render-wasm/src/render/walk.rs new file mode 100644 index 0000000000..7b651efb55 --- /dev/null +++ b/render-wasm/src/render/walk.rs @@ -0,0 +1,232 @@ +use crate::shapes::{Corners, Shadow, Shape, Type}; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; +use skia_safe::{Matrix, Rect}; +use std::collections::HashMap; + +pub type ClipStack = Vec<(Rect, Option, Matrix, Matrix)>; + +#[derive(Debug)] +pub struct NodeRenderState { + pub id: Uuid, + pub(crate) visited_children: bool, + pub(crate) clip_bounds: Option, + pub(crate) visited_mask: bool, + pub(crate) mask: bool, + pub(crate) flattened: bool, +} + +/// Get simplified children of a container, flattening nested flattened containers +pub fn get_simplified_children<'a>( + tree: ShapesPoolRef<'a>, + shape: &'a Shape, + result: &mut Vec, +) { + for child_id in shape.children_ids_iter(false) { + if let Some(child) = tree.get(child_id) { + if child.can_flatten() { + get_simplified_children(tree, child, result); + } else { + result.push(*child_id); + } + } + } +} + +impl NodeRenderState { + pub fn is_root(&self) -> bool { + self.id.is_nil() + } + + /// Calculates the clip bounds for child elements of a given shape. + /// + /// This function determines the clipping region that should be applied to child elements + /// when rendering. It takes into account the element's selection rectangle, transform. + /// + /// # Parameters + /// + /// * `element` - The shape element for which to calculate clip bounds + /// * `offset` - Optional offset (x, y) to adjust the bounds position. When provided, + /// the bounds are translated by the negative of this offset, effectively moving + /// the clipping region to compensate for coordinate system transformations. + /// This is useful for nested coordinate systems or when elements are grouped + /// and need relative positioning adjustments. + fn append_clip( + clip_stack: Option, + clip: (Rect, Option, Matrix, Matrix), + ) -> Option { + match clip_stack { + Some(mut stack) => { + stack.push(clip); + Some(stack) + } + None => Some(vec![clip]), + } + } + + pub fn get_children_clip_bounds( + &self, + element: &Shape, + offset: Option<(f32, f32)>, + clip_inset: Option, + ) -> Option { + if self.id.is_nil() || !element.clip() { + return self.clip_bounds.clone(); + } + + let mut bounds = element.selrect(); + if let Some(offset) = offset { + let x = bounds.x() - offset.0; + let y = bounds.y() - offset.1; + let width = bounds.width(); + let height = bounds.height(); + bounds.set_xywh(x, y, width, height); + } + let mut transform = element.transform; + transform.post_translate(bounds.center()); + transform.pre_translate(-bounds.center()); + + let corners = match &element.shape_type { + Type::Rect(data) => data.corners, + Type::Frame(data) => data.corners, + _ => None, + }; + + if let Some(clip_inset) = clip_inset.filter(|&e| e > 0.0) { + bounds.inset((clip_inset, clip_inset)); + } + + Self::append_clip( + self.clip_bounds.clone(), + ( + bounds, + corners, + transform, + transform.invert().unwrap_or_default(), + ), + ) + } + + /// Calculates the clip bounds for shadow rendering of a given shape. + /// + /// This function determines the clipping region that should be applied when rendering a + /// shadow for a shape element. For frames, it uses the shadow bounds to clip nested + /// shadows. For groups, it returns the existing clip bounds since groups should not + /// constrain nested shadows based on their selection rectangle bounds. + /// + /// # Parameters + /// + /// * `element` - The shape element for which to calculate shadow clip bounds + /// * `shadow` - The shadow configuration containing blur, offset, and other properties + pub fn get_nested_shadow_clip_bounds( + &self, + element: &Shape, + shadow: &Shadow, + ) -> Option { + if self.id.is_nil() { + return self.clip_bounds.clone(); + } + + // Assert that the shape is either a Frame or Group + assert!( + matches!(element.shape_type, Type::Frame(_) | Type::Group(_)), + "Shape must be a Frame or Group for nested shadow clip bounds calculation" + ); + + match &element.shape_type { + Type::Frame(_) => { + let mut bounds = element.get_selrect_shadow_bounds(shadow); + let blur_inset = (shadow.blur * 2.).max(0.0); + if blur_inset > 0.0 { + let max_inset_x = (bounds.width() * 0.5).max(0.0); + let max_inset_y = (bounds.height() * 0.5).max(0.0); + // Clamp the inset so we never shrink more than half of the width/height; + // otherwise the rect could end up inverted on small frames. + let inset_x = blur_inset.min(max_inset_x); + let inset_y = blur_inset.min(max_inset_y); + if inset_x > 0.0 || inset_y > 0.0 { + bounds.inset((inset_x, inset_y)); + } + } + + let mut transform = element.transform; + transform.post_translate(element.center()); + transform.pre_translate(-element.center()); + + let corners = match &element.shape_type { + Type::Frame(data) => data.corners, + _ => None, + }; + + Self::append_clip( + self.clip_bounds.clone(), + ( + bounds, + corners, + transform, + transform.invert().unwrap_or_default(), + ), + ) + } + _ => self.clip_bounds.clone(), + } + } +} + +/* + * Sort by z_index descending (higher z renders on top). + * The sort is stable so if the values are equal the index for the children + * has preference. + * When changing this method check the benchmark + */ +pub fn sort_z_index(tree: ShapesPoolRef, element: &Shape, children_ids: Vec) -> Vec { + if element.has_layout() { + let mut ids = children_ids; + ids.sort_by_cached_key(|id| { + std::cmp::Reverse(tree.get(id).map(|s| s.z_index()).unwrap_or(0)) + }); + if element.is_flex() && !element.is_flex_reverse() { + ids.reverse(); + } + ids + } else { + children_ids + } +} + +pub struct RenderStats { + pub counts: HashMap, +} + +#[allow(dead_code)] +impl RenderStats { + pub fn new() -> Self { + Self { + counts: HashMap::new(), + } + } + + fn count(&mut self, id: Uuid) -> i32 { + let counter = self.counts.entry(id).or_insert(0); + *counter += 1; + *counter + } + + fn clear(&mut self) { + self.counts.clear(); + } + + #[allow(dead_code)] + fn get(&self, id: &Uuid) -> Option<&i32> { + self.counts.get(id) + } + + pub(crate) fn print(&self) { + let mut sum: i32 = 0; + for (&id, &count) in self.counts.iter() { + println!("{}: {}", id, count); + sum += count; + } + println!("{}: {}", self.counts.len(), sum); + } +} diff --git a/render-wasm/src/shapes/modifiers/grid_layout.rs b/render-wasm/src/shapes/modifiers/grid_layout.rs index 7ddc877d91..33930b34d9 100644 --- a/render-wasm/src/shapes/modifiers/grid_layout.rs +++ b/render-wasm/src/shapes/modifiers/grid_layout.rs @@ -315,8 +315,6 @@ fn set_flex_multi_span( for track in tracks[start..end].iter_mut() { if track.track_type == GridTrackType::Flex { let new_size = alloc.clamp(track.size, track.max_size); - let aloc = new_size - track.size; - dist -= aloc; track.size = new_size; } } diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 206b91dbb0..44f3d79528 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -24,7 +24,7 @@ pub fn uuid_from_u32(id: [u32; 4]) -> Uuid { uuid_from_u32_quartet(id[0], id[1], id[2], id[3]) } -pub fn get_image(image_id: &Uuid) -> Option<&Image> { +pub fn get_image(image_id: &Uuid) -> Option { get_render_state().images.get(image_id) } diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index 21c4e6a797..e96403dd78 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -70,3 +70,8 @@ pub extern "C" fn is_font_uploaded( res } + +#[no_mangle] +pub extern "C" fn flush_font_caches() { + get_render_state().fonts_mut().flush_caches(); +}