diff --git a/CHANGES.md b/CHANGES.md index d1324adeb9..683cba1efd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -62,6 +62,7 @@ ### :bug: Bugs fixed +- Fix render-wasm atlas corruption when dragging large shapes after a zoom or pan change (stale multi-zoom-level pixels no longer appear at the old shape position). - Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361) - Add token name on broken token pill on sidebar [Taiga #13527](https://tree.taiga.io/project/penpot/issue/13527) - Fix tooltip activated when tab change [Taiga #13627](https://tree.taiga.io/project/penpot/issue/13627) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index f34f099b7d..2fc04534a2 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -3507,6 +3507,25 @@ impl RenderState { let mut result = HashSet::::with_capacity(old_tiles.len()); + // When the shape has an active modifier (i.e. is being moved/resized), + // clear its OLD doc-space extent from the atlas using the raw + // (pre-modifier) shape. The per-tile clearing done later via + // `clear_tile_in_atlas` only covers tiles tracked in `atlas_tile_doc_rects` + // at the current zoom level. However, the atlas may also contain stale + // pixels from previous zoom levels (tiles are larger / smaller in doc + // space at different zoom scales) that were never re-tracked after a zoom + // change. Clearing the full raw extrect here removes all such residual + // content without growing the atlas. + // + // We intentionally skip this when there is NO modifier so that plain + // zoom / pan tile-index rebuilds do NOT invalidate valid atlas content. + if tree.get_modifier(&shape.id).is_some() { + if let Some(raw_shape) = tree.get_raw(&shape.id) { + let old_extrect = raw_shape.extrect(tree, 1.0); + self.surfaces.clear_doc_rect_in_atlas_clipped(old_extrect); + } + } + // First, remove the shape from all tiles where it was previously located for tile in old_tiles { self.tiles.remove_shape_at(tile, shape.id); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index f4a3456e9b..381d69c730 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -396,6 +396,58 @@ impl Surfaces { Ok(()) } + /// Clears a doc-space rect from the atlas **without** growing it. + /// + /// Unlike [`clear_doc_rect_in_atlas`], this method clips `doc_rect` to the + /// current atlas bounds and skips silently if there is no overlap. Use this + /// when evicting stale shape content (e.g. before a drag re-render) where + /// growing the atlas to accommodate an out-of-range rect would be wasteful. + pub fn clear_doc_rect_in_atlas_clipped(&mut self, doc_rect: skia::Rect) { + if !self.has_atlas() || doc_rect.is_empty() { + return; + } + + let atlas_scale = self.atlas_scale.max(0.01); + let atlas_doc_right = self.atlas_origin.x + (self.atlas_size.width as f32) / atlas_scale; + let atlas_doc_bottom = self.atlas_origin.y + (self.atlas_size.height as f32) / atlas_scale; + + // Intersect with current atlas bounds in doc space. + let mut clipped = doc_rect; + let atlas_bounds = skia::Rect::from_ltrb( + self.atlas_origin.x, + self.atlas_origin.y, + atlas_doc_right, + atlas_doc_bottom, + ); + if !clipped.intersect(atlas_bounds) { + return; + } + + // Apply atlas_doc_bounds clamping. + if let Some(bounds) = self.atlas_doc_bounds { + if !clipped.intersect(bounds) { + return; + } + } + + if clipped.is_empty() { + return; + } + + let dst = skia::Rect::from_xywh( + (clipped.left - self.atlas_origin.x) * atlas_scale, + (clipped.top - self.atlas_origin.y) * atlas_scale, + clipped.width() * atlas_scale, + clipped.height() * atlas_scale, + ); + + let canvas = self.atlas.canvas(); + canvas.save(); + canvas.clip_rect(dst, None, true); + canvas.clear(skia::Color::TRANSPARENT); + canvas.restore(); + } + pub fn clear_tiles(&mut self) { self.tiles.clear(); }