collective

Browse / github.com/storytold/photocraft / patch

perf(selection): crop Color Range sampling to the active selection

perf(selection): crop Color Range sampling to the active selection Color Range rendered the entire document and converted every pixel to Lab even when Replace mode discarded everything outside an existing selection. A 24 MP RGBA f32 composite alone occupied 384 MB. Replace now samples the selection's nonzero bounds, clipped to the canvas. Composite rendering or active-layer reads, Lab conversion, coverage, clipping to prior coverage, and selection replacement share that rectangle. Hue, tonal and skin-tone presets use the same bounded sampling. Eyedropper and subtract points still sample anywhere on the original canvas. Localized Color Clusters retain canvas

Status
open
Score
0.00
Adopters
0 other operators
Author
wanikhawar (imported from pull request #9814, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+395 −39 in 6 files
Review
pending
Shared
2026-10-28
Id
dec4bce6a4bae126b5fa274b

How it went

No other operator has reported using it yet.

Diff

raw · .github/workflows/ci.yml crates/engine/src/cmd/selection_cmds.rs crates/engine/src/cmd/selection_cmds/color_range_performance.rs crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs crates/engine/src/cmd/selection_cmds/tests.rs docs/color-range-performance.md

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml--- a/.github/workflows/ci.yml+++ b/.github/workflows/ci.yml@@ -11,6 +11,10 @@ on:     types: [opened, synchronize, reopened, labeled]   workflow_dispatch:     inputs:+      color_range:+        description: "Compare cropped Color Range on 24 MP (Linux, exact outputs)"+        type: boolean+        default: false       selection_morphology:         description: "Compare selection morphology on 36 MP (Linux, exact outputs)"         type: boolean@@ -424,6 +428,36 @@ jobs:           name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}           path: crates/${{ matrix.target.crate }}/fuzz/artifacts +  color-range-performance:+    if: inputs.color_range == true+    runs-on: ubuntu-latest+    timeout-minutes: 45+    env:+      RAYON_NUM_THREADS: "4"+    steps:+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+        with:+          persist-credentials: false+      - name: Install Linux build dependencies+        uses: storytold/craft-common-actions/actions/install-apt-packages@main+        with:+          packages: >-+            libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev+            libgtk-3-dev+      - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable+      - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3+        with:+          key: color-range-perf+      - name: Compare exact outputs and release timings on 24 MP+        run: |+          set -o pipefail+          cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture | tee color-range-perf.txt+      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2+        if: always()+        with:+          name: color-range-performance+          path: color-range-perf.txt+   selection-morphology:     if: inputs.selection_morphology == true     runs-on: ubuntu-latest@@ -459,7 +493,7 @@ jobs:           path: selection-morphology-perf.txt    selection-morphology-quick-perf:-    if: inputs.selection_morphology == true+    if: inputs.selection_morphology == true || inputs.color_range == true     # Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by     # bevel_bench, so its required GPU rows cannot be measured there.     runs-on: macos-15diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs--- a/crates/engine/src/cmd/selection_cmds.rs+++ b/crates/engine/src/cmd/selection_cmds.rs@@ -13,6 +13,11 @@ use crate::{EngineError, Result, Session};  mod modify_window; +#[cfg(test)]+mod color_range_performance;+#[cfg(test)]+mod color_range_window_tests;+ fn has_doc(s: &Session) -> std::result::Result<(), String> {     s.active().map(|_| ()).ok_or_else(|| "no document open".into()) }@@ -45,15 +50,18 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM }  /// RGBA pixels the tools sample: the composite, or the active layer.-fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> {+fn sample_pixels(s: &Session, all_layers: bool, window: Option<Rect>) -> Result<(Rect, Vec<[f32; 4]>)> {     let d = s.active().ok_or(EngineError::NoDocument)?;-    let area = d.doc.bounds();+    let area = window.map_or(d.doc.bounds(), |r| r.intersect(&d.doc.bounds()));     // Match the decoders' raster budget before either the layer or composite path allocates.     let len = area.size().area();     let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };     if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 {         return Err(EngineError::Other("the canvas is too large to sample for a selection".into()));     }+    if area.is_empty() {+        return Ok((area, Vec::new()));+    }     let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface());     match (all_layers, layer) {         (false, Some(surf)) => {@@ -149,31 +157,6 @@ fn parse_color(p: &Value) -> [f32; 3] {     } } -/// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point-/// and where it was picked (area-local, for Localized Color Clusters).-fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result<Vec<sel::RangeSample>> {-    let Some(pts) = p.get(key) else { return Ok(Vec::new()) };-    let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?;-    let w = area.width() as usize;-    let mut out = Vec::with_capacity(pts.len().min(4096));-    for pt in pts {-        let xy = pt.as_array().and_then(|a| match a.as_slice() {-            [x, y] => Some((x.as_f64()?, y.as_f64()?)),-            _ => None,-        });-        let Some((x, y)) = xy.filter(|(x, y)| x.is_finite() && y.is_finite()) else {-            return Err(bad(format!("bad point {pt} in `{key}` (want [x, y])")));-        };-        let (xi, yi) = (x.floor() as i32, y.floor() as i32);-        let q = if area.contains(xi, yi) { px.get((yi - area.y0) as usize * w + (xi - area.x0) as usize) } else { None };-        let Some(&[r, g, b, _]) = q else {-            return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas")));-        };-        out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) });-    }-    Ok(out)-}- /// Composite RGB (the compositor's output profile for the document) → CIE Lab D50. fn lab_transform(s: &Session) -> Result<std::sync::Arc<photocraft_cms::Transform>> {     let d = s.active().ok_or(EngineError::NoDocument)?;@@ -346,6 +329,23 @@ impl ColorRangeSamples {     } } +/// Replace refines the existing selection; no output outside its nonzero bounds survives.+fn color_range_area(doc: &Document, m: SelectionMode) -> Rect {+    let canvas = doc.bounds();+    let Some(selection) = doc.selection.as_ref().filter(|_| m == SelectionMode::Replace) else { return canvas };+    // content_bounds describes differences from the default, not implicit selected pixels.+    if selection.default_pixel().first().is_some_and(|v| *v != 0.0) {+        return canvas;+    }+    // content_bounds orders raw tile coordinates by their differences. Reject coordinates+    // outside the pixel-addressable range before those differences can overflow.+    let valid = (i32::MIN / photocraft_geom::TILE_SIZE)..=(i32::MAX / photocraft_geom::TILE_SIZE);+    if selection.tiles().any(|(tile, _)| !valid.contains(&tile.tx) || !valid.contains(&tile.ty)) {+        return canvas;+    }+    selection.content_bounds().intersect(&canvas)+}+ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {     const CMD: &str = "select.colorRange";     let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg };@@ -357,17 +357,16 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {     if b(p, "detectFaces", false) {         return Err(bad("detectFaces: face detection isn't available yet; select without it".into()));     }+    let area = color_range_area(&s.active().ok_or(EngineError::NoDocument)?.doc, selection_mode);     let (area, mut mask) = match preset {         "sampledColors" => {-            let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;+            let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?;             let w = area.width() as usize;             // The dialog's eyedroppers: colours picked on the image, at their positions.             // Like Photoshop's, they may sample anywhere on the canvas: an active selection only             // limits the result (scope_color_range), not where colours are picked (#5244).-            let plus = eyedropper_samples(p, "points", area, &px, &bad)?;-            let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?;-            let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?;-            let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]);+            let query = ColorRangeSamples::new(s, p)?;+            let mask = query.coverage(&px, w, 1.0, [area.x0 as f32, area.y0 as f32]);             (area, mask)         }         "reds" | "yellows" | "greens" | "cyans" | "blues" | "magentas" => {@@ -379,11 +378,11 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {                 "blues" => 240.0,                 _ => 300.0,             };-            let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;+            let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?;             (area, sel::hue_range(&px, center))         }         "skinTones" => {-            let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;+            let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?;             (area, sel::skin_tones(&px, fuzz(40.0).min(200.0)))         }         "highlights" | "midtones" | "shadows" => {@@ -406,7 +405,7 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {                     .filter(|(l, h)| l <= h)                     .ok_or_else(|| bad(format!("tonalRange: want [low, high] levels 0..255 (got {v})")))?,             };-            let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;+            let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?;             (area, sel::tone_range(&px, lo, hi, fuzz(20.0).min(100.0) / 100.0 * 255.0))         }         "outOfGamut" => {@@ -545,7 +544,7 @@ fn crop_mask(m: &[f32], pw: usize, pad: usize, w: usize, h: usize) -> Vec<f32> { /// the selected pixels' colour range widened by the tolerance. fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result<Value> {     let tol = f(p, "tolerance", 32.0) / 255.0;-    let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false))?;+    let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false), None)?;     let (_, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc);     let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);     for (q, v) in px.iter().zip(&m) {diff --git a/crates/engine/src/cmd/selection_cmds/color_range_performance.rs b/crates/engine/src/cmd/selection_cmds/color_range_performance.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/selection_cmds/color_range_performance.rs@@ -0,0 +1,70 @@+//! Opt-in release comparison; full-canvas buffers belong on a CI runner.+use super::*;+use photocraft_color::{ColorMode, PixelFormat, SampleType};+use photocraft_doc::{Layer, LayerContent, Size};+use std::time::Instant;++fn reference(s: &mut Session, p: &Value) {+    let (canvas, px) = sample_pixels(s, true, None).unwrap();+    // Previous path: eyedropper colour came from the full composite already in memory.+    let [r, g, b, _] = px[1500 * canvas.width() as usize + 2000];+    let point = sel::RangeSample { color: [r, g, b], at: Some((2000.0, 1500.0)) };+    let query = ColorRangeSamples::with_samples(s, p, canvas, &[point], &[]).unwrap();+    let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [0.0, 0.0]);+    drop(px);+    scope_color_range(&mut mask, &s.active().unwrap().doc, canvas, SelectionMode::Replace);+    set_selection(s, "Color Range", canvas, mask, SelectionMode::Replace).unwrap();+}++#[test]+#[ignore = "24 MP release comparison: run on GitHub Actions"]+fn color_range_release_comparison() {+    let mut doc = Document::new("Color Range performance", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8);+    let mut pixels = Surface::with_default(PixelFormat::RGBA8, &[0.65, 0.3, 0.2, 1.0]);+    pixels.fill_rect(Rect::new(3000, 2000, 3600, 2400), &[0.2, 0.3, 0.65, 1.0]);+    doc.layers.push(Layer::new("image", LayerContent::Raster(pixels)));+    let p = json!({"points": [[2000, 1500]], "fuzziness": 40, "localized": true, "range": 80});+    // Small, 1%, 4%, and full selections: include dense fallback cost in the comparison.+    for bounds in [Rect::new(2975, 1975, 3025, 2025), Rect::new(2700, 1800, 3300, 2200), Rect::new(2400, 1600, 3600, 2400), doc.bounds()] {+        let mut selection = Surface::new(PixelFormat::GRAY8);+        selection.fill_rect(bounds, &[128.0 / 255.0]);+        doc.selection = Some(selection);+        let mut old_times = Vec::new();+        let mut new_times = Vec::new();+        let mut expected = None;+        // One warmup pair, followed by three alternating measured pairs.+        for round in 0..4 {+            for old in if round % 2 == 0 { [true, false] } else { [false, true] } {+                let mut session = Session::new();+                session.add_document(doc.clone(), None);+                let now = Instant::now();+                if old {+                    reference(&mut session, &p);+                } else {+                    session.execute("select.colorRange", p.clone()).unwrap();+                }+                let elapsed = now.elapsed().as_secs_f64() * 1000.0;+                if round > 0 {+                    if old { old_times.push(elapsed) } else { new_times.push(elapsed) }+                }+                // Outside timing, compare every stored sample over the whole canvas.+                let actual = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds());+                if let Some(ref expected) = expected {+                    assert_eq!(&actual, expected);+                } else {+                    expected = Some(actual);+                }+            }+        }+        old_times.sort_by(f64::total_cmp);+        new_times.sort_by(f64::total_cmp);+        let (old, new) = (old_times[1], new_times[1]);+        println!(+            "24 MP Color Range {}x{}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x rgba_bytes={} exact=true",+            bounds.width(),+            bounds.height(),+            old / new,+            bounds.size().area() * 16+        );+    }+}diff --git a/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs b/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs@@ -0,0 +1,211 @@+use super::*;+use photocraft_color::{ColorMode, PixelFormat, SampleType};+use photocraft_geom::Size;+use serde_json::json;++fn fixture(depth: SampleType, p3: bool) -> Session {+    let mut s = Session::new();+    let mut doc = Document::with_background("color range", Size::new(64, 48), ColorMode::Rgb, depth, photocraft_doc::Color::WHITE);+    if p3 {+        doc.icc_profile = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes());+    } else {+        doc.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes());+    }+    let bg = doc.layers[0].surface_mut().unwrap();+    bg.fill_rect(Rect::new(0, 0, 64, 48), &[0.92, 0.92, 0.92, 1.0]);+    bg.fill_rect(Rect::new(7, 8, 22, 23), &[0.85, 0.12, 0.08, 1.0]);+    bg.fill_rect(Rect::new(35, 8, 50, 23), &[0.12, 0.72, 0.18, 1.0]);+    bg.fill_rect(Rect::new(7, 29, 22, 44), &[0.12, 0.20, 0.86, 1.0]);+    bg.fill_rect(Rect::new(35, 29, 50, 44), &[0.72, 0.48, 0.31, 1.0]);+    s.add_document(doc, None);+    s+}++fn coverage_at(s: &Session, x: i32, y: i32) -> f32 {+    s.active().and_then(|d| d.doc.selection.as_ref()).map_or(0.0, |m| m.sample_channel(x, y, 0))+}++fn full_sampled_reference(s: &Session, params: &Value) -> Vec<f32> {+    let doc = &s.active().unwrap().doc;+    let canvas = doc.bounds();+    let (_, px) = sample_pixels(s, b(params, "sampleAllLayers", true), None).unwrap();+    // Reproduce the previous command: read eyedropper colours from the full composite.+    let samples = |key: &str| -> Vec<sel::RangeSample> {+        params+            .get(key)+            .and_then(Value::as_array)+            .into_iter()+            .flatten()+            .map(|point| {+                let x = point[0].as_f64().unwrap().floor() as usize;+                let y = point[1].as_f64().unwrap().floor() as usize;+                let pixel = px[y * canvas.width() as usize + x];+                sel::RangeSample { color: [pixel[0], pixel[1], pixel[2]], at: Some((x as f32, y as f32)) }+            })+            .collect()+    };+    let query = ColorRangeSamples::with_samples(s, params, canvas, &samples("points"), &samples("subtractPoints")).unwrap();+    let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [canvas.x0 as f32, canvas.y0 as f32]);+    if b(params, "invert", false) {+        mask.iter_mut().for_each(|v| *v = 1.0 - *v);+    }+    scope_color_range(&mut mask, doc, canvas, mode(params));+    sel::mask_from_surface(sel::combine(doc.selection.as_ref(), &mask, canvas, mode(params)).as_ref(), canvas)+}++#[test]+fn sampled_color_cropped_results_match_full_canvas_for_depths_profiles_and_sample_options() {+    for (depth, p3) in [SampleType::U8, SampleType::U16, SampleType::F32].into_iter().flat_map(|depth| [false, true].map(|p3| (depth, p3))) {+        for params in [+            json!({"points": [[40, 15]], "fuzziness": 12}),+            json!({"points": [[40, 15]], "subtractPoints": [[10, 15]], "order": ["-", "+"], "fuzziness": 18}),+            json!({"points": [[40, 15]], "localized": true, "range": 80, "invert": true}),+            json!({"points": [[10, 15]], "localized": true, "range": 25}),+            json!({"points": [[40, 15]], "sampleAllLayers": false, "fuzziness": 0}),+        ] {+            let mut s = fixture(depth, p3);+            s.execute("select.rect", json!({"x": 3, "y": 5, "width": 22, "height": 25})).unwrap();+            let expected = full_sampled_reference(&s, &params);+            s.execute("select.colorRange", params.clone()).unwrap();+            let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds());+            assert_eq!(actual, expected, "depth={depth:?} p3={p3} params={params}");+        }+    }+}++#[test]+fn range_presets_are_scoped_and_non_replace_modes_keep_full_canvas_semantics() {+    for preset in ["reds", "yellows", "greens", "cyans", "blues", "magentas", "highlights", "midtones", "shadows", "skinTones"] {+        let mut s = fixture(SampleType::U8, false);+        s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap();+        s.execute("select.colorRange", json!({"select": preset})).unwrap();+        assert_eq!(coverage_at(&s, 42, 15), 0.0, "preset {preset} escaped selection");+    }+    let mut s = fixture(SampleType::U8, false);+    s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap();+    s.execute("select.colorRange", json!({"points": [[40, 15]], "fuzziness": 0, "mode": "add"})).unwrap();+    assert_eq!(coverage_at(&s, 42, 15), 1.0);+    s.execute("edit.undo", json!({})).unwrap();+    assert_eq!(coverage_at(&s, 42, 15), 0.0);+    s.execute("edit.redo", json!({})).unwrap();+    assert_eq!(coverage_at(&s, 42, 15), 1.0);+}++#[test]+fn window_defaults_and_hostile_tiles_fall_back_and_empty_selection_is_safe() {+    let canvas = Rect::new(0, 0, 64, 48);+    let mut doc = Document::new("window", Size::new(64, 48), ColorMode::Rgb, SampleType::U8);+    doc.selection = Some(Surface::with_default(PixelFormat::GRAY8, &[1.0]));+    assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas);+    doc.selection = Some(Surface::new(PixelFormat::GRAY8));+    assert_eq!(color_range_area(&doc, SelectionMode::Replace), Rect::new(0, 0, 0, 0));+    use photocraft_geom::TileCoord;+    use std::sync::Arc;+    let mut hostile = Surface::new(PixelFormat::GRAY8);+    let tile = hostile.solid_tile(&[1.0]);+    hostile.put_tiles([(TileCoord::new(i32::MIN, i32::MIN), Arc::clone(&tile)), (TileCoord::new(i32::MAX, i32::MAX), tile)]);+    doc.selection = Some(hostile);+    assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas);+    assert_eq!(color_range_area(&doc, SelectionMode::Add), canvas);+    doc.selection = Some(Surface::new(PixelFormat::GRAY8));+    let mut s = Session::new();+    s.add_document(doc, None);+    assert!(sample_pixels(&s, true, Some(color_range_area(&s.active().unwrap().doc, SelectionMode::Replace))).unwrap().1.is_empty());+    let err = s.execute("select.colorRange", json!({"select": "shadows", "tonalRange": -1})).unwrap_err();+    assert!(err.to_string().contains("tonalRange"));+    for params in [json!({"points": [[64, 0]]}), json!({"points": "invalid"}), json!({"localized": true}), json!({"order": ["+"]})] {+        assert!(s.execute("select.colorRange", params).is_err());+    }+    s.execute("select.colorRange", json!({"points": [[1, 1]], "invert": true})).unwrap();+    assert!(s.active().unwrap().doc.selection.is_none());+}++#[test]+fn sparse_large_selection_samples_only_its_clipped_window() {+    let mut doc = Document::new("sparse", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8);+    let selection = sel::mask_to_surface(&vec![1.0; 24 * 20], Rect::new(2990, 1990, 3014, 2010));+    doc.selection = Some(selection);+    let mut s = Session::new();+    s.add_document(doc, None);+    let win = color_range_area(&s.active().unwrap().doc, SelectionMode::Replace);+    assert_eq!(win, Rect::new(2990, 1990, 3014, 2010));+    let (area, pixels) = sample_pixels(&s, true, Some(win)).unwrap();+    assert_eq!(pixels.len(), area.size().area() as usize);+    s.execute("select.colorRange", json!({"select": "highlights"})).unwrap();+}++#[test]+fn cropped_range_matches_full_canvas_combinations_with_soft_holes_and_clipped_masks() {+    let canvas = Rect::new(0, 0, 64, 48);+    for case in 0..6 {+        for selection_mode in ["replace", "add", "subtract", "intersect"] {+            for preset in ["sampledColors", "reds", "greens", "blues", "highlights", "midtones", "shadows", "skinTones"] {+                for invert in [false, true] {+                    let mut s = fixture(SampleType::U16, true);+                    let mut selection = match case {+                        0 => Surface::new(PixelFormat::GRAY8),+                        1 => Surface::with_default(PixelFormat::GRAY8, &[128.0 / 255.0]),+                        _ => sel::mask_to_surface(&vec![128.0 / 255.0; 30 * 30], Rect::new(-5, 5, 25, 35)),+                    };+                    if case == 3 {+                        selection.fill_rect(Rect::new(8, 10, 15, 20), &[0.0]);+                    }+                    if case == 5 {+                        selection = Surface::new(PixelFormat::GRAY8);+                    }+                    if case == 4 {+                        selection = sel::mask_to_surface(&[1.0; 4], Rect::new(-8, -8, -6, -6));+                    }+                    let original = (case != 0).then_some(selection);+                    let mut prior = s.active().unwrap().doc.as_ref().clone();+                    prior.selection = original;+                    let before = sel::mask_from_surface(prior.selection.as_ref(), canvas);+                    let mut unselected = prior.clone();+                    unselected.selection = None;+                    let mut full = Session::new();+                    full.add_document(unselected, None);+                    let params = json!({"select": preset, "points": [[40, 15]], "fuzziness": 40, "invert": invert});+                    full.execute("select.colorRange", params.clone()).unwrap();+                    let mut mask = sel::mask_from_surface(full.active().unwrap().doc.selection.as_ref(), canvas);+                    let m = SelectionMode::parse(selection_mode);+                    scope_color_range(&mut mask, &prior, canvas, m);+                    let expected = sel::combine(prior.selection.as_ref(), &mask, canvas, m);+                    s.edit("prior selection", |doc, _| {+                        doc.selection = prior.selection.clone();+                        Ok(())+                    })+                    .unwrap();+                    let mut params = params;+                    params["mode"] = json!(selection_mode);+                    s.execute("select.colorRange", params).unwrap();+                    let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas);+                    assert_eq!(actual, sel::mask_from_surface(expected.as_ref(), canvas), "case {case}, {selection_mode}, {preset}, invert {invert}");+                    s.execute("edit.undo", json!({})).unwrap();+                    assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), before);+                    s.execute("edit.redo", json!({})).unwrap();+                    assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), actual);+                }+            }+        }+    }+}++#[test]+fn cropped_samples_match_full_composite_with_translucent_layers_and_active_layer_sampling() {+    use photocraft_doc::{Layer, LayerContent};+    for all_layers in [false, true] {+        let mut s = fixture(SampleType::F32, true);+        s.edit("overlay", |doc, _| {+            let mut pixels = Surface::new(PixelFormat::RGBA32F);+            pixels.fill_rect(Rect::new(0, 0, 64, 48), &[0.3, 0.1, 0.6, 0.35]);+            doc.layers.push(Layer::new("overlay", LayerContent::Raster(pixels)));+            Ok(())+        })+        .unwrap();+        s.execute("select.rect", json!({"x": 5, "y": 6, "width": 20, "height": 30})).unwrap();+        let params = json!({"points": [[40, 15], [10, 15]], "subtractPoints": [[40, 35]], "sampleAllLayers": all_layers, "localized": true, "range": 70});+        let expected = full_sampled_reference(&s, &params);+        s.execute("select.colorRange", params).unwrap();+        assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds()), expected);+    }+}diff --git a/crates/engine/src/cmd/selection_cmds/tests.rs b/crates/engine/src/cmd/selection_cmds/tests.rs--- a/crates/engine/src/cmd/selection_cmds/tests.rs+++ b/crates/engine/src/cmd/selection_cmds/tests.rs@@ -60,7 +60,7 @@ fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() {         None,     );     for all_layers in [false, true] {-        assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample"));+        assert!(sample_pixels(&s, all_layers, None).unwrap_err().to_string().contains("too large to sample"));     }     assert!(!s.active().unwrap().history.can_undo()); }@@ -275,7 +275,7 @@ fn color_range_prepared_query_samples_visible_layers_not_the_active_layer() {         clear_selection(&mut s);         let p = json!({"points": [[7, 7]], "sampleAllLayers": all_layers, "fuzziness": 0});         let query = ColorRangeSamples::new(&s, &p).unwrap();-        let (area, px) = sample_pixels(&s, all_layers).unwrap();+        let (area, px) = sample_pixels(&s, all_layers, None).unwrap();         let shown = query.coverage(&px, area.width() as usize, 1.0, [0.0, 0.0]);         s.execute("select.colorRange", p).unwrap();         assert_eq!(shown[7 * 40 + 7], coverage(&s, 7, 7));diff --git a/docs/color-range-performance.md b/docs/color-range-performance.mdnew file mode 100644--- /dev/null+++ b/docs/color-range-performance.md@@ -0,0 +1,42 @@+# Color Range selection sampling++`select.colorRange` in Replace mode refines an existing selection. Its sampled-colour,+hue, skin-tone and tonal queries render/read only `selection.content_bounds()` clipped+to the canvas. RGBA buffers, Lab conversion, coverage, selection clipping and replacement+use that same rectangle. Invert happens before clipping to the old coverage, including+soft edges and holes.++Eyedropper points and subtract points still sample anywhere on the original canvas.+Localized Color Clusters retain the full canvas's range and sample positions; the+coverage query receives the sampled rectangle's origin. Without a selection, or with+Add, Subtract or Intersect, sampling covers the full canvas. Nonzero implicit selection+defaults and malformed tile coordinates also use the full canvas. An explicitly empty+selection produces an empty result while still validating query parameters.++The Out of Gamut preset uses the existing proof gamut-mask path and is not cropped.+Interactive preview resolution and scheduling are unchanged.++On a 6000×4000 canvas, the RGBA f32 buffer normally contains 24,000,000 pixels+(384,000,000 bytes). A 50×50 selection uses 2,500 pixels (40,000 bytes); a 600×400+selection uses 240,000 pixels (3,840,000 bytes). Lab input/output and mask buffers+shrink by the same area ratio. These are buffer sizes, not measured peak RSS.++## Reproduce the comparison++On a CI runner, run:++```sh+RAYON_NUM_THREADS=4 cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture+```++Or dispatch `ci.yml` with `color_range=true`. It runs the Linux release comparison,+full normal CI, and the general quick performance suite on macOS. The release fixture+is 24 MP with two colour regions and localized eyedropper sampling. It compares the+full-canvas reference with the cropped command for 50×50, 600×400, 1200×800 and+full-canvas soft selections. Each has a warm-up pair and three alternating timed+pairs; reported timings are medians. Both include the undoable selection edit, and the+cropped path includes command dispatch. Every stored coverage sample is compared over+the whole canvas outside timing. No GUI frame latency is measured.++This optimization does not change an existing scorecard scenario or baseline. The+request's P5 label is unrelated to scorecard P5 (layer visibility).