From f62ee7d1ae092dfeb0f0c97056f1978be1e18a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 14 May 2026 12:56:54 +0200 Subject: [PATCH 1/4] :bug: Fix asset icon (#9612) --- .../src/app/main/ui/ds/foundations/assets/icon.cljs | 11 +++++++---- .../src/app/main/ui/workspace/sidebar/sitemap.scss | 8 -------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index f2ccc02d4a..35da1fc550 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -325,13 +325,16 @@ (let [size-px (cond (= size "l") icon-size-l (= size "s") icon-size-s :else icon-size-m) - + offset (if (or (= size "s") (= size "m")) + (/ (- icon-size-m size-px) 2) + 0) props (mf/spread-props props {:class [class (stl/css :icon)] - :width size-px - :height size-px})] - + :width (max icon-size-m size-px) + :height (max icon-size-m size-px)})] [:> :svg props [:use {:href (dm/str "#icon-" icon-id) + :x offset + :y offset :width size-px :height size-px}]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss index dd0370c5ed..72f028d09c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss @@ -96,14 +96,6 @@ height: deprecated.$s-32; width: deprecated.$s-24; padding: 0 deprecated.$s-4 0 deprecated.$s-8; - - svg { - @extend %button-icon-small; - - color: transparent; - fill: none; - stroke: var(--icon-foreground); - } } .page-actions { From 64f73ef23b92432488430d370d07fb494476e7e3 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Thu, 14 May 2026 12:57:10 +0200 Subject: [PATCH 2/4] :recycle: Remove Mutex from mem buffer (#9479) --- render-wasm/src/mem.rs | 74 +++++++++++++++++++++---------------- render-wasm/src/wasm/mem.rs | 25 ++++++++----- 2 files changed, 59 insertions(+), 40 deletions(-) diff --git a/render-wasm/src/mem.rs b/render-wasm/src/mem.rs index d03cb4fdc1..1c8531e7e9 100644 --- a/render-wasm/src/mem.rs +++ b/render-wasm/src/mem.rs @@ -1,61 +1,73 @@ -use std::sync::Mutex; - -use crate::error::{Error, Result, CRITICAL_ERROR}; +use crate::{error::Result, performance}; pub const LAYOUT_ALIGN: usize = 4; -pub static BUFFERU8: Mutex>> = Mutex::new(None); -pub static BUFFER_ERROR: Mutex = Mutex::new(0x00); +// Please, read about the #[allow(static_mut_refs)] +// +// If we don't put this allow, the compiler shows a warning like this: +// +// shared references to mutable statics are dangerous; it's undefined behavior +// if the static is mutated or if a mutable reference is created for it while +// the shared reference lives +// +// https://doc.rust-lang.org/edition-guide/rust-2024/static-mut-references.html +// +// But this isn't a problem in a single-threaded environment like WebAssembly +// because access/modification is always sequential, not parallel. +pub static mut BUFFERU8: Option> = None; +pub static mut BUFFER_ERROR: u8 = 0x00; pub fn clear_error_code() { - let mut guard = BUFFER_ERROR.lock().unwrap(); - *guard = 0x00; + unsafe { + BUFFER_ERROR = 0x00; + } } /// Sets the error buffer from a byte. Used by #[wasm_error] when E: Into. pub fn set_error_code(code: u8) { - let mut guard = BUFFER_ERROR.lock().unwrap(); - *guard = code; + unsafe { + BUFFER_ERROR = code; + } } #[no_mangle] pub extern "C" fn read_error_code() -> u8 { - if let Ok(guard) = BUFFER_ERROR.lock() { - *guard - } else { - CRITICAL_ERROR - } + unsafe { BUFFER_ERROR } } pub fn write_bytes(mut bytes: Vec) -> *mut u8 { - let mut guard = BUFFERU8.lock().unwrap(); + unsafe { + performance::begin_measure!("write_bytes"); + #[allow(static_mut_refs)] + if BUFFERU8.is_some() { + panic!("Bytes already allocated"); + } - if guard.is_some() { - panic!("Bytes already allocated"); + let ptr = bytes.as_mut_ptr(); + BUFFERU8 = Some(bytes); + performance::end_measure!("write_bytes"); + ptr } - - let ptr = bytes.as_mut_ptr(); - - *guard = Some(bytes); - ptr } pub fn bytes() -> Vec { - let mut guard = BUFFERU8.lock().unwrap(); - guard.take().expect("Buffer is not initialized") + unsafe { + #[allow(static_mut_refs)] + BUFFERU8.take().expect("Buffer is not initialized") + } } pub fn bytes_or_empty() -> Vec { - let mut guard = BUFFERU8.lock().unwrap(); - guard.take().unwrap_or_default() + unsafe { + #[allow(static_mut_refs)] + BUFFERU8.take().unwrap_or_default() + } } pub fn free_bytes() -> Result<()> { - let mut guard = BUFFERU8 - .lock() - .map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?; - *guard = None; - std::mem::drop(guard); + unsafe { + BUFFERU8 = None; + } Ok(()) } diff --git a/render-wasm/src/wasm/mem.rs b/render-wasm/src/wasm/mem.rs index 8f6b7508ef..4cff8d0cd2 100644 --- a/render-wasm/src/wasm/mem.rs +++ b/render-wasm/src/wasm/mem.rs @@ -9,15 +9,22 @@ use macros::wasm_error; #[no_mangle] #[wasm_error] pub extern "C" fn alloc_bytes(len: usize) -> Result<*mut u8> { - let mut guard = BUFFERU8 - .lock() - .map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?; - - if guard.is_some() { - return Err(Error::CriticalError("Bytes already allocated".to_string())); - } - unsafe { + // If we don't put this allow, the compiler shows a warning like this: + // + // shared references to mutable statics are dangerous; it's undefined behavior + // if the static is mutated or if a mutable reference is created for it while + // the shared reference lives + // + // https://doc.rust-lang.org/edition-guide/rust-2024/static-mut-references.html + // + // But this isn't a problem in a single-threaded environment like WebAssembly + // because access/modification is always sequential, not parallel. + #[allow(static_mut_refs)] + if BUFFERU8.is_some() { + return Err(Error::CriticalError("Bytes already allocated".to_string())); + } + let layout = Layout::from_size_align_unchecked(len, LAYOUT_ALIGN); let ptr = alloc(layout); if ptr.is_null() { @@ -25,7 +32,7 @@ pub extern "C" fn alloc_bytes(len: usize) -> Result<*mut u8> { } // TODO: Maybe this could be removed. ptr::write_bytes(ptr, 0, len); - *guard = Some(Vec::from_raw_parts(ptr, len, len)); + BUFFERU8 = Some(Vec::from_raw_parts(ptr, len, len)); Ok(ptr) } } From d4be6686c704ac1ca5065c07d766140acaae1b3c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 12 May 2026 13:43:16 +0200 Subject: [PATCH 3/4] :tada: Rebuild drag-crop cache from tile textures with hybrid atlas fill --- render-wasm/src/render.rs | 175 +++++++++++++++++++++++------ render-wasm/src/render/surfaces.rs | 122 +++++++++++++++++++- 2 files changed, 263 insertions(+), 34 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 44c5c7e7af..f52046ca8f 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -381,7 +381,7 @@ 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, - /// GPU crops from `Backbuffer` keyed by shape id. Filled on full-frame completion; during + /// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during /// drag, entries for the moved top-level selection are ensured here pub backbuffer_crop_cache: HashMap, } @@ -389,9 +389,6 @@ pub(crate) struct RenderState { pub struct InteractiveDragCrop { pub src_doc_bounds: Rect, pub src_selrect: Rect, - /// True if the captured crop bounds were fully inside the viewport at capture time. - /// Used to avoid serving partial/offscreen crops during interactive drag. - pub fits_viewport_at_capture: bool, /// Viewbox origin (doc-space) at capture time. pub capture_vb_left: f32, pub capture_vb_top: f32, @@ -401,6 +398,49 @@ pub struct InteractiveDragCrop { pub image: skia::Image, } +/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)` with each side +/// at most `max_side_px` (**without scaling**): centered on the projection of +/// `viewport_doc ∩ src_doc_bounds`, or on the full crop if that intersection is empty. +/// `max_side_px` should match [`Surfaces::max_texture_dimension_px`] (same budget as the atlas). +#[allow(clippy::too_many_arguments)] +fn drag_crop_snapshot_window_px( + max_side_px: i32, + out_w: i32, + out_h: i32, + viewport_doc: Rect, + vb_left: f32, + vb_top: f32, + scale: f32, + src_left_px: i32, + src_top_px: i32, + src_doc_bounds: Rect, +) -> (i32, i32, i32, i32) { + let cap = max_side_px.max(1); + if out_w <= cap && out_h <= cap { + return (0, 0, out_w, out_h); + } + let win_w = out_w.min(cap); + let win_h = out_h.min(cap); + + let mut vis = viewport_doc; + let has_vis = vis.intersect(src_doc_bounds); + let (cx, cy) = if !has_vis || vis.is_empty() { + (out_w as f32 * 0.5, out_h as f32 * 0.5) + } else { + let lx0 = (vis.left - vb_left) * scale - src_left_px as f32; + let ly0 = (vis.top - vb_top) * scale - src_top_px as f32; + let lx1 = (vis.right - vb_left) * scale - src_left_px as f32; + let ly1 = (vis.bottom - vb_top) * scale - src_top_px as f32; + ((lx0 + lx1) * 0.5, (ly0 + ly1) * 0.5) + }; + + let mut ox = (cx - win_w as f32 * 0.5).round() as i32; + let mut oy = (cy - win_h as f32 * 0.5).round() as i32; + ox = ox.clamp(0, out_w - win_w); + oy = oy.clamp(0, out_h - win_h); + (ox, oy, win_w, win_h) +} + pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { // First we retrieve the extended area of the viewport that we could render. let TileRect(isx, isy, iex, iey) = @@ -458,10 +498,7 @@ impl RenderState { return false; } - let Some(crop) = self.backbuffer_crop_cache.get(&node_id) else { - return false; - }; - if !crop.fits_viewport_at_capture { + if !self.backbuffer_crop_cache.contains_key(&node_id) { return false; } @@ -1666,10 +1703,12 @@ impl RenderState { self.backbuffer_crop_cache.clear(); // Collect candidate shapes that are "recortable" and visible in the current viewport. + // This is intentionally conservative; we only cache shapes that do not overlap with // ANY other candidate to guarantee the pixels under their bounds belong exclusively // to that shape in Backbuffer. let viewport = self.viewbox.area; + let scale = self.get_scale(); let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect) let root_ids: Vec = match tree.get(&Uuid::nil()) { @@ -1713,10 +1752,21 @@ impl RenderState { non_overlapping.push((*id, *bounds, *selrect)); } - // Snapshot from Backbuffer for each accepted shape. - let scale = self.get_scale(); let vb_left = self.viewbox.area.left; let vb_top = self.viewbox.area.top; + let (bb_w, bb_h) = self.surfaces.surface_size(SurfaceId::Backbuffer); + let max_snap_px = self.surfaces.max_texture_dimension_px(); + + // Snapshot the atlas once for the whole pass so that all shapes sharing + // the tile/atlas fallback path reuse the same GPU image rather than each + // triggering a separate `image_snapshot` flush. + let atlas_snap = self.surfaces.atlas_snapshot_for_drag_crop(); + + // Scratch surface reused across all shapes that need the tile/atlas + // fallback — avoids one WebGL texture allocation per shape. + // Created lazily on first use and grown if a later shape needs more space. + let mut scratch_surface: Option = None; + for (id, doc_bounds, selrect) in non_overlapping { let left = ((doc_bounds.left - vb_left) * scale).floor() as i32; let top = ((doc_bounds.top - vb_top) * scale).floor() as i32; @@ -1726,12 +1776,6 @@ impl RenderState { continue; } let src_irect = skia::IRect::new(left, top, right, bottom); - let Some(image) = self - .surfaces - .snapshot_rect(SurfaceId::Backbuffer, src_irect) - else { - continue; - }; let src_doc_bounds = Rect::new( src_irect.left as f32 / scale + vb_left, @@ -1739,30 +1783,92 @@ impl RenderState { src_irect.right as f32 / scale + vb_left, src_irect.bottom as f32 / scale + vb_top, ); - let fits_viewport_at_capture = doc_bounds.left >= viewport.left - && doc_bounds.top >= viewport.top - && doc_bounds.right <= viewport.right - && doc_bounds.bottom <= viewport.bottom; - // When the shape extends beyond the viewport to the left or top, - // `left`/`top` are negative. Skia clamps `makeImageSnapshot` to the - // surface bounds, so the returned image actually starts at pixel 0 — - // not at the negative coordinate. Store the clamped value so that - // `doc_left`/`doc_top` computed during the drag reflects the true - // image origin in the backbuffer. - let capture_src_left = left.max(0); - let capture_src_top = top.max(0); + let full_w = src_irect.width(); + let full_h = src_irect.height(); + let (win_ox, win_oy, win_w, win_h) = drag_crop_snapshot_window_px( + max_snap_px, + full_w, + full_h, + viewport, + vb_left, + vb_top, + scale, + src_irect.left, + src_irect.top, + src_doc_bounds, + ); + let window_irect = skia::IRect::new( + src_irect.left + win_ox, + src_irect.top + win_oy, + src_irect.left + win_ox + win_w, + src_irect.top + win_oy + win_h, + ); + + let src_doc_window = Rect::new( + window_irect.left as f32 / scale + vb_left, + window_irect.top as f32 / scale + vb_top, + window_irect.right as f32 / scale + vb_left, + window_irect.bottom as f32 / scale + vb_top, + ); + + let in_backbuffer = window_irect.left >= 0 + && window_irect.top >= 0 + && window_irect.right <= bb_w + && window_irect.bottom <= bb_h; + + let backbuffer_snap = if in_backbuffer { + self.surfaces + .snapshot_rect(SurfaceId::Backbuffer, window_irect) + } else { + None + }; + + let image = if let Some(img) = backbuffer_snap { + img + } else { + // Ensure the scratch surface is large enough for this window. + // Grow (reallocate) only when necessary so that the common case + // of similarly-sized shapes pays zero extra allocation cost. + let needs_alloc = scratch_surface + .as_ref() + .is_none_or(|s| s.width() < win_w || s.height() < win_h); + if needs_alloc { + scratch_surface = get_gpu_state() + .create_surface_with_isize( + "drag_crop_scratch".to_string(), + skia::ISize::new(win_w, win_h), + ) + .ok(); + } + let Some(scratch) = scratch_surface.as_mut() else { + continue; + }; + let Some(img) = self.surfaces.try_snapshot_doc_rect_from_tiles_and_atlas( + scratch, + atlas_snap.as_ref(), + src_doc_window, + window_irect, + win_w, + win_h, + vb_left, + vb_top, + scale, + ) else { + continue; + }; + img + }; self.backbuffer_crop_cache.insert( id, InteractiveDragCrop { - src_doc_bounds, + src_doc_bounds: src_doc_window, src_selrect: selrect, - fits_viewport_at_capture, capture_vb_left: vb_left, capture_vb_top: vb_top, - capture_src_left, - capture_src_top, + capture_src_left: window_irect.left, + capture_src_top: window_irect.top, image, }, ); @@ -3054,7 +3160,10 @@ impl RenderState { let x = (doc_left + translation.0) * scale; let y = (doc_top + translation.1) * scale; - canvas.draw_image(crop_image, (x, y), Some(&skia::Paint::default())); + let bw = crop_image.width() as f32; + let bh = crop_image.height() as f32; + let dst = skia::Rect::from_xywh(x, y, bw, bh); + canvas.draw_image_rect(crop_image, None, dst, &skia::Paint::default()); canvas.restore(); } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 6949e1ba2f..6368a8bf9e 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -5,7 +5,10 @@ use crate::{get_gpu_state, performance}; use skia_safe::{self as skia, IRect, Paint, RRect}; -use super::{gpu_state::GpuState, tiles::Tile, tiles::TileViewbox, tiles::TILE_SIZE}; +use super::{ + gpu_state::GpuState, + tiles::{self, Tile, TileViewbox, TILE_SIZE}, +}; use base64::{engine::general_purpose, Engine as _}; use std::collections::{HashMap, HashSet}; @@ -183,6 +186,11 @@ impl Surfaces { self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE); } + #[inline] + pub fn max_texture_dimension_px(&self) -> i32 { + self.max_atlas_texture_size + } + /// Sets the document-space bounds used to clamp atlas updates. /// Pass `None` to disable clamping. pub fn set_atlas_doc_bounds(&mut self, bounds: Option) { @@ -1053,6 +1061,118 @@ impl Surfaces { self.tiles.has(tile) } + /// Returns a snapshot of the atlas together with its scale and origin, so the + /// caller can take it **once** per `rebuild_backbuffer_crop_cache` and share it + /// across all shapes that need the tile/atlas fallback path — avoiding an + /// `image_snapshot` (and potential GPU flush) per shape. + pub fn atlas_snapshot_for_drag_crop(&mut self) -> Option<(skia::Image, f32, skia::Point)> { + if !self.has_atlas() { + return None; + } + Some(( + self.atlas.image_snapshot(), + self.atlas_scale.max(0.01), + self.atlas_origin, + )) + } + + /// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into + /// `scratch`, then returns the sub-region `[0, out_w) × [0, out_h)` as an image. + /// + /// `scratch` must be at least `out_w × out_h` pixels — the caller is responsible + /// for allocating (and **reusing across shapes**) a surface large enough to hold + /// the largest window needed in one `rebuild_backbuffer_crop_cache` pass. + /// + /// `atlas_snap` is a pre-snapshotted view of the persistent atlas produced by + /// [`Surfaces::atlas_snapshot_for_drag_crop`]; pass `None` when no atlas exists. + /// + /// For each tile cell intersecting `src_doc_bounds`: draws from + /// [`TileTextureCache`] when present; otherwise samples the atlas. + #[allow(clippy::too_many_arguments)] + pub fn try_snapshot_doc_rect_from_tiles_and_atlas( + &mut self, + scratch: &mut skia::Surface, + atlas_snap: Option<&(skia::Image, f32, skia::Point)>, + src_doc_bounds: skia::Rect, + src_irect: IRect, + out_w: i32, + out_h: i32, + vb_left: f32, + vb_top: f32, + scale: f32, + ) -> Option { + if out_w <= 0 || out_h <= 0 || src_doc_bounds.is_empty() { + return None; + } + + let canvas = scratch.canvas(); + canvas.clear(skia::Color::TRANSPARENT); + + let tile_size = tiles::get_tile_size(scale); + let tr = tiles::get_tiles_for_rect(src_doc_bounds, tile_size); + let ix0 = src_irect.left as f32; + let iy0 = src_irect.top as f32; + let paint = skia::Paint::default(); + + for ty in tr.y1()..=tr.y2() { + for tx in tr.x1()..=tr.x2() { + let tile = Tile(tx, ty); + let tile_doc = tiles::get_tile_rect(tile, scale); + let mut clip_doc = tile_doc; + if !clip_doc.intersect(src_doc_bounds) || clip_doc.is_empty() { + continue; + } + + let dst = skia::Rect::from_ltrb( + (clip_doc.left - vb_left) * scale - ix0, + (clip_doc.top - vb_top) * scale - iy0, + (clip_doc.right - vb_left) * scale - ix0, + (clip_doc.bottom - vb_top) * scale - iy0, + ); + + if let Some(tile_image) = self.tiles.get(tile) { + let iw = tile_image.width() as f32; + let ih = tile_image.height() as f32; + let td_w = tile_doc.width().max(1e-6); + let td_h = tile_doc.height().max(1e-6); + + let src = skia::Rect::from_ltrb( + ((clip_doc.left - tile_doc.left) / td_w) * iw, + ((clip_doc.top - tile_doc.top) / td_h) * ih, + ((clip_doc.right - tile_doc.left) / td_w) * iw, + ((clip_doc.bottom - tile_doc.top) / td_h) * ih, + ); + + canvas.draw_image_rect( + tile_image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &paint, + ); + } else { + let snap = atlas_snap?; + let (atlas, a_scale, atlas_origin) = (&snap.0, snap.1, snap.2); + let sx = (clip_doc.left - atlas_origin.x) * a_scale; + let sy = (clip_doc.top - atlas_origin.y) * a_scale; + let sw = clip_doc.width() * a_scale; + let sh = clip_doc.height() * a_scale; + if sw <= 0.0 || sh <= 0.0 { + continue; + } + let src = skia::Rect::from_xywh(sx, sy, sw, sh); + canvas.draw_image_rect( + atlas, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &paint, + ); + } + } + } + + scratch.image_snapshot_with_bounds(IRect::new(0, 0, out_w, out_h)) + } + pub fn remove_cached_tile_surface(&mut self, tile: Tile) { let gpu_state = get_gpu_state(); // Mark tile as invalid From 575f4b9df04287ea48868e04f5cf9ced2b6598e1 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 14 May 2026 11:33:41 +0200 Subject: [PATCH 4/4] :tada: Optimize drag-crop cache rebuild path --- render-wasm/src/render.rs | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index f52046ca8f..c5cde152ba 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1739,18 +1739,41 @@ impl RenderState { } // Filter out any candidate that overlaps with any other candidate. - let mut non_overlapping: Vec<(Uuid, Rect, Rect)> = Vec::new(); - 'outer: for (i, (id, bounds, selrect)) in candidates.iter().enumerate() { - for (j, (_id2, bounds2, _sel2)) in candidates.iter().enumerate() { - if i == j { - continue; + // Sort by left edge so the inner loop can break early once no further + // x-overlap is possible, reducing comparisons from O(N²) to O(N log N) + // in typical layouts where shapes are spread out. + candidates.sort_unstable_by(|a, b| { + a.1.left + .partial_cmp(&b.1.left) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let n = candidates.len(); + let mut is_overlapping = vec![false; n]; + for i in 0..n { + for j in (i + 1)..n { + if candidates[j].1.left >= candidates[i].1.right { + break; // sorted: no further x-overlap possible for i } - if bounds.intersects(*bounds2) { - continue 'outer; + if is_overlapping[i] && is_overlapping[j] { + continue; // both already excluded, skip check + } + if candidates[i].1.intersects(candidates[j].1) { + is_overlapping[i] = true; + is_overlapping[j] = true; } } - non_overlapping.push((*id, *bounds, *selrect)); } + let non_overlapping: Vec<(Uuid, Rect, Rect)> = candidates + .iter() + .zip(is_overlapping.iter()) + .filter_map(|((id, bounds, selrect), ov)| { + if !ov { + Some((*id, *bounds, *selrect)) + } else { + None + } + }) + .collect(); let vb_left = self.viewbox.area.left; let vb_top = self.viewbox.area.top;