Add drag-sprite render path for shape translation drags

This commit is contained in:
Elena Torro
2026-04-28 13:23:53 +02:00
parent 2aff116906
commit 28e284f41a
3 changed files with 633 additions and 18 deletions

View File

@@ -224,6 +224,17 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
#[wasm_error]
pub extern "C" fn render(_: i32) -> Result<()> {
with_state_mut!(state, {
// Drag-sprite fast path: when a sprite has been captured, the per-rAF
// render bypasses rebuild_modifier_tiles + the entire tile walker.
// It just blits Backbuffer (scene-minus-shape) + the captured shape
// image at the modifier-transformed position + UI overlay.
if state
.render_state
.try_render_drag_sprite_frame(&state.shapes)
{
return Ok(());
}
state.rebuild_touched_tiles();
// Drain the throttled modifier-tile invalidation accumulated
// since the previous rAF. set_modifiers skips this work during
@@ -481,6 +492,21 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
pub extern "C" fn set_modifiers_end() -> Result<()> {
with_state_mut!(state, {
performance::begin_measure!("set_modifiers_end");
// If the drag-sprite fast path was active, the tile cache wasn't
// updated for the dragged shape's new position — tile renders were
// bypassed in favour of image blits. Mark the shape touched so the
// next render's rebuild_touched_tiles invalidates BOTH old (from
// the cache mapping) and new (from shape.extrect) tiles, leaving
// the cache in a consistent state for subsequent pan/zoom.
if let Some(sprite) = state.render_state.drag_sprite.as_ref() {
let shape_id = sprite.shape_id;
state.render_state.mark_touched(shape_id);
}
// Drop the drag-sprite state so the next full render rebuilds tiles
// with the shape at its committed position.
state.render_state.drag_sprite = None;
state.render_state.render_exclude.clear();
state.render_state.render_skip_paint.clear();
state.render_state.options.set_fast_mode(false);
state.render_state.options.set_interactive_transform(false);
state.render_state.cancel_animation_frame();
@@ -986,6 +1012,20 @@ pub extern "C" fn set_modifiers() -> Result<()> {
}
with_state_mut!(state, {
// Drag-sprite Phase 3: on the FIRST set_modifiers of an interactive
// single-shape gesture (before applying the modifier), capture the
// scene-minus-shape backdrop into Backbuffer + the dragged shape
// into a standalone image. Subsequent rAFs use the fast path that
// bypasses the tile walker entirely.
if state.render_state.options.is_interactive_transform()
&& state.render_state.drag_sprite.is_none()
&& ids.len() == 1
{
let ts = performance::get_time();
let _ = state
.render_state
.capture_drag_sprite(&ids[0], &state.shapes, ts);
}
state.set_modifiers(modifiers);
// TO CHECK
if !state.render_state.options.is_interactive_transform() {

View File

@@ -289,6 +289,78 @@ fn sort_z_index(tree: ShapesPoolRef, element: &Shape, children_ids: Vec<Uuid>) -
}
}
/// Partition the scene relative to `dragged_id` for the drag-sprite Phase C
/// (above-overlay) capture.
///
/// Returns `(exclude, skip_paint, has_above)`:
/// - `exclude`: shapes whose entire subtree must NOT render — the dragged
/// shape itself plus every sibling that sits BELOW it at any ancestor
/// level. Excluding their subtrees stops the walker from descending.
/// - `skip_paint`: dragged-shape ancestors. The walker still walks INTO them
/// (otherwise it can't reach above-siblings nested inside) but skips their
/// own fill/stroke paint, so frame backgrounds aren't double-painted on
/// top of the captured shape sprite each frame.
/// - `has_above`: `true` iff any ancestor level has at least one sibling
/// above the path to the dragged shape. When `false`, no overlay capture
/// is needed (the sprite naturally sits on top).
///
/// Z-order convention: in the renderer's `children_ids(false)` order,
/// index 0 is the TOP-MOST sibling and the last index is the bottom-most.
/// So siblings at indices `0..idx` are above and `idx+1..` are below.
fn collect_drag_z_partition(
tree: ShapesPoolRef,
dragged_id: &Uuid,
) -> (HashSet<Uuid>, HashSet<Uuid>, bool) {
let mut exclude: HashSet<Uuid> = HashSet::new();
let mut skip_paint: HashSet<Uuid> = HashSet::new();
let mut has_above = false;
// Exclude the dragged shape's entire subtree — the captured sprite image
// already contains it and we don't want it re-rendered into the overlay.
if let Some(shape) = tree.get(dragged_id) {
for id in shape.all_children_iter(tree, true, true) {
exclude.insert(id);
}
}
let mut current = *dragged_id;
loop {
let Some(parent_id) = tree.get(&current).and_then(|s| s.parent_id) else {
break;
};
// Root (`Uuid::nil()`) has no fills of its own and is the top of the
// walker. Treating it as `skip_paint` is harmless but unnecessary;
// real ancestors with fills (frames) need to be in `skip_paint`.
if !parent_id.is_nil() {
skip_paint.insert(parent_id);
}
let Some(parent) = tree.get(&parent_id) else {
break;
};
let children = sort_z_index(tree, parent, parent.children_ids(false));
if let Some(idx) = children.iter().position(|id| id == &current) {
if idx > 0 {
has_above = true;
}
// Indices `idx + 1..` are below the current node — exclude their
// entire subtrees from the above-overlay.
for below_id in &children[idx + 1..] {
if let Some(below) = tree.get(below_id) {
for id in below.all_children_iter(tree, true, true) {
exclude.insert(id);
}
}
}
}
if parent_id.is_nil() {
break;
}
current = parent_id;
}
(exclude, skip_paint, has_above)
}
pub(crate) struct RenderState {
gpu_state: GpuState,
pub options: RenderOptions,
@@ -345,6 +417,56 @@ pub(crate) struct RenderState {
/// interactive backdrop exactly once per gesture (first rAF) so we don't
/// repeatedly overwrite tiles that have already been updated.
pub interactive_target_seeded: bool,
/// When non-empty, the shape walker skips any node whose UUID is in this
/// set (and therefore its descendants). Used by the drag-sprite capture
/// to render scene-minus-dragged into Backbuffer (Phase A — exclude =
/// {dragged subtree}) and the above-only overlay (Phase C — exclude =
/// {dragged subtree} below-siblings-at-each-ancestor-level). Empty
/// outside the brief capture window.
pub render_exclude: HashSet<Uuid>,
/// When non-empty, the walker walks INTO these nodes (so it can reach
/// their above-children) but skips painting the node's own fills/strokes/
/// effects. Used by the drag-sprite Phase C capture so dragged-shape
/// ancestors (e.g. frame backgrounds) don't get re-painted on top of
/// the captured shape image each frame. Empty outside the capture window.
pub render_skip_paint: HashSet<Uuid>,
/// Drag-sprite state. When `Some`, the per-rAF render path bypasses the
/// tile walker entirely: it blits Backbuffer (which holds scene-minus-
/// shape) and draws only the dragged shape at the modifier-transformed
/// position. Set on the first `set_modifiers` of an interactive gesture
/// after capture succeeds; cleared on `set_modifiers_end`.
pub drag_sprite: Option<DragSprite>,
}
/// Snapshot of the dragged shape at gesture start.
///
/// `surfaces.backbuffer` holds scene-minus-dragged pixels (the static
/// backdrop). `image` holds the dragged shape rasterised on its own.
/// `above_image` holds the shapes that render ABOVE the dragged one in
/// z-order, on a transparent canvas — re-blitting it each frame restores
/// correct stacking after the dragged sprite is drawn.
///
/// Per-rAF render path:
/// 1. seed Target ← Backbuffer (scene-minus-dragged)
/// 2. draw `image` at the modifier-transformed position
/// 3. draw `above_image` on top (above-shapes; transparent elsewhere)
/// 4. UI overlay → flush
#[derive(Clone)]
pub struct DragSprite {
/// Shape being dragged.
pub shape_id: Uuid,
/// Render scale at capture time. Mismatch with current scale (zoom
/// changed mid-drag) invalidates the captured image.
pub captured_at_scale: f32,
/// Dragged shape rasterised standalone, ready for fast blit each rAF.
pub image: skia::Image,
/// Pre-drag extrect (post-margin offset) in document space. The per-rAF
/// blit uses `modifier.map_rect(base_doc_rect)` to position the sprite.
pub base_doc_rect: skia::Rect,
/// Above-shapes captured into a viewport-sized image. `None` when no
/// shape renders above the dragged one (e.g. dragged is top of stack
/// at every ancestor level), letting the per-rAF path skip the blit.
pub above_image: Option<skia::Image>,
}
pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize {
@@ -420,6 +542,9 @@ impl RenderState {
cache_cleared_this_render: false,
current_tile_had_shapes: false,
interactive_target_seeded: false,
render_exclude: HashSet::new(),
render_skip_paint: HashSet::new(),
drag_sprite: None,
})
}
@@ -1757,9 +1882,8 @@ impl RenderState {
let _tile_start = performance::begin_timed_log!("tile_cache_update");
performance::begin_measure!("tile_cache");
let only_visible = self.options.is_interactive_transform();
self.pending_tiles
.update(&self.tile_viewbox, &self.surfaces, only_visible);
.update(&self.tile_viewbox, &self.surfaces);
performance::end_measure!("tile_cache");
performance::end_timed_log!("tile_cache_update", _tile_start);
@@ -1881,6 +2005,442 @@ impl RenderState {
Ok(())
}
/// Per-rAF fast-path render for an active drag-sprite gesture.
///
/// 1. Seed Target from Backbuffer (scene-minus-shape backdrop).
/// 2. Compute the dragged shape's current doc-space rect by reading
/// the modifier-applied extrect from `tree.get(shape_id)`.
/// 3. Blit the captured image at that position on Target.
/// 4. Run UI overlay (selection handles, snap guides).
/// 5. Flush.
///
/// Returns `true` if the fast path ran, `false` if preconditions
/// weren't met (no sprite, scale changed, shape missing).
pub fn try_render_drag_sprite_frame(&mut self, tree: ShapesPoolRef) -> bool {
let sprite = match self.drag_sprite.as_ref() {
Some(s) => s.clone(),
None => return false,
};
// Validity: scale must match capture (zoom changed → fall back).
if (sprite.captured_at_scale - self.get_scale()).abs() > 1e-5 {
crate::run_script!(format!(
"console.log('[drag_sprite] disabled: scale changed (captured {} vs current {})')",
sprite.captured_at_scale,
self.get_scale()
));
self.drag_sprite = None;
return false;
}
// Look up live shape (modifier-applied) to get current position.
let Some(shape) = tree.get(&sprite.shape_id) else {
self.drag_sprite = None;
return false;
};
let current_extrect = shape.extrect(tree, sprite.captured_at_scale);
// Validity: modifier must be pure translation. Rotation and scale
// change the shape's AABB dimensions, and blitting the captured
// image into a different-sized rect would stretch / skew it. We
// detect non-translation by comparing extrect dimensions to the
// captured ones — translation preserves them, rotate/scale don't.
//
// On mismatch we skip THIS frame (slow path runs) but keep the
// sprite armed: many drags pass through brief non-translation
// states (snap correction, layout reflow at boundaries) and recover
// to pure translation seconds later. Permanently disabling would
// require the user to release + re-grab to resume the fast path.
// Epsilon is generous (5 doc units) to absorb sub-pixel snap noise.
let eps = 5.0;
if (current_extrect.width() - sprite.base_doc_rect.width()).abs() > eps
|| (current_extrect.height() - sprite.base_doc_rect.height()).abs() > eps
{
return false;
}
performance::begin_measure!("drag_sprite_render_frame");
// The image was captured into a surface that's `extrect + margin
// band on every side`. The shape itself lives in the centre, with
// margin space around it for strokes/shadows that extend past the
// geometric bounds. Per rAF, expand the modifier-applied extrect
// by the same margin amount so the image's content (shape +
// surrounding effects) lands at the right doc-space position.
let margins = self.surfaces.margins;
let margins_doc_x = margins.width as f32 / sprite.captured_at_scale;
let margins_doc_y = margins.height as f32 / sprite.captured_at_scale;
let dst_doc_rect = skia::Rect::from_ltrb(
current_extrect.left - margins_doc_x,
current_extrect.top - margins_doc_y,
current_extrect.right + margins_doc_x,
current_extrect.bottom + margins_doc_y,
);
// 1. Seed Target from Backbuffer (scene-minus-shape backdrop).
self.surfaces.seed_target_from_backbuffer();
// 2. Map doc-space rect to target pixel coords.
// Standard viewbox transform: target_px = (doc + pan) * (zoom * dpr).
let s = self.viewbox.zoom * self.options.dpr();
let dst = skia::Rect::from_xywh(
(dst_doc_rect.left + self.viewbox.pan_x) * s,
(dst_doc_rect.top + self.viewbox.pan_y) * s,
dst_doc_rect.width() * s,
dst_doc_rect.height() * s,
);
// 3. Blit the captured shape image.
let canvas = self.surfaces.canvas(SurfaceId::Target);
canvas.save();
canvas.reset_matrix();
canvas.draw_image_rect(&sprite.image, None, dst, &skia::Paint::default());
canvas.restore();
// 3b. Restore z-order: blit the above-overlay (shapes that render
// after the dragged shape in document order) over the sprite.
// The image is the viewport plus a margin band on every side
// (so content along the edges isn't clipped at capture time).
// We crop the margin band with a source rect when blitting so
// the viewport's top-left in the image lines up with Target's
// pixel (0, 0). `None` when the dragged shape is on top of the
// stack everywhere — nothing to restore in that case.
if let Some(above_image) = sprite.above_image.as_ref() {
let margins = self.surfaces.margins;
let canvas = self.surfaces.canvas(SurfaceId::Target);
let target_size = canvas.base_layer_size();
let target_w = target_size.width as f32;
let target_h = target_size.height as f32;
let src = skia::Rect::from_xywh(
margins.width as f32,
margins.height as f32,
target_w,
target_h,
);
let dst = skia::Rect::from_xywh(0.0, 0.0, target_w, target_h);
canvas.save();
canvas.reset_matrix();
canvas.draw_image_rect(
above_image,
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
dst,
&skia::Paint::default(),
);
canvas.restore();
}
// 4. UI overlay (selection handles, snap guides) over the sprite.
ui::render(self, tree);
// 5. Flush to canvas.
self.flush_and_submit();
performance::end_measure!("drag_sprite_render_frame");
true
}
/// Drag-sprite capture: render the scene WITHOUT the given shape into
/// Backbuffer, AND rasterise the shape itself into a standalone image.
/// Called on the first `set_modifiers` of an interactive gesture once
/// the dragged shape's id is known.
///
/// On success, sets `self.drag_sprite`, after which the per-rAF render
/// path bypasses the tile walker entirely. On failure (shape missing,
/// render error) leaves `drag_sprite` as `None` and the gesture falls
/// back to Alex's tile-based interactive path.
///
/// Cost: one synchronous full render + one shape rasterisation. Paid
/// once per gesture.
pub fn capture_drag_sprite(
&mut self,
shape_id: &Uuid,
tree: ShapesPoolRef,
timestamp: i32,
) -> Result<()> {
performance::begin_measure!("capture_drag_sprite");
let dx_t = crate::get_now!();
let scale = self.get_scale();
// Look up the shape and its pre-drag extrect up-front; bail early
// if anything is wrong so we don't half-set state.
let Some(shape) = tree.get(shape_id) else {
performance::end_measure!("capture_drag_sprite");
return Err(crate::error::Error::CriticalError(format!(
"drag_sprite: shape {} not found",
shape_id
)));
};
let base_doc_rect = shape.extrect(tree, scale);
if base_doc_rect.is_empty() {
performance::end_measure!("capture_drag_sprite");
return Err(crate::error::Error::CriticalError(
"drag_sprite: empty extrect".to_string(),
));
}
// Temporarily exit interactive_transform so the normal tile pipeline
// runs (Alex's interactive path skips per-tile work in ways that
// would corrupt the capture).
let was_interactive = self.options.is_interactive_transform();
self.options.set_interactive_transform(false);
// --- Phase A: scene-minus-shape into Backbuffer ---
// Optimization: only re-render the tiles the dragged shape's bbox
// intersects. Other tiles already have correct scene-minus-shape
// content (the shape doesn't appear there anyway). This avoids a
// full-viewport re-render and keeps the upfront cost proportional
// to the dragged shape's size, not the scene's complexity.
self.render_exclude.clear();
self.render_exclude.insert(*shape_id);
let shape_tiles: Vec<tiles::Tile> = self
.tiles
.get_tiles_of(*shape_id)
.map(|s| s.iter().copied().collect())
.unwrap_or_default();
for tile in &shape_tiles {
self.surfaces
.remove_cached_tile_surface(&mut self.gpu_state, *tile);
}
self.cache_cleared_this_render = false;
let backdrop_result = self.start_render_loop(None, tree, timestamp, true);
if backdrop_result.is_ok() {
self.surfaces.copy_target_to_backbuffer();
}
self.render_exclude.clear();
// --- Phase C: shapes ABOVE the dragged shape, into a viewport-sized
// image. Re-blitted on top of the sprite each frame so
// above-siblings keep their correct stacking. ---
// We walk the tree directly (not via start_render_loop) so the tile
// cache built by Phase A stays untouched: this pass renders into
// Export sized to the workspace viewport, snapshots the result, and
// never writes to the per-tile cache.
let above_image_result: Result<Option<skia::Image>> = (|| {
let (exclude_set, skip_paint_set, has_above) = collect_drag_z_partition(tree, shape_id);
// No siblings render above the dragged shape at any ancestor
// level — the captured sprite is correctly on top, no overlay
// needed.
if !has_above {
return Ok(None);
}
let target_surface = SurfaceId::Export;
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;
self.focus_mode.clear();
self.surfaces
.canvas(target_surface)
.clear(skia::Color::TRANSPARENT);
// Size Export to the workspace viewport plus the standard margin
// band on every side. `update_render_context(viewport_rect, ..)`
// uses translation `-viewport_rect.left + margins/scale`, so the
// viewport's top-left maps to canvas pixel (margins, margins).
// Without the margin band, content along the viewport's right /
// bottom edge would render past the surface and be clipped. The
// per-rAF blit later strips the margin band back off.
let viewport_rect = self.viewbox.area;
let margins = self.surfaces.margins;
let margins_doc_x = margins.width as f32 / scale;
let margins_doc_y = margins.height as f32 / scale;
let expanded_viewport = skia::Rect::from_ltrb(
viewport_rect.left - margins_doc_x,
viewport_rect.top - margins_doc_y,
viewport_rect.right + margins_doc_x,
viewport_rect.bottom + margins_doc_y,
);
self.export_context = Some((viewport_rect, scale));
self.surfaces
.resize_export_surface(scale, expanded_viewport);
self.render_area = viewport_rect;
self.render_area_with_margins = viewport_rect;
self.surfaces.update_render_context(viewport_rect, scale);
self.render_exclude = exclude_set;
self.render_skip_paint = skip_paint_set;
// Push root shapes (filtered by exclude — below-shapes whose
// ancestor sits at root level are pruned here so we don't even
// descend into them).
let root_children: Vec<Uuid> = tree
.get(&Uuid::nil())
.map(|root| root.children_ids(false))
.unwrap_or_default();
for child_id in root_children.iter() {
if self.render_exclude.contains(child_id) {
continue;
}
self.pending_nodes.push(NodeRenderState {
id: *child_id,
visited_children: false,
clip_bounds: None,
visited_mask: false,
mask: false,
flattened: false,
});
}
let render_result =
self.render_shape_tree_partial_uncached(tree, timestamp, false, true);
self.export_context = None;
self.surfaces
.flush_and_submit(&mut self.gpu_state, target_surface);
let above_image = self.surfaces.snapshot(target_surface);
// Restore.
self.render_exclude.clear();
self.render_skip_paint.clear();
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;
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);
}
render_result?;
Ok(Some(above_image))
})();
// --- Phase B: rasterise the dragged shape into Export, snapshot. ---
// Mirrors `render_shape_pixels` save/restore but returns the Image.
let image_result: Result<skia::Image> = (|| {
let target_surface = SurfaceId::Export;
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;
self.focus_mode.clear();
self.surfaces
.canvas(target_surface)
.clear(skia::Color::TRANSPARENT);
// Size the Export surface to extrect PLUS one margin band on
// every side, so strokes / drop shadows / blur that extend
// past the shape's geometric bounds aren't clipped at the
// surface edge. With `update_render_context(base_doc_rect, ..)`
// the translation is `-base_doc_rect.left + margins/scale`, so
// the shape draws at canvas pixel (margins, margins) and has
// a margin band of buffer space on all four sides.
let margins = self.surfaces.margins;
let margins_doc_x = margins.width as f32 / scale;
let margins_doc_y = margins.height as f32 / scale;
let expanded_doc_rect = skia::Rect::from_ltrb(
base_doc_rect.left - margins_doc_x,
base_doc_rect.top - margins_doc_y,
base_doc_rect.right + margins_doc_x,
base_doc_rect.bottom + margins_doc_y,
);
self.export_context = Some((base_doc_rect, scale));
self.surfaces
.resize_export_surface(scale, expanded_doc_rect);
self.render_area = base_doc_rect;
self.render_area_with_margins = base_doc_rect;
self.surfaces.update_render_context(base_doc_rect, scale);
self.pending_nodes.push(NodeRenderState {
id: *shape_id,
visited_children: false,
clip_bounds: None,
visited_mask: false,
mask: false,
flattened: false,
});
let render_result =
self.render_shape_tree_partial_uncached(tree, timestamp, false, true);
self.export_context = None;
self.surfaces
.flush_and_submit(&mut self.gpu_state, target_surface);
let image = self.surfaces.snapshot(target_surface);
// Restore.
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;
// Workspace render context — same logic as render_shape_pixels.
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);
}
render_result?;
Ok(image)
})();
if let (Ok(_), Ok(image)) = (&backdrop_result, &image_result) {
// `above_image` is `Ok(None)` when the dragged shape sits at the
// top of the stack (no above-siblings) and `Ok(Some(_))` after a
// successful capture. On Phase C error we still publish the
// sprite (degrades to "always on top" rather than disabling the
// fast path entirely).
let above_image = above_image_result.ok().flatten();
self.drag_sprite = Some(DragSprite {
shape_id: *shape_id,
captured_at_scale: scale,
image: image.clone(),
base_doc_rect,
above_image,
});
}
self.options.set_interactive_transform(was_interactive);
let dx_dt = crate::get_now!() - dx_t;
crate::run_script!(format!(
"console.log('[drag_sprite] captured shape={} scale={:.2} ok={} took={:.1}ms')",
shape_id,
scale,
self.drag_sprite.is_some(),
dx_dt
));
performance::end_measure!("capture_drag_sprite");
backdrop_result.and(image_result.map(|_| ()))
}
pub fn render_shape_pixels(
&mut self,
id: &Uuid,
@@ -2133,7 +2693,9 @@ impl RenderState {
}
//In clipped content strokes are drawn over the contained elements
if element.clip() {
// (skipped for nodes in `render_skip_paint` — see Phase C of the
// drag-sprite capture: ancestor frames are walked but not painted).
if element.clip() && !self.render_skip_paint.contains(&element.id) {
let mut element_strokes: Cow<Shape> = Cow::Borrowed(element);
element_strokes.to_mut().clear_fills();
element_strokes.to_mut().clear_shadows();
@@ -2774,7 +3336,15 @@ impl RenderState {
self.render_shape_enter(element, mask, target_surface);
}
if !node_render_state.is_root() && self.focus_mode.is_active() {
// Drag-sprite Phase C: ancestors of the dragged shape walk into
// their children so above-siblings nested inside a frame can be
// reached, but we skip painting their own fills/strokes/effects
// — the captured backdrop already contains them, and re-painting
// here would double-render frame backgrounds on top of the
// sprite each frame.
let skip_paint_for_node = self.render_skip_paint.contains(&node_id);
if !skip_paint_for_node && !node_render_state.is_root() && self.focus_mode.is_active() {
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
@@ -2823,7 +3393,7 @@ impl RenderState {
self.surfaces
.canvas(SurfaceId::DropShadows)
.clear(skia::Color::TRANSPARENT);
} else if visited_children {
} else if !skip_paint_for_node && visited_children {
self.apply_drawing_to_render_canvas(Some(element), target_surface);
}
@@ -2876,6 +3446,12 @@ impl RenderState {
let children_ids = sort_z_index(tree, element, children_ids);
for child_id in children_ids.iter() {
// Drag-sprite capture: skip excluded subtrees (the
// dragged shape itself, and on the Phase C above-only
// pass, every below-sibling at each ancestor level).
if self.render_exclude.contains(child_id) {
continue;
}
self.pending_nodes.push(NodeRenderState {
id: *child_id,
visited_children: false,
@@ -3049,9 +3625,17 @@ impl RenderState {
// siblings inside the same tile would disappear.
let mut valid_ids = Vec::with_capacity(ids.len());
if self.options.is_interactive_transform() || tile_has_bg_blur {
valid_ids.extend(root_ids.iter().copied());
valid_ids.extend(
root_ids
.iter()
.copied()
.filter(|id| !self.render_exclude.contains(id)),
);
} else {
for root_id in root_ids.iter() {
if self.render_exclude.contains(root_id) {
continue;
}
if ids.contains(root_id) {
valid_ids.push(*root_id);
}

View File

@@ -265,20 +265,11 @@ impl PendingTiles {
result
}
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) {
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) {
self.list.clear();
// During interactive transform, skip the interest-area ring
// entirely — the user is dragging, every rAF is on the critical
// path, and pre-rendering tiles outside the viewport is wasted
// work that just gets evicted on the next pointer move. The ring
// is repopulated naturally on gesture end / on idle rAFs.
let spiral_rect = if only_visible {
&tile_viewbox.visible_rect
} else {
&tile_viewbox.interest_rect
};
let spiral = Self::generate_spiral(spiral_rect);
// Generate spiral for the interest area (viewport + margin)
let spiral = Self::generate_spiral(&tile_viewbox.interest_rect);
// Partition tiles into 4 priority groups (highest priority = processed last due to pop()):
// 1. visible + cached (fastest - just blit from cache)