🐛 Fix shapes cut when overflowing a non-clipping board

This commit is contained in:
Elena Torro 2026-08-28 16:14:05 +02:00
parent 2ce202c7d8
commit 6584de6987
5 changed files with 119 additions and 63 deletions

View File

@ -911,7 +911,7 @@ pub extern "C" fn clean_modifiers() -> Result<()> {
// the same tiles for the active modifier set, so the eviction
// here is redundant and doubles the per-emission cost.
if !prev_modifier_ids.is_empty() && !render_state.options.is_interactive_transform() {
render_state.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
render_state.update_tiles_shapes(&prev_modifier_ids, &state.shapes)?;
}
});
Ok(())

View File

@ -4202,22 +4202,8 @@ impl RenderState {
result
}
/*
* Incremental version of update_shape_tiles for pan/zoom operations.
* Updates the tile index and returns ONLY tiles that need cache invalidation.
*
* During pan operations, shapes don't move in world coordinates. The interest
* area (viewport) moves, which changes which tiles we track in the index, but
* tiles that were already cached don't need re-rendering just because the
* viewport moved.
*
* This function:
* 1. Updates the tile index (adds/removes shapes from tiles based on interest area)
* 2. Returns empty vec for cache invalidation (pan doesn't change tile content)
*
* Tile cache invalidation only happens when shapes actually move or change,
* which is handled by rebuild_touched_tiles, not during pan/zoom.
*/
/// Diffs the shape's tile set, leaving cached tiles alone. For callers where the
/// index moves but painted content does not: pan/zoom, and re-indexed ancestors.
pub fn update_shape_tiles_incremental(
&mut self,
shape: &Shape,
@ -4378,22 +4364,14 @@ impl RenderState {
}
}
self.index_dependent_ancestors(&ids, tree);
performance::end_measure!("rebuild_touched_tiles");
}
/// Invalidates extended rectangles and updates tiles for a set of shapes
///
/// This function takes a set of shape IDs and for each one:
/// 1. Invalidates the extrect cache
/// 2. Updates the tiles to ensure proper rendering
///
/// This is useful when you have a pre-computed set of shape IDs that need to be refreshed,
/// regardless of their relationship to other shapes (e.g., ancestors, descendants, or any other collection).
pub fn update_tiles_shapes(
&mut self,
shape_ids: &[Uuid],
tree: ShapesPoolMutRef<'_>,
) -> Result<()> {
/// Re-indexes a set of shapes and evicts the cached tiles they dirty. Extrect caches
/// are not dropped here; `State::touch_shape` invalidates them at the source.
pub fn update_tiles_shapes(&mut self, shape_ids: &[Uuid], tree: ShapesPoolRef) -> Result<()> {
performance::begin_measure!("invalidate_and_update_tiles");
for shape_id in shape_ids {
if let Some(shape) = tree.get(shape_id) {
@ -4439,24 +4417,51 @@ impl RenderState {
self.surfaces.invalidate_cached_tiles_intersecting(dirty);
}
/// Rebuilds tiles for shapes with modifiers and processes their ancestors
///
/// This function applies transformation modifiers to shapes and updates their tiles.
/// Additionally, it processes all ancestors of modified shapes to ensure their
/// extended rectangles are properly recalculated and their tiles are updated.
/// This is crucial for frames and groups that contain transformed children.
fn index_dependent_ancestors(&mut self, ids: &HashSet<Uuid>, tree: ShapesPoolRef) {
if ids.is_empty() {
return;
}
let mut ancestors = Vec::<Uuid>::new();
let mut seen = HashSet::<Uuid>::new();
for id in ids.iter() {
ancestors.clear();
tree.collect_dependent_ancestors(id, &mut ancestors);
for ancestor_id in ancestors.iter() {
if ids.contains(ancestor_id) || !seen.insert(*ancestor_id) {
continue;
}
let Some(shape) = tree.get(ancestor_id) else {
continue;
};
// A hidden ancestor paints nothing, but keep climbing: its parent may.
if shape.hidden() {
continue;
}
// Diffs the tile set instead of removing and re-adding every tile, which
// matters because this runs once per gesture frame.
let _ = self.update_shape_tiles_incremental(shape, tree);
}
}
}
pub fn rebuild_modifier_tiles(
&mut self,
tree: ShapesPoolMutRef<'_>,
ids: &[Uuid],
) -> Result<()> {
// During interactive transform, skip ancestor invalidation: walking up to the
// parent frame evicts every tile the frame covers, including dense tiles with
// many siblings. Ancestor extrect caches are already invalidated by
// `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by
// the committing code path (rebuild_touched_tiles).
// `set_modifiers` runs per pointer move, this runs once per rAF, so the ancestor
// caches are dropped here. Must precede any read of their tile coverage below.
for id in ids {
tree.invalidate_ancestors_extrect(id);
}
if self.options.is_interactive_transform() {
self.update_tiles_shapes(ids, tree)?;
let modified: HashSet<Uuid> = ids.iter().copied().collect();
self.index_dependent_ancestors(&modified, tree);
} else {
let ancestors = all_with_ancestors(ids, tree, false);
self.update_tiles_shapes(&ancestors, tree)?;

View File

@ -960,6 +960,14 @@ impl Shape {
Bounds::from_rect(&rect)
}
pub fn extrect_depends_on_children(&self) -> bool {
match self.shape_type {
Type::Group(Group { masked: true }) => true,
Type::Group(_) | Type::Frame(_) => !self.clip_content,
_ => false,
}
}
fn apply_children_bounds(
&self,
bounds: Bounds,
@ -1125,20 +1133,18 @@ impl Shape {
fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
let mut bounds = self.own_extrect_bounds();
bounds = self.apply_children_bounds(bounds, shapes_pool, scale);
bounds = self.apply_children_blur(bounds, shapes_pool);
if !self.transform.is_identity() {
// Expand everything in the shape's local axis-aligned space first (strokes,
// shadows, blur, children). Only after that do we map the resulting bounds
// through the shape transform so rotation/skew is reflected in the final
// extrect.
let mut matrix = self.transform;
let center = self.center();
matrix.post_translate(center);
matrix.pre_translate(-center);
bounds.transform_mut(&matrix);
}
bounds = self.apply_children_bounds(bounds, shapes_pool, scale);
bounds = self.apply_children_blur(bounds, shapes_pool);
bounds.to_rect()
}

View File

@ -341,21 +341,13 @@ impl State {
}
pub fn touch_current(&mut self) {
// `mark_touched` only drives incremental on-screen tile invalidation;
// the headless export path has no render state, so skip it there.
if self.loading || !has_render_state() {
return;
}
if let Some(current_id) = self.current_id {
let prev = self
.shapes
.get(&current_id)
.map(|shape| shape.extrect(&self.shapes, 1.0));
get_render_state().mark_touched_with_prev(current_id, prev);
self.touch_shape(current_id);
}
}
pub fn touch_shape(&mut self, id: Uuid) {
self.shapes.invalidate_ancestors_extrect(&id);
if self.loading || !has_render_state() {
return;
}

View File

@ -59,6 +59,8 @@ pub struct ShapesPoolImpl {
structure: HashMap<usize, Vec<StructureEntry>>,
/// Scale content values, keyed by index
scale_content: HashMap<usize, f32>,
/// Scratch for ancestor walks: `invalidate_ancestors_extrect` runs on every mutation.
ancestor_scratch: Vec<Uuid>,
}
// Type aliases - no longer need lifetimes!
@ -78,6 +80,7 @@ impl ShapesPoolImpl {
modifier_uuids: Vec::new(),
structure: HashMap::default(),
scale_content: HashMap::default(),
ancestor_scratch: Vec::new(),
}
}
@ -239,6 +242,54 @@ impl ShapesPoolImpl {
self.modified_shape_cache.clear()
}
pub fn collect_dependent_ancestors(&self, id: &Uuid, out: &mut Vec<Uuid>) {
let Some(idx) = self.uuid_to_idx.get(id).copied() else {
return;
};
let mut current = self.shapes[idx].parent_id;
let mut depth = 0;
while let Some(parent_id) = current {
if parent_id.is_nil() {
break;
}
let Some(parent_idx) = self.uuid_to_idx.get(&parent_id).copied() else {
break;
};
if !self.shapes[parent_idx].extrect_depends_on_children() {
break;
}
out.push(parent_id);
current = self.shapes[parent_idx].parent_id;
// A chain longer than the pool means the parent links form a cycle.
depth += 1;
if depth >= self.shapes.len() {
break;
}
}
}
pub fn invalidate_ancestors_extrect(&mut self, id: &Uuid) {
let mut scratch = std::mem::take(&mut self.ancestor_scratch);
scratch.clear();
self.collect_dependent_ancestors(id, &mut scratch);
for parent_id in scratch.iter() {
let Some(parent_idx) = self.uuid_to_idx.get(parent_id).copied() else {
continue;
};
self.shapes[parent_idx].invalidate_extrect();
if let Some(cell) = self.modified_shape_cache.get_mut(&parent_idx) {
*cell = OnceCell::new();
}
}
self.ancestor_scratch = scratch;
}
pub fn set_modifiers(&mut self, modifiers: HashMap<Uuid, skia::Matrix>) {
let mut ids = Vec::<Uuid>::new();
let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len());
@ -254,10 +305,8 @@ impl ShapesPoolImpl {
// When CLJS sends only root shapes (translation on drag), descendants
// need the same matrix.
// For resize/rotate, propagate-modifiers already includes all descendants.
// Descendants are NOT pushed into `ids` / `modifier_uuids`: tile invalidation
// via rebuild_modifier_tiles only runs for roots, which is sufficient because
// descendants always lie inside the parent's bounding box and are therefore
// covered by the parent's old/new tile ranges.
// Descendants are NOT pushed into `ids` / `modifier_uuids`: rebuild_modifier_tiles
// runs for roots, and drops the non-clipping ancestors' extrects separately.
let root_pairs: Vec<(usize, skia::Matrix)> = ids
.iter()
.filter_map(|uuid| {
@ -289,13 +338,15 @@ impl ShapesPoolImpl {
// Compute ancestors before consuming `ids` so we can move it into
// `modifier_uuids` without a clone.
let all_ids = shapes::all_with_ancestors(&ids, self, true);
// rebuild_modifier_tiles doesn't process every descendant individually.
self.modifier_uuids = ids;
for uuid in all_ids {
if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() {
self.modified_shape_cache.insert(idx, OnceCell::new());
}
}
// rebuild_modifier_tiles doesn't process every descendant individually.
self.modifier_uuids = ids;
}
pub fn set_structure(&mut self, structure: HashMap<Uuid, Vec<StructureEntry>>) {
@ -402,6 +453,7 @@ impl ShapesPoolImpl {
modifier_uuids: Vec::new(),
structure: HashMap::default(),
scale_content: HashMap::default(),
ancestor_scratch: Vec::new(),
}
}
@ -484,6 +536,7 @@ impl Clone for ShapesPoolImpl {
modifier_uuids: self.modifier_uuids.clone(),
structure: self.structure.clone(),
scale_content: self.scale_content.clone(),
ancestor_scratch: Vec::new(),
}
}
}