🐛 Fix atlas corruption when dragging large shapes after zoom change

This commit is contained in:
Alejandro Alonso 2026-05-13 11:25:37 +02:00
parent 4f5d269313
commit 1a3b057814
3 changed files with 72 additions and 0 deletions

View File

@ -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)

View File

@ -3507,6 +3507,25 @@ impl RenderState {
let mut result = HashSet::<tiles::Tile>::with_capacity(old_tiles.len());
// When the shape has an active modifier (i.e. is being moved/resized),
// clear its OLD doc-space extent from the atlas using the raw
// (pre-modifier) shape. The per-tile clearing done later via
// `clear_tile_in_atlas` only covers tiles tracked in `atlas_tile_doc_rects`
// at the current zoom level. However, the atlas may also contain stale
// pixels from previous zoom levels (tiles are larger / smaller in doc
// space at different zoom scales) that were never re-tracked after a zoom
// change. Clearing the full raw extrect here removes all such residual
// content without growing the atlas.
//
// We intentionally skip this when there is NO modifier so that plain
// zoom / pan tile-index rebuilds do NOT invalidate valid atlas content.
if tree.get_modifier(&shape.id).is_some() {
if let Some(raw_shape) = tree.get_raw(&shape.id) {
let old_extrect = raw_shape.extrect(tree, 1.0);
self.surfaces.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);

View File

@ -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();
}