collective

Browse / github.com/storytold/photocraft / patch

perf(selection): use sparse combination for marquee Add and Subtract

perf(selection): use sparse combination for marquee Add and Subtract Marquee Add and Subtract still called `combine_surfaces`, which visits every tile in the canvas area even when the new shape is small. The sparse combiner already exists in `photocraft-algo` and is used by Select and Mask tool edits. ## Change - Use `combine_sparse` for the unfeathered `select.rect` Add and Subtract branches. The combiner visits the new shape's stored tiles and keeps the rest of the existing selection. - Keep the existing coverage formulas: `max(a, b)` for Add and `a * (1 - b)` for Subtract, including anti-aliased ellipse edges. - Intersect still uses `combine_surfaces`; fea

Status
open
Score
0.00
Adopters
0 other operators
Author
wanikhawar (imported from pull request #9813, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+91 −5 in 3 files
Review
pending
Shared
2026-10-28
Id
f9a00ba323a0ef177f324ea6

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd.rs crates/engine/src/tests.rs crates/engine/tests/it/empty_selection_combine.rs

diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs--- a/crates/engine/src/cmd.rs+++ b/crates/engine/src/cmd.rs@@ -2,7 +2,7 @@  use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size};-use photocraft_geom::Rect;+use photocraft_geom::{Rect, TILE_SIZE}; use photocraft_raster::Surface; use serde_json::{Map, Value, json}; @@ -417,15 +417,34 @@ fn build() -> Vec<CommandSpec> {                     }                     let old = doc.selection.take();                     let combined = match (mode.as_str(), old) {-                        ("add", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a.max(b)),-                        ("subtract", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a * (1.0 - b)),+                        ("add", Some(o)) => sel::combine_sparse(&o, &shape, area, |a, b| a.max(b)),+                        ("subtract", Some(o)) => sel::combine_sparse(&o, &shape, area, |a, b| a * (1.0 - b)),                         ("intersect", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a.min(b)),                         // With no existing selection, neither operation can select new pixels.                         ("subtract" | "intersect", None) => Surface::new(photocraft_color::PixelFormat::GRAY8),                         _ => shape,                     };-                    // Nothing selected on the canvas (e.g. dragged entirely outside it) deselects.-                    doc.selection = (!combined.content_bounds().is_empty()).then_some(combined);+                    // Sparse Add/Subtract keeps untouched pixels outside the canvas. Only pixels+                    // inside the document count when deciding whether a selection remains.+                    let has_on_canvas_pixels = (combined.default_bytes().first().copied().unwrap_or(0) != 0 && {+                        let canvas_tiles = u64::from(area.width().div_ceil(TILE_SIZE as u32)) * u64::from(area.height().div_ceil(TILE_SIZE as u32));+                        let stored = combined.tiles().filter(|(coord, _)| !coord.rect().intersect(&area).is_empty()).count() as u64;+                        stored < canvas_tiles+                    }) || combined.tiles().any(|(coord, tile)| {+                        let tile_rect = coord.rect();+                        let overlap = tile_rect.intersect(&area);+                        if overlap.is_empty() {+                            return false;+                        }+                        let x0 = (overlap.x0 - tile_rect.x0) as usize;+                        let x1 = (overlap.x1 - tile_rect.x0) as usize;+                        (overlap.y0 - tile_rect.y0..overlap.y1 - tile_rect.y0).any(|y| {+                            let row_start = y as usize * TILE_SIZE as usize;+                            let start = row_start + x0;+                            tile.bytes().get(start..row_start + x1).is_some_and(|row| row.iter().any(|&v| v != 0))+                        })+                    });+                    doc.selection = has_on_canvas_pixels.then_some(combined);                     Ok(())                 })?;                 Ok(Value::Null)diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -433,6 +433,34 @@ fn marquee_dragged_past_the_canvas_stops_at_its_edge() {     assert_eq!(bounds(&mut s), Value::Null); } +#[test]+fn subtract_clears_selection_when_only_disjoint_off_canvas_pixels_remain() {+    use photocraft_geom::Rect;+    use photocraft_raster::Surface;+    let mut s = session_with_doc(); // 64 × 48+    let mut selection = Surface::new(photocraft_color::PixelFormat::GRAY8);+    // Simulate a moved sparse selection: one remnant on either side of the canvas and one+    // in-canvas region that the subtract marquee will remove. The off-canvas bounds straddle+    // the canvas, so testing only their bounding-box intersection would incorrectly keep it.+    selection.fill_rect(Rect::new(-4, 10, 0, 14), &[1.0]);+    selection.fill_rect(Rect::new(20, 10, 24, 14), &[1.0]);+    selection.fill_rect(Rect::new(64, 10, 68, 14), &[1.0]);+    Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(selection);++    s.execute("select.rect", json!({"x": 0, "y": 0, "width": 64, "height": 48, "mode": "subtract"})).unwrap();++    assert!(s.active().unwrap().doc.selection.is_none());+}++#[test]+fn subtract_clears_selection_with_nonzero_default_when_canvas_is_empty() {+    use photocraft_raster::Surface;+    let mut s = session_with_doc();+    Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]));+    s.execute("select.rect", json!({"x": 0, "y": 0, "width": 64, "height": 48, "mode": "subtract"})).unwrap();+    assert!(s.active().unwrap().doc.selection.is_none());+}+ #[test] fn marquee_steps_are_named_after_their_tool() {     // #513: the elliptical marquee shares `select.rect` but records its own step name.diff --git a/crates/engine/tests/it/empty_selection_combine.rs b/crates/engine/tests/it/empty_selection_combine.rs--- a/crates/engine/tests/it/empty_selection_combine.rs+++ b/crates/engine/tests/it/empty_selection_combine.rs@@ -91,3 +91,42 @@ fn subtracting_from_an_existing_selection_preserves_other_pixels_and_history() {         assert_eq!(coverage(&session, 12, 12), removed);     } }++#[test]+fn marquee_add_subtract_match_dense_combination_across_tiles() {+    use photocraft_algo::selection as sel;+    use photocraft_color::PixelFormat;+    use photocraft_geom::Rect;+    use photocraft_raster::Surface;++    // A partial edge tile, a shape crossing a tile boundary, and an untouched distant tile.+    let area = Rect::from_xywh(0, 0, 777, 263);+    let r = Rect::from_xywh(247, -5, 24, 32);+    for depth in [8, 16, 32] {+        for ellipse in [false, true] {+            for mode in ["add", "subtract"] {+                let mut session = Session::new();+                session.execute("file.new", json!({"width": 777, "height": 263, "depth": depth})).unwrap();+                let mut old = Surface::new(PixelFormat::GRAY8);+                old.fill_rect(Rect::from_xywh(240, 0, 40, 40), &[0.6]);+                old.fill_rect(Rect::from_xywh(768, 256, 9, 7), &[0.4]);+                std::sync::Arc::make_mut(&mut session.active_mut().unwrap().doc).selection = Some(old.clone());+                let shape = if ellipse {+                    sel::ellipse_surface(r, area, true)+                } else {+                    let mut shape = Surface::new(PixelFormat::GRAY8);+                    shape.fill_rect(r.intersect(&area), &[1.0]);+                    shape+                };+                let expected = sel::combine_surfaces(&old, &shape, area, |a, b| if mode == "add" { a.max(b) } else { a * (1.0 - b) });+                session.execute("select.rect", json!({"x": 247, "y": -5, "width": 24, "height": 32, "ellipse": ellipse, "mode": mode})).unwrap();+                let pixels = |session: &Session| session.active().unwrap().doc.selection.as_ref().unwrap().read_region(area);+                assert_eq!(pixels(&session), expected.read_region(area), "depth={depth}, ellipse={ellipse}, mode={mode}");+                assert!(session.undo());+                assert_eq!(pixels(&session), old.read_region(area));+                assert!(session.redo());+                assert_eq!(pixels(&session), expected.read_region(area));+            }+        }+    }+}