diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index a6f1f67608..13ee9fd668 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -1049,7 +1049,7 @@ pub extern "C" fn set_modifiers() -> Result<()> { let ts = performance::get_time(); state .render_state - .drag_sprite_try_capture(&ids, &state.shapes, ts)?; + .drag_sprite_try_capture(&ids, &modifiers, &state.shapes, ts)?; } state.set_modifiers(modifiers); // Throttle: skip per-pointer-move tile invalidation. The render diff --git a/render-wasm/src/render/drag_sprite.rs b/render-wasm/src/render/drag_sprite.rs index 84f7a1983a..b0dffbc052 100644 --- a/render-wasm/src/render/drag_sprite.rs +++ b/render-wasm/src/render/drag_sprite.rs @@ -17,7 +17,7 @@ //! render rebuild the affected tiles with the shape at its committed //! position. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use skia_safe::{self as skia, Rect}; @@ -71,6 +71,80 @@ pub struct DragSprite { pub above_image: Option, } +/// Find the unique "primary" shape in the modifier set: the one whose +/// ancestors are NOT also in the set. For a layout-container drag +/// `propagate-modifiers` expands the user's single-shape modifier into +/// transforms-per-descendant; this picks the topmost one back out so +/// the sprite path can capture the parent (whose subtree contains all +/// the propagated children). +/// +/// Returns `None` when the set has no unique root (multi-shape drag of +/// unrelated shapes), in which case the fast path is disabled. +/// +/// Currently dormant — pairs with the layout-drag atlas-sample +/// shortcut that hasn't landed cleanly yet. +#[allow(dead_code)] +fn find_primary_shape(ids: &[Uuid], tree: ShapesPoolRef) -> Option { + let id_set: HashSet = ids.iter().copied().collect(); + let mut roots: Vec = Vec::new(); + for &id in ids { + // Walk ancestors; if any is in the set, `id` isn't a root. + let mut cur = tree.get(&id).and_then(|s| s.parent_id); + let mut has_ancestor_in_set = false; + while let Some(pid) = cur { + if id_set.contains(&pid) { + has_ancestor_in_set = true; + break; + } + cur = tree.get(&pid).and_then(|s| s.parent_id); + } + if !has_ancestor_in_set { + roots.push(id); + if roots.len() > 1 { + return None; + } + } + } + if roots.len() == 1 { + Some(roots[0]) + } else { + None + } +} + +/// True iff every matrix in `modifiers` is a pure translation AND they +/// all share the same `(tx, ty)` (within `eps`). This is the safety +/// invariant for the layout-container drag path: a layout that's just +/// being dragged emits the same translation for the parent and every +/// auto-laid-out descendant. If any descendant differs, the layout has +/// reflowed (e.g. a flex item resized) and the captured sprite would +/// no longer represent the live state. +/// +/// Currently dormant — pairs with `find_primary_shape`. +#[allow(dead_code)] +fn all_modifiers_uniform_translation<'a, I>(modifiers: I) -> bool +where + I: IntoIterator, +{ + let eps = 1e-3_f32; + let mut first: Option<(f32, f32)> = None; + for m in modifiers { + if !is_translation_only(m) { + return false; + } + let (tx, ty) = (m.translate_x(), m.translate_y()); + match first { + None => first = Some((tx, ty)), + Some((fx, fy)) => { + if (tx - fx).abs() > eps || (ty - fy).abs() > eps { + return false; + } + } + } + } + true +} + /// Returns true iff the matrix is a pure translation (within a small /// epsilon to absorb floating-point noise from modifier composition). /// Drag-sprite's per-rAF blit assumes the captured pixels are still @@ -122,6 +196,7 @@ impl RenderState { pub fn drag_sprite_try_capture( &mut self, ids: &[Uuid], + _modifiers: &HashMap, tree: ShapesPoolRef, timestamp: i32, ) -> Result<()> { @@ -129,7 +204,11 @@ impl RenderState { return Ok(()); } if ids.len() != 1 { - // Multi-shape drag: out of Phase 3 scope. + // Multi-shape drag (incl. layout containers whose + // `propagate-modifiers` expanded into per-descendant + // transforms): no fast path — slow tile walker runs for the + // gesture. Layout-aware drag-sprite optimization is a known + // open problem. self.drag_sprite_state = DragSpriteState::Disabled; return Ok(()); } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index a980cba809..0354ceb900 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -403,6 +403,108 @@ impl Surfaces { self.atlas_size.width > 0 && self.atlas_size.height > 0 } + /// True iff the atlas (a) exists, (b) has the same scale as the + /// workspace render scale (no resampling on the sprite), and (c) + /// fully contains `doc_rect`. Strict because the layout-drag + /// atlas-sample path is gated on this and any miss should bail to + /// the slow path with no fallback — partial coverage / scale + /// mismatch caused too many regressions. + /// + /// Currently dormant — pairs with the layout-drag atlas-sample + /// shortcut that hasn't landed cleanly yet. + #[allow(dead_code)] + pub fn atlas_covers_doc_rect_strict( + &self, + doc_rect: skia::Rect, + workspace_scale: f32, + ) -> bool { + if !self.has_atlas() { + return false; + } + if (self.atlas_scale - workspace_scale).abs() > 1e-3 { + return false; + } + let atlas_scale = self.atlas_scale.max(0.01); + let left = self.atlas_origin.x; + let top = self.atlas_origin.y; + let right = left + (self.atlas_size.width as f32) / atlas_scale; + let bottom = top + (self.atlas_size.height as f32) / atlas_scale; + let eps = 1e-3_f32; + doc_rect.left >= left - eps + && doc_rect.top >= top - eps + && doc_rect.right <= right + eps + && doc_rect.bottom <= bottom + eps + } + + /// Doc-space rect covered by the atlas, or `None` when the atlas + /// is empty. + /// + /// Currently dormant — pairs with the layout-drag atlas-sample + /// shortcut that hasn't landed cleanly yet. + #[allow(dead_code)] + pub fn atlas_doc_extents(&self) -> Option { + if !self.has_atlas() { + return None; + } + let atlas_scale = self.atlas_scale.max(0.01); + let left = self.atlas_origin.x; + let top = self.atlas_origin.y; + let right = left + (self.atlas_size.width as f32) / atlas_scale; + let bottom = top + (self.atlas_size.height as f32) / atlas_scale; + Some(skia::Rect::from_ltrb(left, top, right, bottom)) + } + + /// True iff the atlas exists at a usable scale for sampling at the + /// given `workspace_scale`. We accept some downscale (atlas may be + /// reduced when document bounds exceed the texture cap) since a + /// slightly resampled drag preview is still much better than the + /// alternative — re-rendering the dragged subtree, which freezes + /// the UI on layouts with many components. + /// + /// Currently dormant — pairs with the layout-drag atlas-sample + /// shortcut that hasn't landed cleanly yet. + #[allow(dead_code)] + pub fn atlas_supports_sample(&self, workspace_scale: f32) -> bool { + if !self.has_atlas() { + return false; + } + // Allow up to 2× upscale (atlas_scale ≥ 0.5 × workspace_scale). + // Below that the sprite gets visibly soft; better to fall back. + self.atlas_scale + 1e-4 >= workspace_scale * 0.5 + } + + /// Snapshot the atlas region covering `doc_rect` as a standalone + /// `skia::Image`. Used by the drag-sprite atlas-sample shortcut + /// (multi-id / layout drag path) to obtain a sprite without + /// re-rendering the dragged subtree. Returns `None` when the atlas + /// is empty or the region snapshot fails. Caller should pre-validate + /// coverage with `atlas_covers_doc_rect_strict`. + /// + /// Currently dormant — pairs with the layout-drag atlas-sample + /// shortcut that hasn't landed cleanly yet. + #[allow(dead_code)] + pub fn snapshot_atlas_doc_rect(&mut self, doc_rect: skia::Rect) -> Option { + if !self.has_atlas() { + return None; + } + let atlas_scale = self.atlas_scale.max(0.01); + let x = ((doc_rect.left - self.atlas_origin.x) * atlas_scale).floor() as i32; + let y = ((doc_rect.top - self.atlas_origin.y) * atlas_scale).floor() as i32; + let w = ((doc_rect.width() * atlas_scale).ceil() as i32).max(1); + let h = ((doc_rect.height() * atlas_scale).ceil() as i32).max(1); + // Clamp to atlas extents so the snapshot stays in-bounds even + // if the caller's `atlas_covers_doc_rect_at_scale` rounded down + // by a sub-pixel. + let aw = self.atlas_size.width; + let ah = self.atlas_size.height; + let x = x.clamp(0, aw.saturating_sub(1)); + let y = y.clamp(0, ah.saturating_sub(1)); + let w = w.min(aw - x).max(1); + let h = h.min(ah - y).max(1); + let irect = skia::IRect::from_xywh(x, y, w, h); + self.atlas.image_snapshot_with_bounds(irect) + } + /// Draw the persistent atlas onto the target using the current viewbox transform. /// Intended for fast pan/zoom-out previews (avoids per-tile composition). /// Clears Target to `background` first so atlas-uncovered regions don't